UNPKG

expo-passkey

Version:

Passkey authentication for Expo apps with Better Auth integration

457 lines 22 kB
/** * @file Web-only implementation of the Expo Passkey client * @module expo-passkey/client/core.web */ import { ERROR_CODES, PasskeyError } from "../types/errors"; // Web-specific imports - safe to import here since this file is web-only import { getWebAuthnBrowser, createWebRegistrationOptions, createWebAuthenticationOptions, isWebAuthnSupportedInBrowser, isPlatformAuthenticatorAvailable, } from "./utils/web"; /** * Web-only client implementation */ class ExpoPasskeyClient { constructor(options = {}) { this.options = { storagePrefix: options.storagePrefix || "_better-auth", timeout: options.timeout || 60000, }; } async getDeviceInformation() { // Handle cases where navigator might not be available const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "Unknown Browser"; const platform = typeof navigator !== "undefined" ? navigator.platform : "Unknown OS"; return { deviceId: `web-${Date.now()}`, platform: "web", model: userAgent, manufacturer: null, osVersion: platform, appVersion: "1.0.0", biometricSupport: { isSupported: isWebAuthnSupportedInBrowser(), isEnrolled: true, // Assume true for web availableTypes: [], authenticationType: "Platform Authenticator", error: null, platformDetails: { platform: "web", version: userAgent, }, }, }; } getOptions() { return this.options; } async isWebAuthnSupported() { return isWebAuthnSupportedInBrowser(); } } /** * Creates web-only Expo Passkey client plugin */ export const expoPasskeyClient = (options = {}) => { const client = new ExpoPasskeyClient(options); return { id: "expo-passkey", $InferServerPlugin: {}, pathMethods: { "/expo-passkey/challenge": "POST", "/expo-passkey/register": "POST", "/expo-passkey/authenticate": "POST", "/expo-passkey/list/:userId": "GET", "/expo-passkey/revoke": "POST", }, getActions: ($fetch) => { const getChallenge = async (data, fetchOptions) => { try { const { data: challengeData, error: challengeError } = await $fetch("/expo-passkey/challenge", { method: "POST", body: { ...(data.userId && { userId: data.userId }), // Only include if provided type: data.type, registrationOptions: data.registrationOptions, }, ...fetchOptions, }); if (challengeData) { return { data: challengeData, error: null }; } throw challengeError ? new Error(challengeError.message || `Failed to get challenge: ${challengeError.statusText}`) : new Error("Failed to get challenge"); } catch (error) { return { data: null, error: error instanceof Error ? error : new Error(String(error)), }; } }; return { getChallenge, /** * Web-only passkey registration */ registerPasskey: async (data, fetchOptions) => { try { const isSupported = await client.isWebAuthnSupported(); if (!isSupported) { throw new PasskeyError(ERROR_CODES.WEBAUTHN.NOT_SUPPORTED, "WebAuthn is not supported on this browser"); } // Get WebAuthn browser module (statically imported) const webAuthn = getWebAuthnBrowser(); // const deviceInfo = await client.getDeviceInformation(); // Prepare registration options to send to server const registrationOptions = { attestation: data.attestation, authenticatorSelection: data.authenticatorSelection, timeout: data.timeout || client.getOptions().timeout, }; // Get challenge from server // Note: userId is no longer sent to server for security - server gets it from session const challengeResult = await getChallenge({ type: "registration", registrationOptions, }); if (!challengeResult.data) { throw (challengeResult.error || new Error("Failed to get challenge")); } // Create registration options for web const webAuthnOptions = createWebRegistrationOptions(challengeResult.data.challenge, data.userId, data.userName, data.displayName || data.userName, data.rpId || client.getOptions().rpId || (typeof window !== "undefined" ? window.location.hostname : "localhost"), data.rpName || "App", { timeout: data.timeout || client.getOptions().timeout, attestation: data.attestation || "none", authenticatorSelection: data.authenticatorSelection || { authenticatorAttachment: "platform", userVerification: "preferred", residentKey: "preferred", }, }); // Start registration with WebAuthn browser const credential = await webAuthn.startRegistration({ optionsJSON: webAuthnOptions, }); // Register with server // Note: userId is no longer sent to server for security - server gets it from session const { data: registrationData, error: registrationError } = await $fetch("/expo-passkey/register", { method: "POST", body: { credential, platform: "web", metadata: { deviceName: typeof navigator !== "undefined" ? navigator.userAgent : "Unknown Browser", appVersion: "1.0.0", ...data.metadata, }, }, ...fetchOptions, }); if (registrationData) { return { data: registrationData, error: null }; } throw registrationError || new Error("Failed to register passkey"); } catch (error) { return { data: null, error: error instanceof Error ? error : new Error(String(error)), }; } }, /** * Web-only passkey authentication */ authenticateWithPasskey: async (data, fetchOptions) => { try { const isSupported = await client.isWebAuthnSupported(); if (!isSupported) { throw new PasskeyError(ERROR_CODES.WEBAUTHN.NOT_SUPPORTED, "WebAuthn is not supported on this browser"); } // Get WebAuthn browser module (statically imported) const webAuthn = getWebAuthnBrowser(); // const deviceInfo = await client.getDeviceInformation(); // Get challenge const challengeUserId = data?.userId || "auto-discovery"; const challengeResult = await getChallenge({ userId: challengeUserId, type: "authentication", }); if (!challengeResult.data) { throw (challengeResult.error || new Error("Failed to get challenge")); } // Create authentication options for web const authenticationOptions = createWebAuthenticationOptions(challengeResult.data.challenge, data?.rpId || client.getOptions().rpId || (typeof window !== "undefined" ? window.location.hostname : "localhost"), { timeout: data?.timeout || client.getOptions().timeout, userVerification: data?.userVerification || "preferred", }); // Start authentication with WebAuthn browser const credential = await webAuthn.startAuthentication({ optionsJSON: authenticationOptions, }); // Authenticate with server const { data: authData, error: authError } = await $fetch("/expo-passkey/authenticate", { method: "POST", body: { credential, metadata: { lastLocation: "web-app", appVersion: "1.0.0", ...data?.metadata, }, }, credentials: "include", ...fetchOptions, }); if (authData) { return { data: authData, error: null }; } // Fix error handling to properly extract message throw authError ? new Error(authError.message || `Authentication failed: ${authError.statusText}`) : new Error("Authentication failed"); } catch (error) { return { data: null, error: error instanceof Error ? error : new Error(String(error)), }; } }, /** * List passkeys for a user */ listPasskeys: async (data, fetchOptions) => { try { if (!data.userId) { throw new PasskeyError(ERROR_CODES.SERVER.USER_NOT_FOUND, "userId is required"); } const { data: listData, error: listError } = await $fetch(`/expo-passkey/list/${data.userId}`, { method: "GET", credentials: "include", headers: { Accept: "application/json", ...fetchOptions?.headers, }, query: { limit: data.limit?.toString(), offset: data.offset?.toString(), }, ...fetchOptions, }); if (listData) { return { data: listData, error: null }; } throw listError ? new Error(listError.message || `Failed to retrieve passkeys: ${listError.statusText}`) : new Error("Failed to retrieve passkeys"); } catch (error) { return { data: { passkeys: [], nextOffset: undefined, }, error: error instanceof Error ? error : new Error(String(error)), }; } }, /** * Revoke a passkey */ revokePasskey: async (data, fetchOptions) => { try { // Note: userId is no longer sent to server for security - server gets it from session const { data: revokeData, error: revokeError } = await $fetch("/expo-passkey/revoke", { method: "POST", body: { credentialId: data.credentialId, reason: data.reason, }, ...fetchOptions, }); if (revokeData && revokeData.success) { return { data: revokeData, error: null }; } throw revokeError ? new Error(revokeError.message || `Failed to revoke passkey: ${revokeError.statusText}`) : new Error("Failed to revoke passkey"); } catch (error) { return { data: null, error: error instanceof Error ? error : new Error(String(error)), }; } }, /** * Check passkey registration for user */ checkPasskeyRegistration: async (userId, fetchOptions) => { try { const webAuthnSupported = await client.isWebAuthnSupported(); if (!webAuthnSupported) { return { isRegistered: false, credentialIds: [], biometricSupport: null, // Not applicable for web error: new Error("WebAuthn not supported in this browser"), }; } // Check with server const { data: passkeysData, error: passkeysError } = await $fetch(`/expo-passkey/list/${userId}`, { method: "GET", credentials: "include", query: { limit: "50", }, ...fetchOptions, }); if (!passkeysData?.passkeys) { throw passkeysError ? new Error(passkeysError.message || `Failed to retrieve passkey list: ${passkeysError.statusText}`) : new Error("Failed to retrieve passkey list"); } const passkeys = passkeysData.passkeys; const credentialIds = passkeys.map((pk) => pk.credentialId); return { isRegistered: credentialIds.length > 0, credentialIds, biometricSupport: null, // Not applicable for web error: null, }; } catch (error) { return { isRegistered: false, credentialIds: [], biometricSupport: null, error: error instanceof Error ? error : new Error(String(error)), }; } }, isPasskeySupported: async () => { return client.isWebAuthnSupported(); }, // Web doesn't have biometric info - return null getBiometricInfo: async () => { return null; }, getDeviceInfo: async () => { return client.getDeviceInformation(); }, // Web doesn't use secure storage - return null getStorageKeys: () => { return null; }, // Web doesn't have local passkey storage hasPasskeysRegistered: async () => { return false; }, hasUserPasskeysRegistered: async (_userId) => { return false; }, // Web doesn't have local credential storage removeLocalCredential: async (_credentialId) => { // No-op for web }, // Web-specific functions isPlatformAuthenticatorAvailable: async () => { return isPlatformAuthenticatorAvailable(); }, }; }, fetchPlugins: [ { id: "expo-passkey-plugin", name: "Expo Passkey Plugin", description: "Handles passkey authentication and error handling", version: "2.0.0", hooks: { onError: async (context) => { if (context.response?.status === 401) { console.warn("[ExpoPasskey] Authentication error in Expo Passkey plugin"); } }, }, init: async (url, options) => { try { const deviceInfo = await client.getDeviceInformation(); const headers = {}; // Copy existing headers if any if (options?.headers) { if (options.headers instanceof Headers) { // Handle real Headers object options.headers.forEach((value, key) => { headers[key] = value; }); } else if (Array.isArray(options.headers)) { // Handle array format options.headers.forEach(([key, value]) => { headers[key] = value; }); } else if (typeof options.headers === "object" && "forEach" in options.headers && typeof options.headers .forEach === "function") { // Handle mock Headers object with forEach method options.headers.forEach((value, key) => { headers[key] = value; }); } else if (typeof options.headers === "object") { // Handle plain object Object.assign(headers, options.headers); } } // Add custom headers headers["X-Client-Type"] = "expo-passkey-web"; headers["X-Client-Version"] = "2.0.0"; headers["X-Platform"] = deviceInfo.platform; headers["X-Platform-Version"] = deviceInfo.osVersion; return { url, options: { ...options, headers, }, }; } catch (error) { console.warn("[ExpoPasskey] Could not add custom headers:", error); // Still return a valid response with at least basic headers return { url, options: { ...options, headers: { ...(options?.headers || {}), "X-Client-Type": "expo-passkey-web", "X-Client-Version": "2.0.0", }, }, }; } }, }, ], }; }; //# sourceMappingURL=core.web.js.map