UNPKG

expo-passkey

Version:

Passkey authentication for Expo apps with Better Auth integration

655 lines 34.7 kB
/** * @file Core implementation of the Expo Passkey client * @module expo-passkey/client/core */ import { ERROR_CODES, PasskeyError } from "../types/errors"; import { authenticateWithNativePasskey, createNativePasskey, isNativePasskeySupported, } from "./native-module"; import { checkBiometricSupport } from "./utils/biometrics"; import { getDeviceInfo, hasPasskeysRegistered, hasUserPasskeysRegistered, } from "./utils/device"; import { storeCredentialId, getCredentialMetadata, updateCredentialLastUsed, getUserCredentialIds, removeCredentialId, } from "./utils/storage"; import { checkWebAuthnSupport, createAuthenticationOptions, createRegistrationOptions, setDeviceInfo, } from "./utils/webauthn"; /** * Client implementation of the Expo Passkey plugin with WebAuthn support */ class ExpoPasskeyClient { constructor(options = {}) { // Set defaults for options this.options = { storagePrefix: options.storagePrefix || "_better-auth", timeout: options.timeout || 60000, // 1 minute timeout by default }; // Check WebAuthn support this.webAuthnSupport = checkWebAuthnSupport(); setDeviceInfo(this.webAuthnSupport); } /** * Makes device info available to plugin actions */ async getDeviceInformation() { return getDeviceInfo(this.options); } /** * Get the client options */ getOptions() { return this.options; } /** * Check if WebAuthn is supported on this device */ async isWebAuthnSupported() { try { // First check platform requirements if (!this.webAuthnSupport.isSupported) { return false; } // Then check native module availability return await isNativePasskeySupported(); } catch (_error) { // Prefix with _ to indicate intentional unused variable return false; } } } /** * Creates an instance of the Expo Passkey client plugin with WebAuthn support * @param options Configuration options for the client plugin * @returns Better Auth client plugin instance */ export const expoPasskeyClient = (options = {}) => { // Create client 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) => { // Define the challenge function first so it can be referenced by other actions const getChallenge = async (data, fetchOptions) => { try { // Get challenge from server const { data: challengeData, error: challengeError } = await $fetch("/expo-passkey/challenge", { method: "POST", body: { userId: data.userId, 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 { /** * Gets a WebAuthn challenge from the server */ getChallenge, /** * Registers a new passkey for a user using WebAuthn */ registerPasskey: async (data, fetchOptions) => { try { // Check if WebAuthn is supported const isSupported = await client.isWebAuthnSupported(); if (!isSupported) { throw new PasskeyError(ERROR_CODES.WEBAUTHN.NOT_SUPPORTED, "WebAuthn is not supported on this device"); } // Get device information const deviceInfo = await client.getDeviceInformation(); // Check if biometric authentication is supported and enrolled const biometricSupport = await checkBiometricSupport(); if (!biometricSupport.isSupported || !biometricSupport.isEnrolled) { throw new PasskeyError(ERROR_CODES.BIOMETRIC.NOT_ENROLLED, "Biometric authentication must be set up for passkey registration"); } // 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 const challengeResult = await getChallenge({ userId: data.userId, type: "registration", registrationOptions, }); if (!challengeResult.data) { throw (challengeResult.error || new Error("Failed to get challenge")); } if (!data.rpId) { console.error("[ExpoPasskey] Missing rpId parameter - this is required for passkey registration"); throw new PasskeyError(ERROR_CODES.WEBAUTHN.INVALID_STATE, "Missing required parameter: rpId must be provided for passkey registration"); } if (!data.rpName) { console.error("[ExpoPasskey] Missing rpName parameter - this is required for passkey registration"); throw new PasskeyError(ERROR_CODES.WEBAUTHN.INVALID_STATE, "Missing required parameter: rpName must be provided for passkey registration"); } // Prepare registration options const webAuthnOptions = createRegistrationOptions(challengeResult.data.challenge, data.userId, data.userName, data.displayName || data.userName, data.rpId, data.rpName, { timeout: data.timeout || client.getOptions().timeout, attestation: data.attestation || "none", authenticatorSelection: data.authenticatorSelection || { authenticatorAttachment: "platform", userVerification: "required", residentKey: "required", }, }); // Invoke native module to create passkey const credential = await createNativePasskey({ requestJson: JSON.stringify(webAuthnOptions), }); // Make API request to register passkey const { data: registrationData, error: registrationError } = await $fetch("/expo-passkey/register", { method: "POST", body: { userId: data.userId, credential, platform: deviceInfo.platform, metadata: { deviceName: deviceInfo.model || undefined, deviceModel: deviceInfo.model, appVersion: deviceInfo.appVersion, manufacturer: deviceInfo.manufacturer, biometricType: biometricSupport.authenticationType, ...data.metadata, }, }, ...fetchOptions, }); // Check if response was successful if (registrationData) { // Store the credential ID for local tracking try { // Extract credential ID from the credential response const credentialId = credential.id; // Create additional metadata for the credential const credentialMetadata = { rpId: registrationData.rpId, deviceName: deviceInfo.model || undefined, displayName: data.displayName || data.userName, }; // Store in secure storage await storeCredentialId(credentialId, data.userId, client.getOptions(), credentialMetadata); // console.debug( // "[ExpoPasskey] Stored credential after registration:", // credentialId, // ); } catch (storageError) { // Don't fail the registration if local storage fails console.warn("[ExpoPasskey] Failed to store credential ID, but registration was successful:", storageError); } return { data: registrationData, error: null }; } // If there was an error in the response throw registrationError ? new Error(registrationError.message || `Registration failed: ${registrationError.statusText}`) : new Error("Failed to register passkey"); } catch (error) { return { data: null, error: error instanceof Error ? error : new Error(String(error)), }; } }, /** * Authenticates a user using a WebAuthn passkey */ authenticateWithPasskey: async (data, fetchOptions) => { try { // Check if WebAuthn is supported const isSupported = await client.isWebAuthnSupported(); if (!isSupported) { throw new PasskeyError(ERROR_CODES.WEBAUTHN.NOT_SUPPORTED, "WebAuthn is not supported on this device"); } // Get device information for metadata const deviceInfo = await client.getDeviceInformation(); // Get locally stored credential IDs to help with authentication let storedCredentials = {}; let allowCredentials = []; try { storedCredentials = await getCredentialMetadata(client.getOptions()); // If userId is provided, only include credentials for that user if (data?.userId) { const userCredentialIds = await getUserCredentialIds(data.userId, client.getOptions()); allowCredentials = userCredentialIds.map((id) => ({ id, type: "public-key", })); } else { // Otherwise include all credentials allowCredentials = Object.keys(storedCredentials).map((id) => ({ id, type: "public-key", })); } // console.debug( // `[ExpoPasskey] Using ${allowCredentials.length} stored credentials for authentication`, // ); } catch (storageError) { console.warn("[ExpoPasskey] Failed to get stored credentials:", storageError); // Continue with empty allowCredentials - the platform will use discoverable credentials } // If userId is provided, get challenge for that user // Otherwise, the native implementation will use a credential stored on the device let challenge = ""; const challengeUserId = data?.userId || "auto-discovery"; // Get a challenge from the server const challengeResult = await getChallenge({ userId: challengeUserId, type: "authentication", }); if (!challengeResult.data) { throw (challengeResult.error || new Error("Failed to get challenge")); } challenge = challengeResult.data.challenge; // Create authentication options const authenticationOptions = createAuthenticationOptions(challenge, data?.rpId || "", { timeout: data?.timeout || client.getOptions().timeout, userVerification: data?.userVerification || "required", allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined, }); // Invoke native module to authenticate with passkey const credential = await authenticateWithNativePasskey({ requestJson: JSON.stringify(authenticationOptions), }); // Make authentication request const { data: authData, error: authError } = await $fetch("/expo-passkey/authenticate", { method: "POST", body: { credential, metadata: { lastLocation: "mobile-app", appVersion: deviceInfo.appVersion, deviceModel: deviceInfo.model, manufacturer: deviceInfo.manufacturer, ...data?.metadata, }, }, credentials: "include", ...fetchOptions, }); // Check if response was successful if (authData) { try { // Update local credential storage const credentialId = credential.id; // If we already have this credential, update its last used time if (storedCredentials[credentialId]) { await updateCredentialLastUsed(credentialId, client.getOptions()); } else if (authData.user?.id) { // If this is a new credential, store it const credentialMetadata = { displayName: authData.user.email || authData.user.id, deviceName: deviceInfo.model || undefined, }; await storeCredentialId(credentialId, authData.user.id, client.getOptions(), credentialMetadata); } } catch (storageError) { console.warn("[ExpoPasskey] Failed to update credential storage, but authentication was successful:", storageError); // Continue anyway since authentication succeeded } return { data: authData, error: null }; } // If there was an error in the response return { data: null, error: authError ? new Error(authError.message || `Authentication failed: ${authError.statusText}`) : new Error("Authentication failed: Invalid or unexpected response format"), }; } catch (error) { return { data: null, error: error instanceof Error ? error : new Error(String(error)), }; } }, /** * Lists passkeys for a user */ listPasskeys: async (data, fetchOptions) => { try { if (!data.userId) { throw new PasskeyError(ERROR_CODES.SERVER.USER_NOT_FOUND, "userId is required"); } // Make request to list passkeys const response = 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, }); const { data: listData, error: listError } = response; // Check if response was successful if (listData) { // Sync passkeys with local storage try { const localCredentialIds = await getUserCredentialIds(data.userId, client.getOptions()); const serverPasskeys = listData.passkeys; for (const passkey of serverPasskeys) { // If we don't have this credential locally, store it if (!localCredentialIds.includes(passkey.credentialId)) { // Try to parse metadata let metadata = {}; if (typeof passkey.metadata === "string") { try { metadata = JSON.parse(passkey.metadata); } catch (_e) { // Ignore parsing errors } } else if (passkey.metadata && typeof passkey.metadata === "object") { metadata = passkey.metadata; } // Store the credential locally await storeCredentialId(passkey.credentialId, data.userId, client.getOptions(), { deviceName: metadata.deviceName || metadata.deviceModel || undefined, displayName: metadata.displayName || data.userId, lastUsedAt: passkey.lastUsed, registeredAt: passkey.createdAt, }); // console.debug( // "[ExpoPasskey] Synced server credential to local storage:", // passkey.credentialId, // ); } } } catch (storageError) { console.warn("[ExpoPasskey] Failed to sync passkeys with local storage:", storageError); // Continue anyway since listing succeeded } return { data: listData, error: null }; } // If there was an error in the response 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)), }; } }, /** * Revokes a passkey */ revokePasskey: async (data, fetchOptions) => { try { // Make request to revoke passkey const { data: revokeData, error: revokeError } = await $fetch("/expo-passkey/revoke", { method: "POST", body: { userId: data.userId, credentialId: data.credentialId, reason: data.reason, }, ...fetchOptions, }); // Check if response was successful if (revokeData && revokeData.success) { // Also remove from local storage try { await removeCredentialId(data.credentialId, client.getOptions()); // console.debug( // "[ExpoPasskey] Removed revoked credential from local storage:", // data.credentialId, // ); } catch (storageError) { console.warn("[ExpoPasskey] Failed to remove credential from local storage:", storageError); // Continue anyway since server revocation succeeded } return { data: revokeData, error: null }; } // If there was an error in the response 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)), }; } }, /** * Checks if passkey registration exists for a user */ checkPasskeyRegistration: async (userId, fetchOptions) => { try { const biometricSupport = await checkBiometricSupport(); const webAuthnSupported = await client.isWebAuthnSupported(); if (!webAuthnSupported) { return { isRegistered: false, credentialIds: [], biometricSupport, error: new Error("WebAuthn not supported on this device"), }; } // Check server first for cross-platform sync 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("Failed to retrieve passkey list")); } const passkeys = passkeysData.passkeys; const credentialIds = passkeys.map((pk) => pk.credentialId); const deviceInfo = await client.getDeviceInformation(); // Sync ALL server credentials to local storage // This ensures cross-platform passkeys are locally available try { for (const passkey of passkeys) { let metadata = {}; if (typeof passkey.metadata === "string") { try { metadata = JSON.parse(passkey.metadata); } catch (_e) { // Ignore parsing errors } } else if (passkey.metadata && typeof passkey.metadata === "object") { metadata = passkey.metadata; } // Store credential locally (will update if exists) await storeCredentialId(passkey.credentialId, userId, client.getOptions(), { deviceName: metadata.deviceName || metadata.deviceModel || undefined, displayName: metadata.displayName || userId, lastUsedAt: passkey.lastUsed, registeredAt: passkey.createdAt, // Mark as cross-platform if not created on this platform crossPlatform: passkey.platform !== deviceInfo.platform, originalPlatform: passkey.platform, }); } } catch (storageError) { console.warn("[ExpoPasskey] Failed to sync server credentials locally:", storageError); // Continue anyway - server credentials still work } return { isRegistered: credentialIds.length > 0, credentialIds, biometricSupport, error: null, }; } catch (error) { return { isRegistered: false, credentialIds: [], biometricSupport: null, error: error instanceof Error ? error : new Error(String(error)), }; } }, /** * Checks if passkeys are supported on this device */ isPasskeySupported: async () => { return client.isWebAuthnSupported(); }, /** * Gets biometric information for the device */ getBiometricInfo: async () => { return checkBiometricSupport(); }, /** * Gets device information */ getDeviceInfo: async () => { return client.getDeviceInformation(); }, /** * Gets the storage keys used by the plugin */ getStorageKeys: () => { const prefix = client.getOptions().storagePrefix; return { DEVICE_ID: `${prefix}.device_id`, STATE: `${prefix}.passkey_state`, USER_ID: `${prefix}.user_id`, CREDENTIAL_IDS: `${prefix}.credential_ids`, }; }, /** * Checks if this device has any registered passkeys */ hasPasskeysRegistered: async () => { return hasPasskeysRegistered(client.getOptions()); }, /** * Checks if a specific user has registered passkeys on this device */ hasUserPasskeysRegistered: async (userId) => { return hasUserPasskeysRegistered(userId, client.getOptions()); }, /** * Helper function to remove a credential ID from local storage */ removeLocalCredential: async (credentialId) => { try { await removeCredentialId(credentialId, client.getOptions()); } catch (error) { console.error("[ExpoPasskey] Failed to remove credential ID:", error); throw error; } }, }; }, fetchPlugins: [ { id: "expo-passkey-plugin", name: "Expo Passkey Plugin", description: "Handles passkey authentication and error handling", version: "1.0.0", hooks: { onError: async (context) => { // Handle authentication errors if (context.response?.status === 401) { console.warn("[ExpoPasskey] Authentication error in Expo Passkey plugin"); } }, }, init: async (url, options) => { try { // Add custom headers for diagnostics const deviceInfo = await client.getDeviceInformation(); const headers = {}; // Copy existing headers if any if (options?.headers) { if (options.headers instanceof Headers) { options.headers.forEach((value, key) => { headers[key] = value; }); } else if (Array.isArray(options.headers)) { options.headers.forEach(([key, value]) => { headers[key] = value; }); } else if (typeof options.headers === "object") { Object.assign(headers, options.headers); } } // Add custom headers headers["X-Client-Type"] = "expo-passkey"; 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) { // If error occurs, return original URL and options console.warn("[ExpoPasskey] Could not add custom headers:", error); return { url, options }; } }, }, ], }; }; //# sourceMappingURL=core.native.js.map