From 42fd6abfebba619382d92c654af84f9234507688 Mon Sep 17 00:00:00 2001 From: Corey Jepperson Date: Wed, 21 May 2025 15:25:09 -0500 Subject: [PATCH 1/2] passkey authentication --- bun.lockb | Bin 255216 -> 259824 bytes packages/openauth/package.json | 1 + packages/openauth/src/provider/passkey.ts | 655 ++++++++++++++++++++++ packages/openauth/src/ui/passkey.tsx | 323 +++++++++++ 4 files changed, 979 insertions(+) create mode 100644 packages/openauth/src/provider/passkey.ts create mode 100644 packages/openauth/src/ui/passkey.tsx diff --git a/bun.lockb b/bun.lockb index 7f4b865820733fdfc374b2177bd115d17dacfb6e..8b7f02aef5ea0da63a8de937d67e9d08d7f0d4f2 100755 GIT binary patch delta 43559 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 NWz|0;{bNL$^9OmFtcU;r delta 40542 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 { // optional + * // Check if the user is allowed to register a passkey + * return true + * } + * }) + * }, + * // ... + * }) + * ``` + * + * PasskeyProvider implements WebAuthn (Web Authentication) to enable passwordless + * authentication using biometrics, mobile devices, or security keys. It handles + * the complete flow for registering new passkeys and authenticating with them. + * + * The provider requires configuration of: + * - Relying Party information (rpName, rpID) + * - Origin validation + * - UI handlers for authorization and registration + * + * It automatically manages: + * - Challenge generation + * - Credential storage + * - Registration verification + * - Authentication verification + * + * This implementation is powered by [@simplewebauthn/server](https://simplewebauthn.dev), + * which provides the core WebAuthn functionality for passkey authentication. + * + * @packageDocumentation + */ + +import type { + AuthenticatorTransportFuture, + CredentialDeviceType, + Base64URLString, + AuthenticatorSelectionCriteria, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, + AuthenticationResponseJSON, + VerifiedRegistrationResponse, +} from "@simplewebauthn/server" +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server" + +import type { Provider, ProviderOptions, ProviderRoute } from "./provider.js" +import { Storage } from "../storage/storage.js" +import type { Context } from "hono" + +/** + * Converts a Uint8Array to a Base64URL encoded string. + * This is used to convert binary data for storage in databases or JSON. + * + * @param bytes - The Uint8Array to convert + * @returns Base64URL encoded string + */ +function uint8ArrayToBase64Url(bytes: Uint8Array): string { + let str = "" + + for (const charCode of bytes) { + str += String.fromCharCode(charCode) + } + + const base64String = btoa(str) + + return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") +} + +/** + * Converts a Base64URL encoded string back to a Uint8Array. + * This is used to convert stored data back to binary format for WebAuthn operations. + * + * @param base64urlString - The Base64URL encoded string to convert + * @returns Uint8Array containing the decoded data + */ +function base64UrlToUint8Array(base64urlString: string): Uint8Array { + // Convert from Base64URL to Base64 + const base64 = base64urlString.replace(/-/g, "+").replace(/_/g, "/") + /** + * Pad with '=' until it's a multiple of four + * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding + * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding + * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding + * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding + */ + const padLength = (4 - (base64.length % 4)) % 4 + const padded = base64.padEnd(base64.length + padLength, "=") + + // Convert to a binary string + const binary = atob(padded) + + // Convert binary string to buffer + const buffer = new ArrayBuffer(binary.length) + const bytes = new Uint8Array(buffer) + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + + return bytes +} + +/** + * User model for passkey authentication. + * Contains the core user data needed for WebAuthn operations. + */ +export type UserModel = { + id: string // User's unique ID (must be stable and unique) + username: string + // other user fields... +} + +/** + * Original PasskeyModel structure for in-memory use. + * Represents a registered credential with public key as Uint8Array. + */ +export type PasskeyModel = { + id: string + publicKey: Uint8Array + userId: string // Foreign key to UserModel + webauthnUserID: string + counter: number + deviceType: CredentialDeviceType + backedUp: boolean + transports?: AuthenticatorTransportFuture[] +} + +/** + * PasskeyModel version for KV storage with publicKey as string. + * Used for storing credentials in a key-value store. + */ +export type PasskeyModelStored = Omit & { + publicKey: string // Stored as Base64URL string +} + +// --- Storage Key Definitions --- +const userKey = (userId: string) => ["passkey", "user", userId] +const passkeyKey = (userId: string, credentialId: Base64URLString) => [ + "passkey", + "user", + userId, + "credential", + credentialId, + "passkey", +] +const optionsKey = (userId: string) => ["passkey", "user", userId, "options"] +const userPasskeysIndexKey = (userId: string) => [ + "passkey", + "user", + userId, + "passkeys", +] // Stores list of credentialIDs + +// Configuration +const DEFAULT_COPY = { + error_user_not_allowed: + "There is already an account with this email. Login to add a passkey.", +} + +/** + * Configuration for the PasskeyProvider. + * Defines how the passkey authentication flow should behave. + */ +export interface PasskeyProviderConfig { + /** + * Custom authorization handler that generates the UI for authorization. + */ + authorize: (req: Request) => Promise + + /** + * Custom registration handler that generates the UI for registration. + */ + register: (req: Request) => Promise + + /** + * The human-readable name of the relying party (your application). + */ + rpName: string + + /** + * The ID of the relying party, typically the domain name without protocol. + */ + rpID?: string + + /** + * The origin URL(s) that are allowed to initiate WebAuthn ceremonies. + */ + origin?: string | string[] + + /** + * Optional function to check if a user is allowed to register a passkey. + */ + userCanRegisterPasskey?: (userId: string, req: Request) => Promise + + /** + * Optional WebAuthn authenticator selection criteria. + */ + authenticatorSelection?: AuthenticatorSelectionCriteria + + /** + * Optional attestation type. + */ + attestationType?: "none" | "direct" | "enterprise" + + /** + * Optional timeout for challenges in milliseconds. + */ + timeout?: number + + /** + * Custom copy texts for error messages and UI elements. + */ + copy?: Partial +} + +/** + * Creates a passkey (WebAuthn) authentication provider. + * + * This provider enables passwordless authentication using biometrics, hardware security + * keys, or platform authenticators. It implements the Web Authentication (WebAuthn) standard. + * + * It handles: + * - Passkey registration (creating new credentials) + * - Authentication with existing passkeys + * - Secure storage of credentials + * - Challenge verification + * + * @param config Configuration options for the passkey provider + * @returns A Provider instance configured for passkey authentication + */ +export function PasskeyProvider( + config: PasskeyProviderConfig, +): Provider<{ userId: string; credentialId?: Base64URLString }> { + const copy = { + ...DEFAULT_COPY, + ...config.copy, + } + return { + type: "passkey", + init( + routes: ProviderRoute, + ctx: ProviderOptions<{ + userId: string + credentialId?: Base64URLString + verified: boolean + }>, + ) { + const { + rpName, + authenticatorSelection, + attestationType = "none", + timeout = 5 * 60 * 1000, // 5 minutes in ms for challenge + } = config + + // --- Internal Data Access Functions using options.storage --- + + async function getStoredUserById( + userId: string, + ): Promise { + return await Storage.get(ctx.storage, userKey(userId)) + } + + async function saveUser(user: UserModel): Promise { + await Storage.set(ctx.storage, userKey(user.id), user) + } + + async function getStoredPasskeyById( + userId: string, + credentialID: Base64URLString, + ): Promise { + const storedPasskey = await Storage.get( + ctx.storage, + passkeyKey(userId, credentialID), + ) + if (!storedPasskey) return null + return { + ...storedPasskey, + publicKey: base64UrlToUint8Array(storedPasskey.publicKey), + } + } + + async function getStoredUserPasskeys( + userId: string, + ): Promise { + const passkeyIds = + (await Storage.get( + ctx.storage, + userPasskeysIndexKey(userId), + )) || [] + const passkeys: PasskeyModel[] = [] + for (const id of passkeyIds) { + const pk = await getStoredPasskeyById(userId, id) + if (pk) passkeys.push(pk) + } + return passkeys + } + + async function saveNewStoredPasskey( + passkeyData: PasskeyModel, + ): Promise { + const storablePasskey: PasskeyModelStored = { + ...passkeyData, + publicKey: uint8ArrayToBase64Url(passkeyData.publicKey), + } + await Storage.set( + ctx.storage, + passkeyKey(passkeyData.userId, passkeyData.id), + storablePasskey, + ) + + // Update user's passkey index + const passkeyIds = + (await Storage.get( + ctx.storage, + userPasskeysIndexKey(passkeyData.userId), + )) || [] + if (!passkeyIds.includes(passkeyData.id)) { + passkeyIds.push(passkeyData.id) + await Storage.set( + ctx.storage, + userPasskeysIndexKey(passkeyData.userId), + passkeyIds, + ) + } + } + + async function updateStoredPasskeyCounter( + userId: string, + credentialID: Base64URLString, + newCounter: number, + ): Promise { + const passkey = await getStoredPasskeyById(userId, credentialID) + if (passkey) { + passkey.counter = newCounter + const storablePasskey: PasskeyModelStored = { + ...passkey, + publicKey: uint8ArrayToBase64Url(passkey.publicKey), + } + await Storage.set( + ctx.storage, + passkeyKey(userId, credentialID), + storablePasskey, + ) + } + } + + routes.get("/authorize", async (c) => { + return ctx.forward(c, await config.authorize(c.req.raw)) + }) + + routes.get("/register", async (c) => { + return ctx.forward(c, await config.register(c.req.raw)) + }) + + // --- REGISTRATION FLOW --- + routes.get("/register-request", async (c: Context) => { + const userId = c.req.query("userId") + const rpID = config.rpID || c.req.query("rpID") + const otherDevice = c.req.query("otherDevice") === "true" + + if (!userId) { + return c.json({ error: "User ID for registration is required." }, 400) + } + if (!rpID) { + return c.json({ error: "RP ID for registration is required." }, 400) + } + const username = c.req.query("username") || userId + + let user = await getStoredUserById(userId) + + if (config.userCanRegisterPasskey) { + const isAllowed = await config.userCanRegisterPasskey( + userId, + c.req.raw, + ) + if (!isAllowed) { + return c.json( + { + error: copy.error_user_not_allowed, + }, + 403, + ) + } + } + // If user does not exist, you might create them here or expect them to be pre-registered + if (!user) { + user = { id: userId, username } + await saveUser(user) + } + + const userPasskeys = await getStoredUserPasskeys(user.id) + + const regOptions: PublicKeyCredentialCreationOptionsJSON = + await generateRegistrationOptions({ + rpName, + rpID, + userName: user.username, + attestationType, + excludeCredentials: userPasskeys.map((pk) => ({ + id: pk.id, + transports: pk.transports, + })), + authenticatorSelection: authenticatorSelection ?? { + residentKey: "preferred", + userVerification: "preferred", + authenticatorAttachment: otherDevice + ? "cross-platform" + : "platform", + }, + timeout, + }) + await Storage.set(ctx.storage, optionsKey(user.id), regOptions) + return c.json(regOptions) + }) + + routes.post("/register-verify", async (c: Context) => { + const body: RegistrationResponseJSON = await c.req.json() + + const { userId } = c.req.query() as { userId: string } + const rpID = config.rpID || c.req.query("rpID") + const origin = config.origin || c.req.query("origin") + if (!userId) { + return c.json( + { + verified: false, + error: "User ID for verification is required.", + }, + 400, + ) + } + if (!rpID) { + return c.json({ error: "RP ID for verification is required." }, 400) + } + if (!origin) { + return c.json({ error: "Origin for verification is required." }, 400) + } + + const user = await getStoredUserById(userId) + if (!user) { + return c.json( + { verified: false, error: "User not found during verification." }, + 404, + ) + } + const regOptions = + await Storage.get( + ctx.storage, + optionsKey(user.id), + ) + if (!regOptions) { + return c.json( + { verified: false, error: "Registration options not found." }, + 400, + ) + } + const challenge = regOptions.challenge + + let verification: VerifiedRegistrationResponse + try { + verification = await verifyRegistrationResponse({ + response: body, + expectedChallenge: challenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: + authenticatorSelection?.userVerification !== "discouraged", + }) + } catch (error: any) { + console.error("Passkey Registration Verification Error:", error) + return c.json({ verified: false, error: error.message }, 400) + } + + const { verified, registrationInfo } = verification + + if (verified && registrationInfo) { + const { credential, credentialDeviceType, credentialBackedUp } = + registrationInfo + + if (credential) { + const newPasskey: PasskeyModel = { + id: credential.id, + userId: user.id, + webauthnUserID: regOptions.user.id, + publicKey: credential.publicKey, + counter: credential.counter, + transports: credential.transports, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + } + + await saveNewStoredPasskey(newPasskey) + + return ctx.success(c, { + userId: user.id, + credentialId: newPasskey.id, + verified: true, + }) + } + } + return c.json( + { verified: false, error: "Registration verification failed." }, + 400, + ) + }) + + // --- AUTHENTICATION FLOW --- + routes.get("/authenticate-options", async (c: Context) => { + const { userId } = c.req.query() as { userId?: string } + if (!userId) { + return c.json( + { error: "User ID for authentication is required." }, + 400, + ) + } + const rpID = config.rpID || c.req.query("rpID") + if (!rpID) { + return c.json({ error: "RP ID for authentication is required." }, 400) + } + + const userForAuth = await getStoredUserById(userId) + if (!userForAuth) { + return c.json({ error: "User not found for authentication." }, 404) + } + + const userPasskeys = await getStoredUserPasskeys(userForAuth.id) + const allowCredentialsList = userPasskeys.map((pk) => ({ + id: pk.id, + transports: pk.transports, + })) + + const authOptions: PublicKeyCredentialRequestOptionsJSON = + await generateAuthenticationOptions({ + rpID, + allowCredentials: allowCredentialsList, + userVerification: + authenticatorSelection?.userVerification ?? "preferred", + timeout, + }) + + await Storage.set(ctx.storage, optionsKey(userForAuth.id), authOptions) + return c.json(authOptions) + }) + + routes.post("/authenticate-verify", async (c: Context) => { + const body: AuthenticationResponseJSON = await c.req.json() + const { userId } = c.req.query() as { userId?: string } + if (!userId) { + return c.json( + { error: "User ID for authentication is required." }, + 400, + ) + } + const rpID = config.rpID || c.req.query("rpID") + if (!rpID) { + return c.json({ error: "RP ID for authentication is required." }, 400) + } + const origin = config.origin || c.req.query("origin") + if (!origin) { + return c.json( + { error: "Origin for authentication is required." }, + 400, + ) + } + + const user = await getStoredUserById(userId) + if (!user) { + return c.json( + { verified: false, error: `User ${userId} not found.` }, + 404, + ) + } + + const authOptions = + await Storage.get( + ctx.storage, + optionsKey(user.id), + ) + + if (!authOptions) { + return c.json({ error: "Authentication options not found." }, 400) + } + const passkey = await getStoredPasskeyById(userId, body.id) + + if (!passkey) { + return c.json( + { + verified: false, + error: `Passkey ${body.id} not found for user ${user.username}.`, + }, + 400, + ) + } + + const { publicKey, counter, transports } = passkey + + if (!publicKey || typeof counter !== "number" || !transports) { + return c.json({ error: "Passkey not found for authentication." }, 400) + } + + const challenge = authOptions.challenge + if (!challenge) { + return c.json({ error: "Authentication challenge not found." }, 400) + } + + const verification = await verifyAuthenticationResponse({ + response: body, + expectedChallenge: challenge, + expectedOrigin: origin || "", + expectedRPID: rpID, + credential: { + id: passkey.id, + publicKey: publicKey, + counter: counter, + transports: transports, + }, + }) + + const { verified, authenticationInfo } = verification + + if (verified) { + await updateStoredPasskeyCounter( + user.id, + passkey.id, + authenticationInfo.newCounter, + ) + return ctx.success(c, { + userId: user.id, + credentialId: passkey.id, + verified: true, + }) + } + return c.json( + { verified: false, error: "Authentication verification failed." }, + 400, + ) + }) + }, + } +} diff --git a/packages/openauth/src/ui/passkey.tsx b/packages/openauth/src/ui/passkey.tsx new file mode 100644 index 00000000..d165334a --- /dev/null +++ b/packages/openauth/src/ui/passkey.tsx @@ -0,0 +1,323 @@ +import { PasskeyProviderConfig } from "../provider/passkey.js" +import { Layout } from "./base.js" +import { FormAlert } from "./form.js" + +import { AuthenticatorSelectionCriteria } from "@simplewebauthn/server" + +const DEFAULT_COPY = { + /** + * Copy for the register button. + */ + register: "Register", + register_with_passkey: "Register With Passkey", + register_other_device: "Use another device", + /** + * Copy for the register link. + */ + register_prompt: "Don't have an account?", + /** + * Copy for the login link. + */ + login_prompt: "Already have an account?", + /** + * Copy for the login button. + */ + login: "Login", + /** + * Copy for the login with passkey button. + */ + login_with_passkey: "Login With Passkey", + /** + * Copy for the forgot password link. + */ + change_prompt: "Forgot password?", + /** + * Copy for the resend code button. + */ + code_resend: "Resend code", + /** + * Copy for the "Back to" link. + */ + code_return: "Back to", + /** + * Copy for the email input. + */ + input_email: "Email", +} +type PasskeyUIOptions = Omit + +export function PasskeyUI(options: PasskeyUIOptions): PasskeyProviderConfig { + const { + rpName, + rpID, + origin, + userCanRegisterPasskey, + authenticatorSelection, + attestationType, + timeout, + } = options + const copy = { + ...DEFAULT_COPY, + ...options.copy, + } + return { + authorize: async () => { + const jsx = ( + + + + ) + return new Response(jsx.toString(), { + status: 200, + headers: { + "Content-Type": "text/html", + }, + }) + }, + register: async () => { + const jsx = ( + + + + ) + return new Response(jsx.toString(), { + status: 200, + headers: { + "Content-Type": "text/html", + }, + }) + }, + rpName, + rpID, + origin, + userCanRegisterPasskey, + authenticatorSelection, + attestationType, + timeout, + } +} From a28f7e3b997debe63a62e0768dbfdf6371bb717c Mon Sep 17 00:00:00 2001 From: Corey Jepperson Date: Wed, 21 May 2025 15:26:58 -0500 Subject: [PATCH 2/2] remove unused type --- packages/openauth/src/ui/passkey.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/openauth/src/ui/passkey.tsx b/packages/openauth/src/ui/passkey.tsx index d165334a..8097127e 100644 --- a/packages/openauth/src/ui/passkey.tsx +++ b/packages/openauth/src/ui/passkey.tsx @@ -2,8 +2,6 @@ import { PasskeyProviderConfig } from "../provider/passkey.js" import { Layout } from "./base.js" import { FormAlert } from "./form.js" -import { AuthenticatorSelectionCriteria } from "@simplewebauthn/server" - const DEFAULT_COPY = { /** * Copy for the register button.