diff --git a/bun.lockb b/bun.lockb index 7f4b8658..18e78c7b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/openauth/package.json b/packages/openauth/package.json index a5e3276b..ea1d2aa3 100644 --- a/packages/openauth/package.json +++ b/packages/openauth/package.json @@ -30,9 +30,15 @@ "types": "./dist/types/ui/index.d.ts" } }, + "peerDependenciesMeta": { + "@oslojs/webauthn": { + "optional": true + } + }, "peerDependencies": { "arctic": "^2.2.2", - "hono": "^4.0.0" + "hono": "^4.0.0", + "@oslojs/webauthn": "^1.0.0" }, "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", diff --git a/packages/openauth/src/provider/webauthn.ts b/packages/openauth/src/provider/webauthn.ts new file mode 100644 index 00000000..f092c47a --- /dev/null +++ b/packages/openauth/src/provider/webauthn.ts @@ -0,0 +1,333 @@ +/** + * Configures a provider that supports webauthn authentication. This is usually + * paired with the `WebAuthnUI`. + * + * This provider requires `@oslojs/webauthn` to be installed. + * + * ```bash + * npm i @oslojs/webauthn + * ``` + * + * ```ts + * import { WebAuthnUI } from "@openauthjs/openauth/ui/webauthn" + * import { WebAuthnProvider } from "@openauthjs/openauth/provider/webauthn" + * + * export default issuer({ + * passkey: WebAuthnProvider( + * WebAuthnUI({ + * // options returned to the browser for navigator.credentials.get() + * options: { + * userVerification: "required", + * rpId: "myapp.com", // optional, defaults to the domain of the issuer (auth.myapp.com) + * }, + * async getCredential(id) { + * const credential = await authService.getPasskeyCredential(id); + * + * if (!credential) return null; + * return { credential, claims: { userId: credential.userId } }; + * }, + * }), + * ), + * }, + * // ... + * }) + * ``` + * + * Behind the scenes, the `WebAuthnProvider` expects callbacks that implements request handlers + * that generate the UI for the following. + * + * ```ts + * WebAuthnProvider({ + * // ... + * rpId?: string; + * request: (req: Request, state: WebAuthnProviderState, form?: FormData, error?: WebAuthnProviderError) => Promise + * getCredential: (credentialId: Uint8Array) => Promise<{ credential: { id: Uint8Array; algorithm: number; publicKey: Uint8Array }; claims: Claims } | null> + * verifyAuthn?: (data) => Promise + * }) + * ``` + * + * This allows you to create your own UI. + * + * @packageDocumentation + */ + +import type { Provider } from "./provider.js" +import type { Context } from "hono" + +import { generateUnbiasedDigits, timingSafeCompare } from "../random.js" +import { getRelativeUrl } from "../util.js" +import { + decodePKIXECDSASignature, + decodeSEC1PublicKey, + p256, + verifyECDSASignature, +} from "@oslojs/crypto/ecdsa" +import { sha256 } from "@oslojs/crypto/sha2" +import { + ClientDataType, + createAssertionSignatureMessage, + parseAuthenticatorData, + parseClientDataJSON, +} from "@oslojs/webauthn" + +export type WebAuthnProviderConfig< + Claims extends Record = Record, +> = { + /** + * The request handler to generate the UI for the webauthn flow. + * + * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) + * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) + * ojects. + * + * Also passes in the current `state` of the flow and any `error` that occurred. + * + * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object + * in return. + */ + request: ( + req: Request, + state: WebAuthnProviderState, + form?: FormData, + error?: WebAuthnProviderError, + ) => Promise + /** + * The relying party ID to use for the webauthn flow. + */ + rpId?: string + /** + * Callback to get credential and claims for the user. + */ + getCredential: (credentialId: Uint8Array) => Promise<{ + credential: { + id: Uint8Array + algorithm: number + publicKey: Uint8Array + } + claims: Claims + } | null> + /** + * Callback to verify the credential for the user. + * + * @example + * ```ts + * { + * async verifyAuthn(data) { + * const clientData = parseClientDataJSON(data.raw.clientDataJSON); + * + * if (clientData.type !== ClientDataType.Get) { + * return { error: "rejected" } + * } + * // ... other checks + * + * // decode & verify signature + * } + * } + * ``` + */ + verifyAuthn?: (data: { + credential: { + id: Uint8Array + algorithm: number + publicKey: Uint8Array + } + claims: Record + raw: { + credentialId: Uint8Array + signature: Uint8Array + authenticatorData: Uint8Array + clientDataJSON: Uint8Array + } + }) => Promise +} + +/** + * The state of the webauthn flow. + * + * | State | Description | + * | ----- | ----------- | + * | `start` | The user is asked to use their credential to start the flow. | + */ +export type WebAuthnProviderState = { type: "start"; challenge: string } + +export type WebAuthnProviderError = + | { error: "invalid_challenge" } + | { error: "invalid_rp_id" } + | { error: "invalid_origin" } + | { error: "invalid_cross_origin" } + | { error: "invalid_signature" } + | { error: "invalid_client_data_type" } + | { error: "credential_not_found" } + | { error: "unresolved" } + | { error: "rejected" } + +function decodeBase64(str: string): Uint8Array { + return new Uint8Array( + atob(str) + .split("") + .map((c) => c.charCodeAt(0)), + ) +} + +export function WebAuthnProvider< + Claims extends Record = Record, +>(config: WebAuthnProviderConfig): Provider<{ claims: Claims }> { + return { + type: "code", + init(routes, ctx) { + async function transition( + c: Context, + next: WebAuthnProviderState, + fd?: FormData, + err?: WebAuthnProviderError, + ) { + await ctx.set(c, "provider", 60 * 60 * 24, next) + const resp = ctx.forward( + c, + await config.request(c.req.raw, next, fd, err), + ) + return resp + } + + async function transitionToStart( + c: Context, + fd?: FormData, + err?: WebAuthnProviderError, + ) { + const challenge = generateUnbiasedDigits(32) + return await transition(c, { type: "start", challenge }, fd, err) + } + + routes.get("/authorize", async (c) => { + return await transitionToStart(c) + }) + + routes.post("/authorize", async (c) => { + const fd = await c.req.formData() + const state = await ctx.get( + c, + "provider", + ) + + const credentialId = fd.get("credentialId")?.toString() + const signature = fd.get("signature")?.toString() + const authenticatorData = fd.get("authData")?.toString() + const clientDataJSON = fd.get("clientDataJSON")?.toString() + + if ( + state?.type !== "start" || + !credentialId || + !signature || + !authenticatorData || + !clientDataJSON + ) { + return await transitionToStart(c, fd, { error: "unresolved" }) + } + + const credId = decodeBase64(credentialId) + const sig = decodeBase64(signature) + const authData = decodeBase64(authenticatorData) + const clientDataJson = decodeBase64(clientDataJSON) + + const clientData = parseClientDataJSON(clientDataJson) + const challenge = new TextDecoder().decode(clientData.challenge) + + if (!timingSafeCompare(state.challenge, challenge)) { + return await transitionToStart(c, fd, { error: "invalid_challenge" }) + } + + const res = await config.getCredential(credId) + + if (!res) { + return await transitionToStart(c, fd, { + error: "credential_not_found", + }) + } + + const url = new URL(getRelativeUrl(c, "/")) + const origin = url.origin + const rpId = config.rpId || url.hostname + + const verifyError = config.verifyAuthn + ? await config.verifyAuthn({ + credential: res.credential, + claims: res.claims, + raw: { + credentialId: credId, + signature: sig, + authenticatorData: authData, + clientDataJSON: clientDataJson, + }, + }) + : verifyWebAuthn({ + rpId, + origin, + credentialPublicKey: res.credential.publicKey, + signature: sig, + authData, + clientDataJSON: clientDataJson, + }) + + if (verifyError) { + return await transitionToStart(c, fd, verifyError) + } + + await ctx.unset(c, "provider") + return ctx.forward(c, await ctx.success(c, res)) + }) + }, + } +} + +/** + * @internal + */ +export type WebAuthnProviderOptions< + Claims extends Record = Record, +> = WebAuthnProviderConfig + +/** + * Default implementation of the `verifyWebAuthn` function. + * This function verifies the webauthn signature and checks if the user is present and verified. + * Reference: https://webauthn.oslojs.dev/examples/authentication + */ +function verifyWebAuthn(input: { + rpId: string + origin: string + credentialPublicKey: Uint8Array + signature: Uint8Array + authData: Uint8Array + clientDataJSON: Uint8Array +}): WebAuthnProviderError | undefined { + const authenticatorData = parseAuthenticatorData(input.authData) + if (!authenticatorData.verifyRelyingPartyIdHash(input.rpId)) { + return { error: "invalid_rp_id" } + } + if (!authenticatorData.userPresent || !authenticatorData.userVerified) { + return { error: "rejected" } + } + + const clientData = parseClientDataJSON(input.clientDataJSON) + if (clientData.type !== ClientDataType.Get) { + return { error: "invalid_client_data_type" } + } + + if (clientData.origin !== input.origin) { + return { error: "invalid_origin" } + } + if (clientData.crossOrigin !== null && clientData.crossOrigin) { + return { error: "invalid_cross_origin" } + } + + // Decode DER-encoded signature + const ecdsaSignature = decodePKIXECDSASignature(input.signature) + const ecdsaPublicKey = decodeSEC1PublicKey(p256, input.credentialPublicKey) + const hash = sha256( + createAssertionSignatureMessage(input.authData, input.clientDataJSON), + ) + const valid = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature) + + if (!valid) { + return { error: "invalid_signature" } + } +} diff --git a/packages/openauth/src/ui/webauthn.tsx b/packages/openauth/src/ui/webauthn.tsx new file mode 100644 index 00000000..14f66f80 --- /dev/null +++ b/packages/openauth/src/ui/webauthn.tsx @@ -0,0 +1,220 @@ +/** + * Configure the UI that's used by the WebAuthn provider. + * + * This provider requires `@oslojs/webauthn` to be installed. + * + * ```bash + * npm i @oslojs/webauthn + * ``` + * + * ```ts + * import { WebAuthnUI } from "@openauthjs/openauth/ui/webauthn" + * import { WebAuthnProvider } from "@openauthjs/openauth/provider/webauthn" + * + * export default issuer({ + * passkey: WebAuthnProvider( + * WebAuthnUI({ + * // options returned to the browser for navigator.credentials.get() + * options: { + * userVerification: "required", + * rpId: "myapp.com", // optional, defaults to the domain of the issuer (auth.myapp.com) + * }, + * async getCredential(id) { + * const credential = await authService.getPasskeyCredential(id); + * + * if (!credential) return null; + * return { credential, claims: { userId: credential.userId } }; + * }, + * }), + * ), + * }, + * // ... + * }) + * ``` + * + * @packageDocumentation + */ +/** @jsxImportSource hono/jsx */ + +import { Layout } from "./base.js" +import { FormAlert } from "./form.js" + +import type { WebAuthnProviderOptions } from "../provider/webauthn.js" +import { html } from "hono/html" + +const DEFAULT_COPY = { + invalid_challenge: "Invalid Challenge.", + invalid_rp_id: "Invalid RP ID.", + invalid_origin: "Invalid Origin.", + invalid_cross_origin: "Invalid Cross Origin.", + invalid_signature: "Invalid Signature.", + invalid_client_data_type: "Invalid Client Data Type.", + credential_not_found: "Credential not found.", + unresolved: "Unresolved.", + rejected: "Rejected.", + verify: "Verify", + code_info: "Please verify your identity.", +} + +export type WebAuthnUICopy = typeof DEFAULT_COPY + +export type WebAuthnUIOptions = { + /** + * The request options for navigator.credentials.get. + * + * Sent to the client via JSON. + * + * See [`Request Options`](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/parseRequestOptionsFromJSON_static) + */ + options?: Omit + /** + * Custom copy for the UI. + */ + copy?: Partial + /** + * Callback to get the webauthn credential for the user. + */ + getCredential: WebAuthnProviderOptions["getCredential"] + /** + * Callback to verify the credential for the user. + * + * @example + * ```ts + * { + * async verifyAuthn(data) { + * const clientData = parseClientDataJSON(data.raw.clientDataJSON); + * + * if (clientData.type !== ClientDataType.Get) { + * return { error: "rejected" } + * } + * // ... other checks + * + * // decode & verify signature + * } + * } + * ``` + */ + verifyAuthn?: WebAuthnProviderOptions["verifyAuthn"] +} + +function stringToBase64Url(from: string): string { + return btoa(from).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") +} + +export function WebAuthnUI(props: WebAuthnUIOptions): WebAuthnProviderOptions { + const copy = { + ...DEFAULT_COPY, + ...props.copy, + } + + return { + rpId: props.options?.rpId, + getCredential: props.getCredential, + verifyAuthn: props.verifyAuthn, + request: async (_req, state, _form, error): Promise => { + const options = JSON.stringify({ + ...(props.options ?? {}), + challenge: stringToBase64Url(state.challenge), + }) + + const jsx = ( + +
+ {error && } + + + + + + + + + + + +

{copy.code_info}

+
+ ) + + return new Response(jsx.toString(), { + headers: { + "Content-Type": "text/html", + }, + }) + }, + } +} + +// in order to trigger the passkey flow, we need to use onclick in