UNPKG

expo-passkey

Version:

Passkey authentication for Expo apps with Better Auth integration

308 lines 14.7 kB
/** * @file Register passkey endpoint * @description WebAuthn-based implementation for passkey registration with client preferences support */ import { createAuthEndpoint } from "better-auth/api"; import { APIError } from "better-call"; import { verifyRegistrationResponse, } from "@simplewebauthn/server"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { ERROR_CODES, ERROR_MESSAGES } from "../../types/errors"; import { registerPasskeySchema } from "../utils/schema"; /** * Create WebAuthn passkey registration endpoint */ export const createRegisterEndpoint = (options) => { const { rpName, rpId, origin, logger, schemaConfig } = options; // Convert to array of origins for consistency, or use empty array if undefined const expectedOrigins = origin ? Array.isArray(origin) ? origin : [origin] : []; return createAuthEndpoint("/expo-passkey/register", { method: "POST", body: registerPasskeySchema, metadata: { openapi: { description: "Register a new passkey using WebAuthn", tags: ["Authentication"], responses: { 200: { description: "Passkey successfully registered", content: { "application/json": { schema: { type: "object", properties: { success: { type: "boolean" }, rpName: { type: "string" }, rpId: { type: "string" }, }, }, }, }, }, 400: { description: "Invalid request", content: { "application/json": { schema: { type: "object", properties: { error: { type: "object", properties: { code: { type: "string" }, message: { type: "string" }, }, }, }, }, }, }, }, }, }, }, }, async (ctx) => { const { userId, credential, platform, metadata } = ctx.body; try { logger.debug("WebAuthn registration attempt:", { userId, credentialId: credential.id, platform, }); // Verify user exists const user = await ctx.context.adapter.findOne({ model: "user", where: [{ field: "id", operator: "eq", value: userId }], }); if (!user) { logger.warn("Registration failed: User not found", { userId }); throw new APIError("BAD_REQUEST", { code: ERROR_CODES.SERVER.USER_NOT_FOUND, message: ERROR_MESSAGES[ERROR_CODES.SERVER.USER_NOT_FOUND], }); } // Get latest challenge for this user const challenges = await ctx.context.adapter.findMany({ model: schemaConfig.passkeyChallengeModel, where: [ { field: "userId", operator: "eq", value: userId }, { field: "type", operator: "eq", value: "registration" }, ], sortBy: { field: "createdAt", direction: "desc" }, limit: 1, }); const storedChallenge = challenges[0]; if (!storedChallenge) { logger.warn("Registration failed: No challenge found", { userId }); throw new APIError("BAD_REQUEST", { code: "INVALID_CHALLENGE", message: "No challenge found for registration", }); } // Check if challenge has expired if (new Date(storedChallenge.expiresAt) < new Date()) { logger.warn("Registration failed: Challenge expired", { userId }); throw new APIError("BAD_REQUEST", { code: "EXPIRED_CHALLENGE", message: "Challenge has expired. Please request a new one.", }); } // Parse stored registration options from the challenge let registrationOptions = {}; if (storedChallenge.registrationOptions) { try { registrationOptions = JSON.parse(storedChallenge.registrationOptions); logger.debug("Using stored registration options:", registrationOptions); } catch (parseError) { logger.warn("Failed to parse stored registration options, using defaults:", parseError); // Continue with defaults } } // Determine user verification requirement from client preferences const userVerificationRequirement = registrationOptions.authenticatorSelection?.userVerification || "preferred"; const requireUserVerification = userVerificationRequirement === "required"; logger.debug("Registration verification settings:", { userVerificationRequirement, requireUserVerification, attestation: registrationOptions.attestation || "none", authenticatorAttachment: registrationOptions.authenticatorSelection?.authenticatorAttachment, residentKey: registrationOptions.authenticatorSelection?.residentKey, }); // Prepare credential for verification const verifiableCredential = credential; try { // Create verification options with client preferences const verificationOptions = { response: verifiableCredential, expectedChallenge: storedChallenge.challenge, expectedOrigin: expectedOrigins, expectedRPID: rpId, requireUserVerification, // Use client preference }; // Verify the registration response const verification = await verifyRegistrationResponse(verificationOptions); if (!verification.verified || !verification.registrationInfo) { throw new Error("Verification failed"); } // Extract credential information from the WebAuthnCredential object const { credential: webAuthnCredential, aaguid } = verification.registrationInfo; // Use credential data directly or convert as needed const credentialIdStr = typeof webAuthnCredential.id === "string" ? webAuthnCredential.id : isoBase64URL.fromBuffer(webAuthnCredential.id); const publicKeyStr = typeof webAuthnCredential.publicKey === "string" ? webAuthnCredential.publicKey : isoBase64URL.fromBuffer(webAuthnCredential.publicKey); const aaguidStr = aaguid ? typeof aaguid === "string" ? aaguid : Buffer.from(aaguid).toString("base64") : null; // Check if credential already exists const existingCredentials = await ctx.context.adapter.findMany({ model: schemaConfig.authPasskeyModel, where: [ { field: "credentialId", operator: "eq", value: credentialIdStr, }, ], limit: 1, }); const existingCredential = existingCredentials.length > 0 ? existingCredentials[0] : null; const now = new Date().toISOString(); // Prepare enhanced metadata with client preferences const enhancedMetadata = { ...metadata, // Add information about client preferences used during registration registrationPreferences: { attestation: registrationOptions.attestation || "none", userVerification: userVerificationRequirement, authenticatorAttachment: registrationOptions.authenticatorSelection ?.authenticatorAttachment, residentKey: registrationOptions.authenticatorSelection?.residentKey, requireResidentKey: registrationOptions.authenticatorSelection?.requireResidentKey, }, registeredAt: now, verificationSettings: { requireUserVerification, expectedOrigins: expectedOrigins, rpId, }, }; if (existingCredential) { // If the existing credential is already active, throw error if (existingCredential.status === "active") { logger.warn("Registration failed: Credential already exists", { credentialId: credentialIdStr, }); throw new APIError("BAD_REQUEST", { code: ERROR_CODES.SERVER.CREDENTIAL_EXISTS, message: ERROR_MESSAGES[ERROR_CODES.SERVER.CREDENTIAL_EXISTS], }); } // Update the existing revoked credential logger.info("Reactivating previously revoked passkey", { credentialId: credentialIdStr, previousStatus: existingCredential.status, clientPreferences: registrationOptions, }); await ctx.context.adapter.update({ model: schemaConfig.authPasskeyModel, where: [ { field: "id", operator: "eq", value: existingCredential.id }, ], update: { userId, platform, lastUsed: now, status: "active", updatedAt: now, publicKey: publicKeyStr, counter: 0, aaguid: aaguidStr, revokedAt: null, revokedReason: null, metadata: JSON.stringify(enhancedMetadata), }, }); } else { // Create new passkey record if one doesn't exist await ctx.context.adapter.create({ model: schemaConfig.authPasskeyModel, data: { id: ctx.context.generateId({ model: schemaConfig.authPasskeyModel, size: 32, }), userId, credentialId: credentialIdStr, publicKey: publicKeyStr, counter: 0, platform, aaguid: aaguidStr, lastUsed: now, status: "active", createdAt: now, updatedAt: now, metadata: JSON.stringify(enhancedMetadata), }, }); } // Delete the used challenge await ctx.context.adapter.delete({ model: schemaConfig.passkeyChallengeModel, where: [{ field: "id", operator: "eq", value: storedChallenge.id }], }); logger.info("WebAuthn passkey registration successful", { userId, credentialId: credentialIdStr, platform, isUpdate: !!existingCredential, clientPreferences: { userVerification: userVerificationRequirement, attestation: registrationOptions.attestation || "none", authenticatorAttachment: registrationOptions.authenticatorSelection ?.authenticatorAttachment, }, }); return ctx.json({ success: true, rpName, rpId, }); } catch (verificationError) { logger.error("WebAuthn verification failed:", { error: verificationError, userId, credentialId: credential.id, clientPreferences: registrationOptions, }); throw new APIError("BAD_REQUEST", { code: "VERIFICATION_FAILED", message: verificationError instanceof Error ? `WebAuthn verification failed: ${verificationError.message}` : "WebAuthn verification failed", }); } } catch (error) { logger.error("Registration error:", error); if (error instanceof APIError) throw error; throw new APIError("BAD_REQUEST", { code: ERROR_CODES.SERVER.REGISTRATION_FAILED, message: ERROR_MESSAGES[ERROR_CODES.SERVER.REGISTRATION_FAILED], }); } }); }; //# sourceMappingURL=register.js.map