{"_id":"@bradpurchase/remix-auth-webauthn","_rev":"1-39b6d1e1c294b89d9015cec4ba3f7bfb","name":"@bradpurchase/remix-auth-webauthn","description":"Authenticate users with [Web Authentication](https://www.w3.org/TR/webauthn-2/) passkeys and physical tokens. It is implemented using [SimpleWebAuthn](https://simplewebauthn.dev) and supports user authentication and user registration using passkeys.","dist-tags":{"latest":"0.3.3"},"versions":{"0.3.2":{"name":"@bradpurchase/remix-auth-webauthn","version":"0.3.2","keywords":["remix","remix-auth","auth","authentication","strategy","webauthn","passkey","fido"],"author":{"name":"Alex Anderson"},"license":"MIT","_id":"@bradpurchase/remix-auth-webauthn@0.3.2","maintainers":[{"name":"bradpurchase","email":"bradpurchase@gmail.com"}],"contributors":[{"url":"https://github.com/alexanderson1993","name":"Alex Anderson"},{"url":"https://github.com/bradpurchase","name":"Brad Purchase"}],"homepage":"https://github.com/bradpurchase/remix-auth-webauthn#readme","bugs":{"url":"https://github.com/bradpurchase/remix-auth-webauthn/issues"},"dist":{"shasum":"d23d611388af9d0db4cfb661f6a3f78429112fac","tarball":"https://registry.npmjs.org/@bradpurchase/remix-auth-webauthn/-/remix-auth-webauthn-0.3.2.tgz","fileCount":3,"integrity":"sha512-IScF/t/oUNSwgYTV1daMvBG9Skh1LmYXLyY8vt4QB71HPcZifDV3SbkK+D7YAc+Lo1tKAOthiZqvovN91A+5Og==","signatures":[{"sig":"MEUCIQCETfoyy75fj2YARO0iJUFjoU7AIs9JdRiu8UKAxp4fGQIgVzReOYJgxdgKlRH4u+kFOrtNXpcbOYxtgk5Vl1HUn7k=","keyid":"SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA"}],"unpackedSize":18471},"type":"module","exports":{".":{"types":"./build/server.d.ts","import":"./build/server.js","require":"./build/server.js"},"./server":{"types":"./build/server.d.ts","import":"./build/server.js","require":"./build/server.js"},"./browser":{"types":"./build/browser.d.ts","import":"./build/browser.js","require":"./build/browser.js"}},"gitHead":"f7125a53ccd748f22c2fc28da52641335585de90","scripts":{"lint":"eslint --ext .ts,.tsx src/","build":"tsc --project tsconfig.json && npx esbuild src/* --outdir=build --platform=node --format=esm","typecheck":"tsc --project tsconfig.json --noEmit"},"_npmUser":{"name":"bradpurchase","email":"bradpurchase@gmail.com"},"repository":{"url":"git+https://github.com/bradpurchase/remix-auth-webauthn.git","type":"git"},"_npmVersion":"10.5.0","description":"Authenticate users with [Web Authentication](https://www.w3.org/TR/webauthn-2/) passkeys and physical tokens. It is implemented using [SimpleWebAuthn](https://simplewebauthn.dev) and supports user authentication and user registration using passkeys.","directories":{},"_nodeVersion":"20.12.2","dependencies":{"remix-auth":"^3.6.0","tiny-webcrypto":"^1.0.1","@simplewebauthn/server":"^10.0.0","@simplewebauthn/browser":"^10.0.0"},"_hasShrinkwrap":false,"devDependencies":{"jest":"^26.6.3","react":"^18.2.0","eslint":"^7.26.0","esbuild":"^0.17.14","ts-node":"^9.1.1","prettier":"^2.3.2","babel-jest":"^26.6.3","typescript":"^4.3.5","@babel/core":"^7.14.2","@types/jest":"^26.0.23","@remix-run/node":"^1.14.3","jest-fetch-mock":"^3.0.3","@remix-run/react":"^1.14.3","@babel/preset-env":"^7.14.1","eslint-plugin-jest":"^24.3.6","@babel/preset-react":"^7.13.13","@simplewebauthn/types":"^10.0.0","eslint-plugin-unicorn":"^32.0.1","eslint-config-prettier":"^8.3.0","eslint-plugin-jest-dom":"^3.9.0","eslint-plugin-prettier":"^3.4.0","@babel/preset-typescript":"^7.13.0","@remix-run/server-runtime":"^1.14.3","@typescript-eslint/parser":"^4.23.0","@typescript-eslint/eslint-plugin":"^4.23.0"},"peerDependencies":{"@remix-run/server-runtime":"^2.0.0"},"_npmOperationalInternal":{"tmp":"tmp/remix-auth-webauthn_0.3.2_1717103470630_0.5700747511615656","host":"s3://npm-registry-packages"}},"0.3.3":{"name":"@bradpurchase/remix-auth-webauthn","version":"0.3.3","exports":{".":{"types":"./build/server.d.ts","require":"./build/server.js","import":"./build/server.js"},"./browser":{"types":"./build/browser.d.ts","require":"./build/browser.js","import":"./build/browser.js"},"./server":{"types":"./build/server.d.ts","require":"./build/server.js","import":"./build/server.js"}},"author":{"name":"Alex Anderson"},"type":"module","contributors":[{"name":"Alex Anderson","url":"https://github.com/alexanderson1993"},{"name":"Brad Purchase","url":"https://github.com/bradpurchase"}],"repository":{"type":"git","url":"git+https://github.com/bradpurchase/remix-auth-webauthn.git"},"bugs":{"url":"https://github.com/bradpurchase/remix-auth-webauthn/issues"},"scripts":{"build":"tsc --project tsconfig.json && npx esbuild src/* --outdir=build --platform=node --format=esm","typecheck":"tsc --project tsconfig.json --noEmit","lint":"eslint --ext .ts,.tsx src/"},"keywords":["remix","remix-auth","auth","authentication","strategy","webauthn","passkey","fido"],"license":"MIT","peerDependencies":{"@remix-run/server-runtime":"^2.0.0"},"devDependencies":{"@babel/core":"^7.14.2","@babel/preset-env":"^7.14.1","@babel/preset-react":"^7.13.13","@babel/preset-typescript":"^7.13.0","@remix-run/node":"^1.14.3","@remix-run/react":"^1.14.3","@remix-run/server-runtime":"^1.14.3","@simplewebauthn/types":"^10.0.0","@types/jest":"^26.0.23","@typescript-eslint/eslint-plugin":"^4.23.0","@typescript-eslint/parser":"^4.23.0","babel-jest":"^26.6.3","esbuild":"^0.17.14","eslint":"^7.26.0","eslint-config-prettier":"^8.3.0","eslint-plugin-jest":"^24.3.6","eslint-plugin-jest-dom":"^3.9.0","eslint-plugin-prettier":"^3.4.0","eslint-plugin-unicorn":"^32.0.1","jest":"^26.6.3","jest-fetch-mock":"^3.0.3","prettier":"^2.3.2","react":"^18.2.0","ts-node":"^9.1.1","typescript":"^4.3.5"},"dependencies":{"@simplewebauthn/browser":"^10.0.0","@simplewebauthn/server":"^10.0.0","remix-auth":"^3.6.0","tiny-webcrypto":"^1.0.1"},"_id":"@bradpurchase/remix-auth-webauthn@0.3.3","gitHead":"8b35406cf3cddccdcb3a00cba170850966b63d80","description":"Authenticate users with [Web Authentication](https://www.w3.org/TR/webauthn-2/) passkeys and physical tokens. It is implemented using [SimpleWebAuthn](https://simplewebauthn.dev) and supports user authentication and user registration using passkeys.","homepage":"https://github.com/bradpurchase/remix-auth-webauthn#readme","_nodeVersion":"20.12.2","_npmVersion":"10.5.0","dist":{"integrity":"sha512-U6zx9Wylcjee+F1jmh1jzr9a7XDd8ig5gCqxXqCXCmDqaQQBD9VPzrLo0IwHFadd4oJ0++6ArzhFIxRgBwdv1Q==","shasum":"cab71499c25a74fd49fcc27427d311ce54c1a7ee","tarball":"https://registry.npmjs.org/@bradpurchase/remix-auth-webauthn/-/remix-auth-webauthn-0.3.3.tgz","fileCount":7,"unpackedSize":33730,"signatures":[{"keyid":"SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA","sig":"MEYCIQCpAb8ulkEvpx1TlXgM1huveoMMh6RDp+OAGdC9F/REkwIhAMjAxffVBWb67CZxIFH5t0KK5LF69dfbjQi6er5gZUxx"}]},"_npmUser":{"name":"bradpurchase","email":"bradpurchase@gmail.com"},"directories":{},"maintainers":[{"name":"bradpurchase","email":"bradpurchase@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages","tmp":"tmp/remix-auth-webauthn_0.3.3_1717107028046_0.3093562062734099"},"_hasShrinkwrap":false}},"time":{"created":"2024-05-30T21:11:10.540Z","modified":"2024-05-30T22:10:28.523Z","0.3.2":"2024-05-30T21:11:10.791Z","0.3.3":"2024-05-30T22:10:28.337Z"},"maintainers":[{"name":"bradpurchase","email":"bradpurchase@gmail.com"}],"author":{"name":"Alex Anderson"},"repository":{"type":"git","url":"git+https://github.com/bradpurchase/remix-auth-webauthn.git"},"keywords":["remix","remix-auth","auth","authentication","strategy","webauthn","passkey","fido"],"license":"MIT","homepage":"https://github.com/bradpurchase/remix-auth-webauthn#readme","bugs":{"url":"https://github.com/bradpurchase/remix-auth-webauthn/issues"},"readme":"# WebAuthn Strategy - Remix Auth\n\nAuthenticate users with [Web Authentication](https://www.w3.org/TR/webauthn-2/) passkeys and physical tokens. It is implemented using [SimpleWebAuthn](https://simplewebauthn.dev) and supports user authentication and user registration using passkeys.\n\n> This package should be considered unstable. It works in my limited testing, but I haven't covered every case or written automated tests. _Caveat emptor_.\n\n## Supported runtimes\n\n| Runtime    | Has Support |\n| ---------- | ----------- |\n| Node.js    | ✅          |\n| Cloudflare | ❓          |\n\n> I haven't tested it in a Cloudflare environment. If you do, let me know how it goes!\n\n<!-- If it doesn't support one runtime, explain here why -->\n\n> This package also only supports ESM, because package.json is scary and I'm not certain how to set up the necessary build steps. You might need to add this to your `serverDependenciesToBundle` in your remix.config.js file.\n\n## About Web Authentication\n\nWeb Authentication lets a user register a device as a passkey. The device could be a USB device, like a Yubikey, the computer running the webpage, or a separate Bluetooth connected device like a smartphone. [This page has a good summary of the benefits](https://developer.apple.com/passkeys/), and you can [try it firsthand here](https://webauthn.io).\n\nWebAuthn follows a two-step process. First, a device is _registered_ as a passkey. The browser generates a private/public key pair, associates it with a user ID and username, and sends the public key to the server to be verified. At this point the server could create a new user with that passkey, or if the user is already signed in the server could associate that passkey with the existing user.\n\nIn the _authentication_ step, the browser uses the passkey's private key to sign a challenge sent by the server, which the server checks with its stored public key in the verification step.\n\nThis strategy handles generating the challenge, storing it in session storage, passing the WebAuthn options to the client, generating the passkeys, and verifying the passkeys. Since this strategy requires database persistence and browser-based APIs, it requires a bit more work to set up.\n\n> Note: This strategy also requires generating string user IDs on the browser. If your setup requires generating IDs, you might have to work around this limitation by creating a mapping of the authenticator userIds and your actual userIds.\n\n## Setup\n\n### Install\n\nThis project depends on `remix-auth`. Install it and [follow the setup instructions](https://github.com/sergiodxa/remix-auth).\n\n```\nnpm install remix-auth remix-auth-webauthn\n```\n\n### Database\n\nThis strategy requires database access to store user Authenticators. The kind of database doesn't matter, but the strategy expects authenticators to match this interface (as provided by @simplewebauthn/server):\n\n```ts\ninterface Authenticator {\n  // SQL: Encode to base64url then store as `TEXT` or a large `VARCHAR(511)`. Index this column\n  credentialID: string;\n  // Some reference to the user object. Consider indexing this column too\n  userId: string;\n  // SQL: Encode to base64url and store as `TEXT`\n  credentialPublicKey: string;\n  // SQL: Consider `BIGINT` since some authenticators return atomic timestamps as counters\n  counter: number;\n  // SQL: `VARCHAR(32)` or similar, longest possible value is currently 12 characters\n  // Ex: 'singleDevice' | 'multiDevice'\n  credentialDeviceType: string;\n  // SQL: `BOOL` or whatever similar type is supported\n  credentialBackedUp: boolean;\n  // SQL: `VARCHAR(255)` and store string array or a CSV string\n  // Ex: ['usb' | 'ble' | 'nfc' | 'internal']\n  transports: string;\n}\n```\n\nIf you're just playing around, you can use this stub in-memory database.\n\n<details>\n<summary>Show Code</summary>\n\n```ts\n// /app/db.server.ts\nimport { type Authenticator } from \"remix-auth-webauthn/server\";\n\nexport type User = { id: string; username: string };\n\nconst authenticators = new Map<string, Authenticator>();\nconst users = new Map<string, User>();\nexport function getAuthenticatorById(id: string) {\n  return authenticators.get(id) || null;\n}\nexport function getAuthenticators(user: User | null) {\n  if (!user) return [];\n\n  const userAuthenticators: Authenticator[] = [];\n  authenticators.forEach((authenticator) => {\n    if (authenticator.userId === user.id) {\n      userAuthenticators.push(authenticator);\n    }\n  });\n\n  return userAuthenticators;\n}\nexport function getUserByUsername(username: string) {\n  users.forEach((user) => {\n    if (user.username === username) {\n      return user;\n    }\n  });\n  return null;\n}\nexport function getUserById(id: string) {\n  return users.get(id) || null;\n}\nexport function createAuthenticator(\n  authenticator: Omit<Authenticator, \"userId\">,\n  userId: string\n) {\n  authenticators.set(authenticator.credentialID, { ...authenticator, userId });\n}\nexport function createUser(username: string) {\n  const user = { id: Math.random().toString(36), username };\n  users.set(user.id, user);\n  return user;\n}\n```\n\n> Note that this database will reset every time your server restarts, but any passkeys you generate will still be present on your device. You'll have to manually delete them.\n\n</details>\n\n### Create the strategy instance\n\nThis strategy tries not to make assumptions about your database structure, so it requires several configuration options. Also, to give you access to the methods on the WebAuthnStrategy instance, create and export it before passing it to `authenticator.use`.\n\n```ts\n// /app/authenticator.server.ts\nimport { WebAuthnStrategy } from \"remix-auth-webauthn/server\";\nimport {\n  getAuthenticators,\n  getUserByUsername,\n  getAuthenticatorById,\n  type User,\n  createUser,\n  createAuthenticator,\n  getUserById,\n} from \"./db\";\nimport { Authenticator } from \"remix-auth\";\nimport { sessionStorage } from \"./session.server\";\n\nexport let authenticator = new Authenticator<User>(sessionStorage);\n\nexport const webAuthnStrategy = new WebAuthnStrategy<User>(\n  {\n    // The human-readable name of your app\n    // Type: string | (response:Response) => Promise<string> | string\n    rpName: \"Remix Auth WebAuthn\",\n    // The hostname of the website, determines where passkeys can be used\n    // See https://www.w3.org/TR/webauthn-2/#relying-party-identifier\n    // Type: string | (response:Response) => Promise<string> | string\n    rpID: (request) => new URL(request.url).hostname,\n    // Website URL (or array of URLs) where the registration can occur\n    origin: (request) => new URL(request.url).origin,\n    // Return the list of authenticators associated with this user. You might\n    // need to transform a CSV string into a list of strings at this step.\n    getUserAuthenticators: async (user) => {\n      const authenticators = await getAuthenticators(user);\n\n      return authenticators.map((authenticator) => ({\n        ...authenticator,\n        transports: authenticator.transports.split(\",\"),\n      }));\n    },\n    // Transform the user object into the shape expected by the strategy.\n    // You can use a regular username, the users email address, or something else.\n    getUserDetails: (user) =>\n      user ? { id: user.id, username: user.username } : null,\n    // Find a user in the database with their username/email.\n    getUserByUsername: (username) => getUserByUsername(username),\n    getAuthenticatorById: (id) => getAuthenticatorById(id),\n  },\n  async function verify({ authenticator, type, username }) {\n    // Verify Implementation Here\n  }\n);\n\nauthenticator.use(webAuthnStrategy);\n```\n\n### Write your verify function\n\nThe verify function handles both the _registration_ and _authentication_ steps, and expects you to return a `user` object or throw an error if verification fails.\n\nThe verify function will receive an Authenticator object (without the userId), the provided username, and the type of verification - either `registration` or `authentication`.\n\nNote: It should be possible to expand this to support giving a single user multiple passkeys by checking to see if the user is already logged in.\n\n```ts\nconst webAuthnStrategy = new WebAuthnStrategy(\n  {\n    // Options here...\n  },\n  async function verify({ authenticator, type, username }) {\n    let user: User | null = null;\n    const savedAuthenticator = await getAuthenticatorById(\n      authenticator.credentialID\n    );\n    if (type === \"registration\") {\n      // Check if the authenticator exists in the database\n      if (savedAuthenticator) {\n        throw new Error(\"Authenticator has already been registered.\");\n      } else {\n        // Username is null for authentication verification,\n        // but required for registration verification.\n        // It is unlikely this error will ever be thrown,\n        // but it helps with the TypeScript checking\n        if (!username) throw new Error(\"Username is required.\");\n        user = await getUserByUsername(username);\n\n        // Don't allow someone to register a passkey for\n        // someone elses account.\n        if (user) throw new Error(\"User already exists.\");\n\n        // Create a new user and authenticator\n        user = await createUser(username);\n        await createAuthenticator(authenticator, user.id);\n      }\n    } else if (type === \"authentication\") {\n      if (!savedAuthenticator) throw new Error(\"Authenticator not found\");\n      user = await getUserById(savedAuthenticator.userId);\n    }\n\n    if (!user) throw new Error(\"User not found\");\n    return user;\n  }\n);\n```\n\n### Set up your login page loader and action\n\nThe login page will need a loader to supply the WebAuthn options from the server, and an action to deliver the passkey back to the server.\n\n```ts\n// /app/routes/_auth.login.ts\nexport async function loader({ request, response }: LoaderFunctionArgs) {\n  const user = await authenticator.isAuthenticated(request);\n  let session = await sessionStorage.getSession(\n    request.headers.get(\"Cookie\")\n  );\n\n  const options = webAuthnStrategy.generateOptions(request, user);\n\n  // Set the challenge in a session cookie so it can be accessed later.\n  session.set(\"challenge\", options.challenge)\n  \n  // Update the cookie\n  response.headers.append(\"Set-Cookie\", await sessionStorage.commitSession(session))\n  response.headers.set(\"Cache-Control\":\"no-store\")\n\n  return options;\n}\n\nexport async function action({ request }: ActionFunctionArgs) {\n  try {\n    await authenticator.authenticate(\"webauthn\", request, {\n      successRedirect: \"/\",\n    });\n    return { error: null };\n  } catch (error) {\n    // This allows us to return errors to the page without triggering the error boundary.\n    if (error instanceof Response && error.status >= 400) {\n      return { error: (await error.json()) as { message: string } };\n    }\n    throw error;\n  }\n}\n```\n\nIf you choose to store the challenge somewhere other than session storage, such as in a database, you can pass it as context to the authenticate function in your action.\n\n```ts\nexport async function action({ request }: ActionFunctionArgs) {\n  const challenge = await getChallenge(request)\n  try {\n    await authenticator.authenticate(\"webauthn\", request, {\n      successRedirect: \"/\",\n      context: { challenge }\n    });\n    return { error: null };\n  } catch (error) {\n    // This allows us to return errors to the page without triggering the error boundary.\n    if (error instanceof Response && error.status >= 400) {\n      return { error: (await error.json()) as { message: string } };\n    }\n    throw error;\n  }\n}\n```\n\n## Set up the form\n\nFor ease-of-use, this strategy provides an `onSubmit` handler which performs the necessary browser-side actions to generate passkeys. The `onSubmit` handler is generated by passing in the options object from the loader above. Depending on your setup, you might need to implement separate forms for registration and authentication.\n\nWhen registering, the process follows a few steps:\n\n1. When first visiting the login page, the server will provide an options object which can be used for both registration and authentication.\n2. The user requests registration by entering their desired username and pressing the \"Check Username\" button, which submits a GET request to get updated options.\n3. The server responds with whether the username is taken and if the user already has registered a passkey so the browser doesn't produce duplicates.\n4. The form must be submitted a second time, as POST this time, with the actual passkey for registration.\n5. The server verifies the passkey, creates the new user, and logs the user in.\n\nYour registration form should include a required `username` field and `<button name=\"intent\" value=\"registration\">` for triggering registration. You can use `formMethod=\"GET\"` on a submit button to submit the value of the `username` field to the loader to check if the username is available. The `registration` button should change state and behavior based on whether the options from the loader indicate that the username is available. This is demonstrated below.\n\nAuthentication is a simpler process and only requires one button press:\n\n1. The user requests authentication, and the browser shows the available passkeys for the domain.\n2. The user picks a passkey, and the form is generated and submitted to the server.\n3. The server verifies the passkey by checking it against the database, and logs the user in.\n\nSince the username is stored with the passkey in the browser, the `username` field is not required for the authentication form, but you should include a submit button like so: `<button name=\"intent\" value=\"authentication\">` to trigger the authentication flow.\n\nHere's what the forms might look like in practice:\n\n```tsx\n// /app/routes/_auth.login.ts\nimport { handleFormSubmit } from \"remix-auth-webauthn/browser\";\n\nexport default function Login() {\n  const options = useLoaderData<typeof loader>();\n  const actionData = useActionData<typeof action>();\n  return (\n    <Form onSubmit={handleFormSubmit(options)} method=\"POST\">\n      <label>\n        Username\n        <input type=\"text\" name=\"username\" />\n      </label>\n      <button formMethod=\"GET\">Check Username</button>\n      <button\n        name=\"intent\"\n        value=\"registration\"\n        disabled={options.usernameAvailable !== true}\n      >\n        Register\n      </button>\n      <button name=\"intent\" value=\"authentication\">\n        Authenticate\n      </button>\n      {actionData?.error ? <div>{actionData.error.message}</div> : null}\n    </Form>\n  );\n}\n```\n\nYou can set the [`attestationType`](https://simplewebauthn.dev/docs/packages/server#1a-supported-attestation-formats) in the second parameter of `handleFormSubmit`. If omitted, it defaults to `none`:\n\n```tsx\nonSubmit={handleFormSubmit(options, { attestationType: \"direct\" })}\n```\n\n## TODO\n\n- Implement [Conditional UI](https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-Conditional-UI)\n","readmeFilename":"README.md","contributors":[{"name":"Alex Anderson","url":"https://github.com/alexanderson1993"},{"name":"Brad Purchase","url":"https://github.com/bradpurchase"}]}