import type { AuthenticationParams, BackupShareResult, BeginAuthenticationResponse, BeginRegistrationResponse, FetchFunction, FinishAuthenticationResponse, GetJwtFunction, LoginParams, PasskeyServiceConfig, PasskeyStatusResponse, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationParams, } from './types' import { sdkLogger } from '../logger' export class PasskeyService { private readonly defaultDomain: string private readonly getJwt: GetJwtFunction private readonly fetchImpl: FetchFunction constructor({ defaultDomain, getJwt, fetchImpl }: PasskeyServiceConfig) { this.defaultDomain = defaultDomain this.getJwt = getJwt this.fetchImpl = fetchImpl ?? fetch.bind(globalThis) } /** * Generate MPC backup share and register a passkey in the top-level window * while storing the provided encryption key with the relying party. */ public async registerPasskeyAndStoreKey( params: RegistrationParams, ): Promise { const { customDomain, encryptionKey } = params const { origin, relyingPartyOrigins, relyingPartyId, relyingPartyName } = this.resolveParams(params) const status = await this.getStatus(origin, relyingPartyId) const normalizedStatus = status?.toLowerCase() const shouldRegister = !normalizedStatus || normalizedStatus === 'not registered' || normalizedStatus === 'registered' if (!shouldRegister) { await this.loginAndStoreKey({ customDomain, relyingPartyId, relyingPartyOrigins, encryptionKey, }) return } const begin = await this.postJson( `${origin}/passkeys/begin-registration`, { relyingParty: relyingPartyId, relyingPartyName, relyingPartyOrigins, }, ) const credential = await this.createCredential(begin.options.publicKey) const attestation = serializeAttestationCredential(credential) await this.postJsonVoid( `${origin}/passkeys/finish-registration`, { sessionId: begin.sessionId, relyingParty: relyingPartyId, relyingPartyOrigins, attestation, encryptionKey, }, ) } /** * Authenticate with passkey in the current browsing context and return the encryption key. */ public async authenticatePasskeyAndRetrieveKey( params: AuthenticationParams, ): Promise { const { origin, relyingPartyOrigins, relyingPartyId, relyingPartyName } = this.resolveParams(params) const begin = await this.postJson( `${origin}/passkeys/begin-authentication`, { relyingParty: relyingPartyId, relyingPartyName, relyingPartyOrigins, }, ) const credential = await this.getCredential(begin.options.publicKey) const assertion = serializeAssertionCredential(credential) const finish = await this.postJson( `${origin}/passkeys/finish-authentication`, { sessionId: begin.sessionId, relyingParty: relyingPartyId, relyingPartyOrigins, assertion, }, ) if (!finish?.encryptionKey) { throw new Error('Passkey authentication succeeded but no encryption key was returned.') } return finish.encryptionKey } /** * Register a passkey without tying it to an encryption key. * The encryption key can be stored later using authenticatePasskeyAndWriteKey. */ public async registerPasskey( params: Omit, ): Promise { const { origin, relyingPartyOrigins, relyingPartyId, relyingPartyName } = this.resolveParams(params) const status = await this.getStatus(origin, relyingPartyId) const normalizedStatus = status?.toLowerCase() if (normalizedStatus && normalizedStatus !== 'not registered' && normalizedStatus !== 'registered') { throw new Error(`Passkey already exists with status: ${status}. Cannot register new credential.`) } const begin = await this.postJson( `${origin}/passkeys/begin-credential-registration`, { relyingParty: relyingPartyId, relyingPartyName, relyingPartyOrigins, }, ) const credential = await this.createCredential(begin.options.publicKey) const attestation = serializeAttestationCredential(credential) await this.postJsonVoid( `${origin}/passkeys/finish-credential-registration`, { sessionId: begin.sessionId, relyingParty: relyingPartyId, relyingPartyOrigins, attestation, }, ) } /** * Authenticate with passkey and store an encryption key. * Used after registerPasskey to associate an encryption key with the passkey. */ public async authenticatePasskeyAndWriteKey( params: AuthenticationParams & { encryptionKey: string }, ): Promise { const { encryptionKey } = params const { origin, relyingPartyOrigins, relyingPartyId, relyingPartyName } = this.resolveParams(params) const begin = await this.postJson( `${origin}/passkeys/begin-login`, { relyingParty: relyingPartyId, relyingPartyName, relyingPartyOrigins, }, ) const credential = await this.getCredential(begin.options.publicKey) const assertion = serializeAssertionCredential(credential) await this.postJsonVoid( `${origin}/passkeys/finish-login/write`, { sessionId: begin.sessionId, relyingParty: relyingPartyId, relyingPartyOrigins, assertion, encryptionKey, }, ) } /** * Utility used by combined backup flow to produce reusable payloads. */ public generateBackupSharePayload(cipherText: string, encryptionKey: string): BackupShareResult { return { cipherText, encryptionKey, } } private async createCredential( options: PublicKeyCredentialCreationOptionsJSON, ): Promise { const publicKey = decodeCreationOptions(options) const credential = (await navigator.credentials.create({ publicKey, })) as PublicKeyCredential | null if (!credential) { throw new Error('Passkey registration was cancelled or failed to create credential.') } return credential } private async loginAndStoreKey(params: LoginParams): Promise { const { customDomain, relyingPartyId, relyingPartyOrigins, encryptionKey } = params const origin = this.normalizeDomain(customDomain) const begin = await this.postJson( `${origin}/passkeys/begin-login`, { relyingParty: relyingPartyId, relyingPartyOrigins, }, ) const credential = await this.getCredential(begin.options.publicKey) const assertion = serializeAssertionCredential(credential) await this.postJsonVoid( `${origin}/passkeys/finish-login/write`, { sessionId: begin.sessionId, relyingParty: relyingPartyId, relyingPartyOrigins, assertion, encryptionKey, }, ) } private async getStatus( origin: string, relyingPartyId: string, ): Promise { try { const query = new URLSearchParams({ relyingParty: relyingPartyId }) const url = `${origin}/passkeys/status?${query.toString()}` const response = await this.fetchImpl(url, { method: 'GET', headers: await this.buildHeaders(), }) if (!response.ok) { return undefined } const raw = await response.text() if (!raw) { return undefined } const data = JSON.parse(raw) as PasskeyStatusResponse return data.status } catch (error) { sdkLogger.warn('[Portal] Failed to fetch passkey status', error) return undefined } } private async getCredential( options: PublicKeyCredentialRequestOptionsJSON, ): Promise { const publicKey = decodeRequestOptions(options) const credential = (await navigator.credentials.get({ publicKey, })) as PublicKeyCredential | null if (!credential) { throw new Error('Passkey authentication was cancelled or failed to resolve credential.') } return credential } private normalizeDomain(customDomain?: string): string { const domain = customDomain ?? this.defaultDomain if (!domain) { throw new Error('Passkey domain is not configured.') } if (domain.startsWith('http://') || domain.startsWith('https://')) { return domain } return `https://${domain}` } private buildRelyingPartyOrigins(origin: string): string[] { const set = new Set([origin]) if (typeof window !== 'undefined' && window.location?.origin) { set.add(window.location.origin) } return Array.from(set) } private resolveParams(options: { customDomain?: string relyingPartyId: string relyingPartyName?: string }): { origin: string relyingPartyOrigins: string[] relyingPartyId: string relyingPartyName?: string } { const origin = this.normalizeDomain(options.customDomain) const relyingPartyOrigins = this.buildRelyingPartyOrigins(origin) return { origin, relyingPartyOrigins, relyingPartyId: options.relyingPartyId, relyingPartyName: options.relyingPartyName, } } private async postJson( url: string, body: Record, ): Promise { const response = await this.fetchImpl(url, { method: 'POST', headers: await this.buildHeaders(), body: JSON.stringify(body), }) if (!response.ok) { const text = await safeReadText(response) throw new Error( `Passkey request failed (${response.status} ${response.statusText}): ${text}`, ) } return response.json() as Promise } private async postJsonVoid( url: string, body: Record, ): Promise { const response = await this.fetchImpl(url, { method: 'POST', headers: await this.buildHeaders(), body: JSON.stringify(body), }) if (!response.ok) { const text = await safeReadText(response) throw new Error( `Passkey request failed (${response.status} ${response.statusText}): ${text}`, ) } } private async buildHeaders(): Promise { const jwt = await this.getJwt() return { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, } } } const decodeCreationOptions = ( options: PublicKeyCredentialCreationOptionsJSON, ): PublicKeyCredentialCreationOptions => { const { excludeCredentials, ...rest } = options const publicKey: PublicKeyCredentialCreationOptions = { ...rest, challenge: base64UrlToArrayBuffer(options.challenge), user: { ...options.user, id: base64UrlToArrayBuffer(options.user.id), }, } if (excludeCredentials) { publicKey.excludeCredentials = excludeCredentials.map( (credential): PublicKeyCredentialDescriptor => ({ type: credential.type, id: base64UrlToArrayBuffer(credential.id), transports: credential.transports, }), ) } return publicKey } const decodeRequestOptions = ( options: PublicKeyCredentialRequestOptionsJSON, ): PublicKeyCredentialRequestOptions => { const { allowCredentials, ...rest } = options const publicKey: PublicKeyCredentialRequestOptions = { ...rest, challenge: base64UrlToArrayBuffer(options.challenge), } if (allowCredentials) { publicKey.allowCredentials = allowCredentials.map( (credential): PublicKeyCredentialDescriptor => ({ type: credential.type, id: base64UrlToArrayBuffer(credential.id), transports: credential.transports, }), ) } return publicKey } const serializeAttestationCredential = ( credential: PublicKeyCredential, ): string => { const { response } = credential const attestationResponse = response as AuthenticatorAttestationResponse const payload = { id: credential.id, rawId: arrayBufferToBase64Url(credential.rawId), type: credential.type, response: { clientDataJSON: arrayBufferToBase64Url(attestationResponse.clientDataJSON), attestationObject: attestationResponse.attestationObject ? arrayBufferToBase64Url(attestationResponse.attestationObject) : undefined, transports: attestationResponse.getTransports?.(), }, clientExtensionResults: credential.getClientExtensionResults(), } return JSON.stringify(payload) } const serializeAssertionCredential = ( credential: PublicKeyCredential, ): string => { const { response } = credential const assertionResponse = response as AuthenticatorAssertionResponse const payload = { id: credential.id, rawId: arrayBufferToBase64Url(credential.rawId), type: credential.type, response: { clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON), authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData), signature: arrayBufferToBase64Url(assertionResponse.signature), userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null, }, clientExtensionResults: credential.getClientExtensionResults(), } return JSON.stringify(payload) } const base64UrlToArrayBuffer = (value: string): ArrayBuffer => { const normalized = value.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=') const binary = typeof atob === 'function' ? atob(padded) : decodeWithBufferFallback(padded) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return bytes.buffer } const arrayBufferToBase64Url = (buffer: ArrayBuffer): string => { const bytes = new Uint8Array(buffer) let binary = '' for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]) } const base64 = typeof btoa === 'function' ? btoa(binary) : encodeWithBufferFallback(binary) return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } const safeReadText = async (response: Response): Promise => { try { return await response.text() } catch (error) { return (error as Error).message } } export default PasskeyService const decodeWithBufferFallback = (value: string): string => { const bufferCtor = (globalThis as Record).Buffer if (bufferCtor) { return bufferCtor.from(value, 'base64').toString('binary') } throw new Error('Base64 decoding is not supported in this environment.') } const encodeWithBufferFallback = (binary: string): string => { const bufferCtor = (globalThis as Record).Buffer if (bufferCtor) { return bufferCtor.from(binary, 'binary').toString('base64') } throw new Error('Base64 encoding is not supported in this environment.') }