MasterKale / SimpleWebAuthn

WebAuthn, Simplified. A collection of TypeScript-first libraries for simpler WebAuthn integration. Supports modern browsers, Node, Deno, and more.
https://simplewebauthn.dev
MIT License
1.62k stars 137 forks source link

ignore `node:crypto` import with webpack in `@simplewebauthn/server` #517

Closed balazsorban44 closed 9 months ago

balazsorban44 commented 9 months ago

Hi, I'm the creator/maintainer of Auth.js, a runtime/framework agnostic set of auth libraries for modern applications.

We recently added experimental WebAuthn support via your amazing libraries (thank you!), but we realized that our Next.js users would see a node:crypto import error when @simplewebauthn/server ended up being imported in the Edge runtime: https://github.com/nextauthjs/next-auth/pull/9919

This happened because of the import on this line: https://github.com/MasterKale/SimpleWebAuthn/blob/ba7048cfd791cd9400871d008d21d1354543e2b3/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts#L52

Related issues I could find: https://github.com/MasterKale/SimpleWebAuthn/issues/386, https://github.com/MasterKale/SimpleWebAuthn/issues/427, https://github.com/MasterKale/SimpleWebAuthn/issues/471

All modern runtimes have globalThis.crypto available, the only exception is Node.js 18. I would like to propose dropping anything related to polyfilling globalThis.crypto.

Possible solutions:

  1. Drop Node.js 18 support (Support ends in 1 year, arguably would be too big of a change yet, leave it up to you to decide)
  2. users on Node.js 18 could be suggested to run Node.js with --experimental-global-webcrypto,
  3. users on Node.js 18 could be suggested to add globalThis.crypto ??= await import("node:crypto").webcrypto in an initialization phase of their code.
  4. If the user uses this library via a framework, they might already not have to worry about this. In our case for example, Next.js polyfills (with method 3.) globalThis.crypto for us.

NOTE: I do understand that this might only be a problem for us currently, but in my opinion, a library should solely rely on its runtime to do polyfilling, if necessary. You are also currently shipping this extra code for all users who are not on Node.js 18. I'm curious if you agree with me! :pray:

If there are more questions or you need more context, let me know!

Thanks for the great work! :green_heart:

MasterKale commented 9 months ago

Hello @balazsorban44, thank you for starting a dialog here about this. Congratulations on your success with Auth.js. I'm honored you would consider leveraging my library to bring passkey auth to it 🚀

I feel the need to start off by saying that I wouldn't characterize how getWebCrypto() works as polyfilling anything. Historically "polyfilling" meant introducing additional code to fill in gaps when a native runtime does not fully implement the desired API. In contrast, getWebCrypto() attempts to leverage the same native JS API but from two possible locations.

NOTE: I simplified some of the code samples below to keep some Deno-specific code architecture out of the discussion.

It first tries to use globalThis.crypto when it's available, to try and leverage the fact that more modern ESM-first runtimes typically make WebCrypto available at this import path (which as we know includes Node LTS v20+):

  /**
   * Naively attempt to access Crypto as a global object, which popular alternative run-times
   * support.
   */
  const _globalThisCrypto = globalThis.crypto;

  if (_globalThisCrypto) {
    webCrypto = _globalThisCrypto;
    return webCrypto;
  }

If that fails then it tries to import('node:crypto') to try and support runtimes that are either an older version of Node (starting with Node v16) or have implemented some kind of Node compatibility:

  /**
   * `globalThis.crypto` isn't available, so attempt a Node import...
   */
  let _nodeCrypto;
  try {
    // dnt-shim-ignore
    _nodeCrypto = await import('node:crypto');
  } catch (_err) {
    /**
     * Intentionally declaring webcrypto as undefined because we're assuming the Node import
     * failed due to either:
     * - `import()` isn't supported
     * - `node:crypto` is unavailable.
     */
    _nodeCrypto = { webcrypto: undefined };
  }

  if (_nodeCrypto?.webcrypto) {
    webCrypto = _nodeCrypto.webcrypto as Crypto;
    return webCrypto;
  }

And if neither of these succeed then a MissingWebCrypto exception is thrown. For the record, SimpleWebAuthn makes no attempt to try and define its own webcrypto functionality if things get this far.

