diff --git a/CHANGELOG.md b/CHANGELOG.md index 446d43d..460d688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ This is a *BREAKING CHANGE*. - Upgrade to SimpleWebAuthn v10, which requires Node v20 TLS. Make sure you upgrade to Node 20 before using this package. +- `webAuthnStrategy.generateOptions` no longer returns `json` data, to better support Single Fetch. You'll need to manually store the challenge in the session or some other storage. + +```ts +// /app/routes/_auth.login.ts +export async function loader({ request, response }: LoaderFunctionArgs) { + const user = await authenticator.isAuthenticated(request); + let session = await sessionStorage.getSession( + request.headers.get("Cookie") + ); + + const options = webAuthnStrategy.generateOptions(request, user); + + // Set the challenge in a session cookie so it can be accessed later. + session.set("challenge", options.challenge) + + // Update the cookie + response.headers.append("Set-Cookie", await sessionStorage.commitSession(session)) + response.headers.set("Cache-Control":"no-store") + + return options; +} +``` ## 0.2.1 diff --git a/README.md b/README.md index 616f558..87df3dd 100644 --- a/README.md +++ b/README.md @@ -233,10 +233,22 @@ The login page will need a loader to supply the WebAuthn options from the server ```ts // /app/routes/_auth.login.ts -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ request, response }: LoaderFunctionArgs) { const user = await authenticator.isAuthenticated(request); + let session = await sessionStorage.getSession( + request.headers.get("Cookie") + ); + + const options = webAuthnStrategy.generateOptions(request, user); + + // Set the challenge in a session cookie so it can be accessed later. + session.set("challenge", options.challenge) + + // Update the cookie + response.headers.append("Set-Cookie", await sessionStorage.commitSession(session)) + response.headers.set("Cache-Control":"no-store") - return webAuthnStrategy.generateOptions(request, sessionStorage, user); + return options; } export async function action({ request }: ActionFunctionArgs) { @@ -255,6 +267,27 @@ export async function action({ request }: ActionFunctionArgs) { } ``` +If 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. + +```ts +export async function action({ request }: ActionFunctionArgs) { + const challenge = await getChallenge(request) + try { + await authenticator.authenticate("webauthn", request, { + successRedirect: "/", + context: { challenge } + }); + return { error: null }; + } catch (error) { + // This allows us to return errors to the page without triggering the error boundary. + if (error instanceof Response && error.status >= 400) { + return { error: (await error.json()) as { message: string } }; + } + throw error; + } +} +``` + ## Set up the form For 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. diff --git a/src/server.ts b/src/server.ts index 643b56f..4c8739d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import { - json, type SessionStorage, type SessionData, + Session, } from "@remix-run/server-runtime"; import { AuthenticateOptions, @@ -31,7 +31,7 @@ export interface Authenticator { credentialPublicKey: string; counter: number; credentialDeviceType: string; - credentialBackedUp: number; + credentialBackedUp: boolean; transports: string; } @@ -183,14 +183,9 @@ export class WebAuthnStrategy extends Strategy< async generateOptions( request: Request, - sessionStorage: SessionStorage, user: User | null, extraData?: ExtraData ) { - let session = await sessionStorage.getSession( - request.headers.get("Cookie") - ); - let authenticators: WebAuthnAuthenticator[] = []; let userDetails: UserDetails | null = null; let usernameAvailable: boolean | null = null; @@ -231,17 +226,20 @@ export class WebAuthnStrategy extends Strategy< extra: extraData as ExtraKey, }; - session.set("challenge", options.challenge); - - return json(options, { - status: 200, - headers: { - "Set-Cookie": await sessionStorage.commitSession(session), - "Cache-Control": "no-store", - }, - }); + return options; + } + private getChallenge( + session: Session, + options: AuthenticateOptions + ) { + if ( + typeof options.context?.challenge === "string" && + options.context?.challenge !== "" + ) { + return options.context.challenge; + } + return session.get("challenge"); } - async authenticate( request: Request, sessionStorage: SessionStorage, @@ -258,12 +256,13 @@ export class WebAuthnStrategy extends Strategy< if (request.method !== "POST") throw new Error("The WebAuthn strategy only supports POST requests."); - const expectedChallenge = session.get("challenge"); + const expectedChallenge = this.getChallenge(session, options); - if (!expectedChallenge) + if (!expectedChallenge) { throw new Error( - "Expected challenge not found. Please pass it as an option to the authenticate function." + "Expected challenge not found. It either needs to set to the `challenge` property on the auth session, or passed as context to the authenticate function." ); + } // Based on the authenticator response, either verify registration, // or verify authentication @@ -302,7 +301,7 @@ export class WebAuthnStrategy extends Strategy< credentialID, credentialPublicKey: isoBase64URL.fromBuffer(credentialPublicKey), counter, - credentialBackedUp: credentialBackedUp ? 1 : 0, + credentialBackedUp, credentialDeviceType, transports: "", };