Skip to content

Commit

Permalink
Improves the behavior of WebAuthnStrategy.generateOptions. Closes R…
Browse files Browse the repository at this point in the history
…ethink `WebAuthnStrategy.generateOptions` #14
  • Loading branch information
alexanderson1993 committed May 24, 2024
1 parent 66995bd commit 2fec187
Show file tree
Hide file tree
Showing 3 changed files with 77 additions and 23 deletions.
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
41 changes: 20 additions & 21 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
json,
type SessionStorage,
type SessionData,
Session,
} from "@remix-run/server-runtime";
import {
AuthenticateOptions,
Expand Down Expand Up @@ -31,7 +31,7 @@ export interface Authenticator {
credentialPublicKey: string;
counter: number;
credentialDeviceType: string;
credentialBackedUp: number;
credentialBackedUp: boolean;
transports: string;
}

Expand Down Expand Up @@ -183,14 +183,9 @@ export class WebAuthnStrategy<User> extends Strategy<

async generateOptions<ExtraData>(
request: Request,
sessionStorage: SessionStorage<SessionData, SessionData>,
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;
Expand Down Expand Up @@ -231,17 +226,20 @@ export class WebAuthnStrategy<User> 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<SessionData, SessionData>,
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<SessionData, SessionData>,
Expand All @@ -258,12 +256,13 @@ export class WebAuthnStrategy<User> 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
Expand Down Expand Up @@ -302,7 +301,7 @@ export class WebAuthnStrategy<User> extends Strategy<
credentialID,
credentialPublicKey: isoBase64URL.fromBuffer(credentialPublicKey),
counter,
credentialBackedUp: credentialBackedUp ? 1 : 0,
credentialBackedUp,
credentialDeviceType,
transports: "",
};
Expand Down

0 comments on commit 2fec187

Please sign in to comment.