I say all of this upfront because...

  1. users on Node.js 18 could be suggested to add globalThis.crypto ??= await import("node:crypto").webcrypto in an initialization phase of their code.
  2. If the user uses this library via a framework, they might already not have to worry about this. In our case for example, Next.js polyfills (with method 3.) globalThis.crypto for us.

...based on my understanding of all the various runtimes and how they expose WebCrypto, the ultimate attempt to await import('node:crypto') can only happen if globalThis.crypto is undefined before this library is used. Might this be some kind of weird race condition with how Next.js ensures that globalThis.crypto is populated in its Edge runtime?

MasterKale commented 9 months ago

Something else that would help is at least a stacktrace, if not a basic list of repro steps I can use to recreate the issue on my end. I looked through https://github.com/nextauthjs/next-auth/pull/9919 and it's not clear to me why the changes in that PR fixed whatever problem initiated the work that became that PR.

MasterKale commented 9 months ago

This causes an issue, not sure if it's the one you're talking about:

import { generateAuthenticationOptions } from '@simplewebauthn/server';

export const runtime = 'edge';

export default async function Home() {
  const options = await generateAuthenticationOptions();
  return (
    <>
      <pre>{JSON.stringify(options, null, 2)}</pre>
    </>
  );
}

Screenshot 2024-02-07 at 10 21 09 PM

@balazsorban44 I'll continue to investigate this, but when you can it would be helpful to get confirmation that this is the issue we're talking about.

MasterKale commented 9 months ago

I believe I've identified a fix for this. The word webpack in the stacktrace kept jumping out at me. After some searching I was reminded about webpack's webpackIgnore magic comment 🤔

What I'm seeing on my end is that when I change this line in getWebCrypto()...

const _nodeCrypto = await import('node:crypto');

...to this...

const _nodeCrypto = await import(/* webpackIgnore: true */ 'node:crypto');

...then the Next.js code I posted above loads just fine with const runtime = 'edge':

Screenshot 2024-02-07 at 10 55 05 PM

I confirmed that the same code works when setting const runtime = 'nodejs' (the default according to here) so I can't see why this won't be a solution to an issue I ran into locally that sounds like the one that you described.

balazsorban44 commented 9 months ago

https://github.com/MasterKale/SimpleWebAuthn/issues/517#issuecomment-1933430690 is a sufficient reproduction.

~I believe webpack tries to eagerly evaluate the import, even if the code is not reached. If I put a console.log right before the import, it is indeed not executed. Trying to verify this with someone.~

Webpack tries to compile the code, because it can't really know if something is unreachable at runtime or not.

In the meantime, if the fix for us would be to add /* webpackIgnore: true */ (which I also verified), that would be sufficient closure to this issue.

Technically, the more correct solution would be to have a Node.js specific export condition, but that's a stretch for supporting Node.js 18 only.


On polyfilling/not polyfilling. The terminology I used might be incorrect, and I saw that you are not using any third-party lib, but only trying to rely on the runtime-native implementation (kudos for that).

I guess my point in general was to try to avoid even that, and strictly stick with the standard Web APIs in the library, and make it up to the runtime environment to patch up inconsistencies like missing globalThis.crypto.

Your lib would be cleaner, and would put pressure on runtimes not following the standard Web APIs. (which as far as I can tell is only Node.js 18 at this point, so we are in a very good shape 🥳)

balazsorban44 commented 9 months ago

Another note. According to https://github.com/MasterKale/SimpleWebAuthn/tree/master/packages/server#simplewebauthnserver-

Node.js 16 is still supported, which is EOL. Maybe, just maybe, v10 could drop support for Node.js 18, and drop the whole getWebCrypto.ts and related test file. :slightly_smiling_face:

MasterKale commented 9 months ago

Node.js 16 is still supported, which is EOL. Maybe, just maybe, v10 could drop support for Node.js 18, and drop the whole getWebCrypto.ts and related test file. 🙂

Thanks for the reminder that I need to update the project's minimum Node runtime, you're right that I should drop at least v16 support if not v18 too. I've created #519 for myself in response ✌️

MasterKale commented 9 months ago

I've just published @simplewebauthn/server@9.0.2 that should fix this issue. Thanks again for stopping by @balazsorban44 🙏