UNPKG

expo-passkey

Version:

Passkey authentication for Expo apps with Better Auth integration

261 lines 12.2 kB
/** * @file Authenticate passkey endpoint * @description WebAuthn-based implementation for passkey authentication */ import { verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { createAuthEndpoint } from "better-auth/api"; import { setCookieCache, setSessionCookie } from "better-auth/cookies"; import { APIError } from "better-call"; import { ERROR_CODES, ERROR_MESSAGES } from "../../types/errors"; import { authenticatePasskeySchema } from "../utils/schema"; /** * Create WebAuthn passkey authentication endpoint */ export const createAuthenticateEndpoint = (options) => { const { logger, rpId, origin, 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/authenticate", { method: "POST", body: authenticatePasskeySchema, metadata: { openapi: { description: "Authenticate using a registered WebAuthn passkey", tags: ["Authentication"], responses: { 200: { description: "Authentication successful", content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, user: { type: "object", properties: { id: { type: "string" }, email: { type: "string" }, emailVerified: { type: "boolean" }, }, }, }, }, }, }, }, 401: { description: "Authentication failed", content: { "application/json": { schema: { type: "object", properties: { error: { type: "object", properties: { code: { type: "string" }, message: { type: "string" }, }, }, }, }, }, }, }, }, }, }, }, async (ctx) => { const { credential, metadata } = ctx.body; const credentialId = credential?.id; try { logger.debug("WebAuthn authentication attempt:", { credentialId }); // Find the credential by its ID const passkey = await ctx.context.adapter.findOne({ model: schemaConfig.authPasskeyModel, where: [ { field: "credentialId", operator: "eq", value: credentialId }, { field: "status", operator: "eq", value: "active" }, ], }); if (!passkey) { logger.warn("Authentication failed: Invalid credential", { credentialId, }); throw new APIError("UNAUTHORIZED", { code: ERROR_CODES.SERVER.INVALID_CREDENTIAL, message: ERROR_MESSAGES[ERROR_CODES.SERVER.INVALID_CREDENTIAL], }); } // Get the latest challenge for this user const userChallenges = await ctx.context.adapter.findMany({ model: schemaConfig.passkeyChallengeModel, where: [ { field: "userId", operator: "eq", value: passkey.userId }, { field: "type", operator: "eq", value: "authentication" }, ], sortBy: { field: "createdAt", direction: "desc" }, limit: 1, }); const autoDiscoveryChallenges = await ctx.context.adapter.findMany({ model: schemaConfig.passkeyChallengeModel, where: [ { field: "userId", operator: "eq", value: "auto-discovery" }, { field: "type", operator: "eq", value: "authentication" }, ], sortBy: { field: "createdAt", direction: "desc" }, limit: 1, }); // Combine and sort to get the most recent challenge const allChallenges = [ ...userChallenges, ...autoDiscoveryChallenges, ].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); const storedChallenge = allChallenges[0]; if (!storedChallenge) { logger.warn("Authentication failed: No challenge found", { userId: passkey.userId, }); throw new APIError("BAD_REQUEST", { code: "INVALID_CHALLENGE", message: "No challenge found for authentication", }); } // Check if challenge has expired if (new Date(storedChallenge.expiresAt) < new Date()) { logger.warn("Authentication failed: Challenge expired", { userId: passkey.userId, }); throw new APIError("BAD_REQUEST", { code: "EXPIRED_CHALLENGE", message: "Challenge has expired. Please request a new one.", }); } // Prepare credential for verification const verifiableCredential = credential; try { // Create verification options const verificationOptions = { response: verifiableCredential, expectedChallenge: storedChallenge.challenge, expectedOrigin: expectedOrigins, expectedRPID: rpId, requireUserVerification: true, credential: { id: passkey.credentialId, publicKey: isoBase64URL.toBuffer(passkey.publicKey), counter: passkey.counter, }, }; // Verify the authentication response const verification = await verifyAuthenticationResponse(verificationOptions); if (!verification.verified) { throw new Error("Verification failed"); } // Find the user associated with the credential const user = await ctx.context.adapter.findOne({ model: "user", where: [{ field: "id", operator: "eq", value: passkey.userId }], }); if (!user) { logger.error("Authentication failed: User not found", { credentialId, userId: passkey.userId, }); throw new APIError("UNAUTHORIZED", { code: ERROR_CODES.SERVER.USER_NOT_FOUND, message: ERROR_MESSAGES[ERROR_CODES.SERVER.USER_NOT_FOUND], }); } const now = new Date().toISOString(); // Update passkey metadata and counter await ctx.context.adapter.update({ model: schemaConfig.authPasskeyModel, where: [{ field: "id", operator: "eq", value: passkey.id }], update: { lastUsed: now, updatedAt: now, // Update counter from authentication response counter: verification.authenticationInfo.newCounter, metadata: JSON.stringify({ ...JSON.parse(passkey.metadata || "{}"), ...metadata, lastAuthenticationAt: now, }), }, }); // Delete the used challenge await ctx.context.adapter.delete({ model: schemaConfig.passkeyChallengeModel, where: [{ field: "id", operator: "eq", value: storedChallenge.id }], }); // Create session token using internal adapter // We pass false to prevent automatic cookie setting const sessionToken = await ctx.context.internalAdapter.createSession(user.id, ctx, false); // Get session configuration from context const sessionConfig = ctx.context.options.session || {}; // Calculate expiration based on configured expiresIn or default to 7 days const expiresInSeconds = sessionConfig.expiresIn || 7 * 24 * 60 * 60; const expiresAt = new Date(Date.now() + expiresInSeconds * 1000); // Create session data matching better-auth's expected structure const sessionData = { session: { // Standard session properties id: sessionToken.token, userId: user.id, token: sessionToken.token, expiresAt, createdAt: new Date(), updatedAt: new Date(), // Request metadata ipAddress: ctx.request?.headers?.get("x-forwarded-for") || ctx.request?.headers?.get("x-real-ip") || null, userAgent: ctx.request?.headers?.get("user-agent") || null, }, user, }; // Set the session cookie with our manually constructed data await setSessionCookie(ctx, sessionData); // Set session data cache if enabled in configuration if (sessionConfig.cookieCache?.enabled) { await setCookieCache(ctx, sessionData); } logger.info("WebAuthn authentication successful", { userId: user.id, credentialId, }); // Return response with token and user data return ctx.json({ token: sessionToken.token, user: sessionData.user, }); } catch (verificationError) { logger.error("WebAuthn verification failed:", verificationError); throw new APIError("UNAUTHORIZED", { code: "VERIFICATION_FAILED", message: verificationError instanceof Error ? verificationError.message : "WebAuthn verification failed", }); } } catch (error) { logger.error("Authentication error:", error); if (error instanceof APIError) throw error; throw new APIError("UNAUTHORIZED", { code: ERROR_CODES.SERVER.AUTHENTICATION_FAILED, message: ERROR_MESSAGES[ERROR_CODES.SERVER.AUTHENTICATION_FAILED], }); } }); }; //# sourceMappingURL=authenticate.js.map