expo-passkey
Version:
Passkey authentication for Expo apps with Better Auth integration
457 lines • 22 kB
JavaScript
/**
* @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