UNPKG

passkeys-prf-client

Version:

A client npm package for passkeys authentication with PRF functionality

938 lines (814 loc) 25 kB
// @ts-ignore import {TextEncoder} from 'text-encoding'; import { Config, ErrorWithMessage, PasskeysAuthResult, SignupBeginResponse, TokenPrfResponse, PromiseResult, RegisterBeginResponse, Result, SigninBeginResponse, SigninMethod, GetUserPasskeyCredentialsBackendResponse, GetUserPasskeyCredentialsResult, DeleteUserPasskeyCredentialResult, PasskeysRegisterResult, VerifyReAuthResponse, PasskeysReAuthResult, VerifyAuthResponse, } from './types'; export const ErrorCodes = { Unknown: 'Unknown', HTTP: 'HTTP', AbortError: 'AbortError', NotAllowedError: 'NotAllowedError', FailedCreateCredential: 'FailedCreateCredential', PassKeySignInVerificationFailed: 'PassKeySignInVerificationFailed', PRFNotSupported: 'PRFNotSupported', InvalidStateError: 'InvalidStateError', }; export const ErrorFrom = { BrowserWebAuthn: 'BrowserWebAuthn', Backend: 'Backend', PasswordlessDev: 'PasswordlessDev', Unknown: 'Unknown', }; export const prf_salt = 'passwordless-login'; export class PasskeysAuthError extends Error { from: string; errorCode: string; constructor(from: string, errorCode: string, message: string) { super(message); this.from = from; this.errorCode = errorCode; } } export class PasswordlessService { private abortController: AbortController = new AbortController(); private config: Config; constructor(publicApiKey: string, backendApiRootUrl: string) { this.config = { apiUrl: 'https://v4.passwordless.dev', apiKey: publicApiKey, origin: window.location.origin, rpid: window.location.hostname, backendApiRootUrl: backendApiRootUrl, }; } private createHeaders(): Record<string, string> { return { ApiKey: this.config.apiKey, 'Content-Type': 'application/json', 'Client-Version': 'js-1.1.0', }; } public isBrowserSupported(): void { if ( !( window.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function' ) ) { throw new PasskeysAuthError( ErrorFrom.BrowserWebAuthn, ErrorCodes.Unknown, 'WebAuthn and PublicKeyCredentials are not supported on this browser/device' ); } } private async registerBegin( token: string ): PromiseResult<RegisterBeginResponse> { const response = await fetch(`${this.config.apiUrl}/register/begin`, { method: 'POST', headers: this.createHeaders(), body: JSON.stringify({ token, RPID: this.config.rpid, Origin: this.config.origin, }), }); const res = await response.json(); if (response.ok) { return res; } throw new PasskeysAuthError( ErrorFrom.PasswordlessDev, ErrorCodes.HTTP, `${response.status} ${res.message}` ); } private async registerComplete( credential: PublicKeyCredential, session: string, isPRFSupportRequired: boolean, nickname?: string ): PromiseResult<TokenPrfResponse> { const attestationResponse = credential.response as AuthenticatorAttestationResponse; const clientExtensionResults = credential.getClientExtensionResults(); //@ts-ignore delete clientExtensionResults.prf; const extensionResults = credential.getClientExtensionResults(); //@ts-ignore const prfKey = extensionResults.prf?.enabled ? btoa( String.fromCharCode.apply( null, Array.from( new Uint8Array( //@ts-ignore extensionResults.prf.results.first ) ) ) ) : null; if (!prfKey && isPRFSupportRequired) { throw new PasskeysAuthError( ErrorFrom.BrowserWebAuthn, ErrorCodes.PRFNotSupported, 'Browser or Authenticator does not support PRF!' ); } const response = await fetch(`${this.config.apiUrl}/register/complete`, { method: 'POST', headers: this.createHeaders(), body: JSON.stringify({ session: session, response: { id: credential.id, rawId: this.arrayBufferToBase64Url(credential.rawId), type: credential.type, clientExtensionResults, response: { AttestationObject: this.arrayBufferToBase64Url( attestationResponse.attestationObject ), clientDataJson: this.arrayBufferToBase64Url( attestationResponse.clientDataJSON ), }, }, RPID: this.config.rpid, Origin: this.config.origin, nickname, }), }); const res = await response.json(); if (!response.ok) { throw new PasskeysAuthError( ErrorFrom.PasswordlessDev, ErrorCodes.HTTP, `${response.status} ${res.message}` ); } return { ...res, prfKey, }; } private arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string { const uint8Array = (() => { if (Array.isArray(buffer)) return Uint8Array.from(buffer); if (buffer instanceof ArrayBuffer) return new Uint8Array(buffer); if (buffer instanceof Uint8Array) return buffer; const msg = 'Cannot convert from ArrayBuffer to Base64Url. Input was not of type ArrayBuffer, Uint8Array or Array'; console.error(msg, buffer); throw new Error(msg); })(); let string = ''; for (let i = 0; i < uint8Array.byteLength; i++) { string += String.fromCharCode(uint8Array[i]); } const base64String = window.btoa(string); return this.base64ToBase64Url(base64String); } private base64ToBase64Url(base64: string): string { return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, ''); } private async register( token: string, prf_salt: string, isPRFSupportRequired: boolean, nickname?: string ): Promise<Result<TokenPrfResponse> | void> { try { this.isBrowserSupported(); this.handleAbort(); const registration = await this.registerBegin(token); if (!registration.data) return; registration.data.challenge = this.base64UrlToArrayBuffer( registration.data.challenge ); registration.data.user.id = this.base64UrlToArrayBuffer( registration.data.user.id ); registration.data.excludeCredentials?.forEach( (cred: {id: string | BufferSource}) => { cred.id = this.base64UrlToArrayBuffer(cred.id); } ); //@ts-ignore registration.data.extensions.prf = {eval: {}}; //@ts-ignore registration.data.extensions.prf.eval.first = new TextEncoder().encode( prf_salt ); const credential = (await navigator.credentials.create({ publicKey: registration.data, signal: this.abortController.signal, })) as PublicKeyCredential; if (!credential) { throw new PasskeysAuthError( ErrorFrom.BrowserWebAuthn, ErrorCodes.FailedCreateCredential, 'Failed to create credential (navigator.credentials.create returned null)' ); } const result = await this.registerComplete( credential, registration.session, isPRFSupportRequired, nickname ); return { token: result.token ?? '', prfKey: result.prfKey ?? '', passkeyCredentialId: credential.id, error: undefined, }; // next steps // return a token from the API // Add a type to the token (method/action) } catch (caughtError: any) { if (caughtError instanceof PasskeysAuthError) { throw caughtError; } const errorMessage = this.getErrorMessage(caughtError); let errorCode; if (!caughtError) { errorCode = ErrorCodes.Unknown; } else if (caughtError.name === 'AbortError') { errorCode = ErrorCodes.AbortError; } else if (caughtError.name === 'NotAllowedError') { errorCode = ErrorCodes.NotAllowedError; } else if (caughtError.name === 'InvalidStateError') { errorCode = ErrorCodes.InvalidStateError; } else { errorCode = ErrorCodes.Unknown; } throw new PasskeysAuthError( ErrorFrom.BrowserWebAuthn, errorCode, errorMessage ); } } private base64UrlToArrayBuffer( base64UrlString: string | BufferSource ): ArrayBuffer { // improvement: Remove BufferSource-type and add proper types upstream if (typeof base64UrlString !== 'string') { const msg = 'Cannot convert from Base64Url to ArrayBuffer: Input was not of type string'; console.error(msg, base64UrlString); throw new TypeError(msg); } const base64Unpadded = this.base64UrlToBase64(base64UrlString); const paddingNeeded = (4 - (base64Unpadded.length % 4)) % 4; const base64Padded = base64Unpadded.padEnd( base64Unpadded.length + paddingNeeded, '=' ); const binary = window.atob(base64Padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } private base64UrlToBase64(base64Url: string): string { return base64Url.replace(/-/g, '+').replace(/_/g, '/'); } private getErrorMessage(error: unknown) { return this.toErrorWithMessage(error).message; } private toErrorWithMessage(maybeError: unknown): ErrorWithMessage { if (this.isErrorWithMessage(maybeError)) return maybeError; try { return new Error(JSON.stringify(maybeError)); } catch { // fallback in case there's an error stringifying the maybeError // like with circular references for example. return new Error(String(maybeError)); } } private isErrorWithMessage(error: unknown): error is ErrorWithMessage { return ( typeof error === 'object' && error !== null && 'message' in error && typeof (error as Record<string, unknown>)['message'] === 'string' ); } public async reAuthWithAlias( alias: string, isPRFSupportRequired = false, authToken?: string, backendData?: unknown ): PromiseResult<PasskeysReAuthResult> { try { const {token, prfKey, error, passkeyCredentialId} = await this.signin( {alias}, isPRFSupportRequired ); if (!token) throw error; const result = await this.verifyReAuth(token, authToken, backendData); if (!result) { throw new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.PassKeySignInVerificationFailed, 'Passkey signin verification failed!' ); } return { isPrfSupported: Boolean(prfKey), prfKey, verifyToken: result.verifyToken, passkeyCredentialId, error: undefined, }; } catch (err: any) { if (err instanceof PasskeysAuthError) { return {error: err}; } else { const error = new PasskeysAuthError( ErrorFrom.Unknown, ErrorCodes.Unknown, err?.message ); return {error}; } } } public async signinWithAlias( alias: string, isPRFSupportRequired = false ): PromiseResult<PasskeysAuthResult> { try { const {token, prfKey, error, passkeyCredentialId} = await this.signin( {alias}, isPRFSupportRequired ); if (!token) throw error; const result = await this.verifySignin(token); if (!result) { throw new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.PassKeySignInVerificationFailed, 'Passkey signin verification failed!' ); } return { isPrfSupported: Boolean(prfKey), prfKey, passkeyCredentialId, authToken: result.authToken, error: undefined, username: result.username, }; } catch (err: any) { if (err instanceof PasskeysAuthError) { return {error: err}; } else { const error = new PasskeysAuthError( ErrorFrom.Unknown, ErrorCodes.Unknown, err?.message ); return {error}; } } } public async signinWithAutofill( isPRFSupportRequired = false ): PromiseResult<PasskeysAuthResult> { try { if (!(await this.isAutofillSupported())) { throw new PasskeysAuthError( ErrorFrom.BrowserWebAuthn, ErrorCodes.Unknown, 'Autofill authentication (conditional meditation) is not supported in this browser' ); } const {token, prfKey, error, passkeyCredentialId} = await this.signin( {autofill: true}, isPRFSupportRequired ); if (!token) throw error; const result = await this.verifySignin(token); if (!result) { throw new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.PassKeySignInVerificationFailed, 'Passkey signin verification failed!' ); } return { isPrfSupported: Boolean(prfKey), passkeyCredentialId, prfKey, authToken: result.authToken, error: undefined, username: result.username, }; } catch (err: any) { if (err instanceof PasskeysAuthError) { return {error: err}; } else { const error = new PasskeysAuthError( ErrorFrom.Unknown, ErrorCodes.Unknown, err?.message ); return {error}; } } } private async isAutofillSupported(): Promise<boolean> { const PublicKeyCredential = window.PublicKeyCredential as any; // Typescript lacks support for this if (!PublicKeyCredential.isConditionalMediationAvailable) return false; return PublicKeyCredential.isConditionalMediationAvailable() as Promise<boolean>; } private handleAbort() { this.abort(); this.abortController = new AbortController(); } private abort() { if (this.abortController) { this.abortController.abort(); } } private async signinBegin( signinMethod: SigninMethod ): PromiseResult<SigninBeginResponse> { const response = await fetch(`${this.config.apiUrl}/signin/begin`, { method: 'POST', headers: this.createHeaders(), body: JSON.stringify({ userId: 'userId' in signinMethod ? signinMethod.userId : undefined, alias: 'alias' in signinMethod ? signinMethod.alias : undefined, RPID: this.config.rpid, Origin: this.config.origin, }), }); const res = await response.json(); if (response.ok) { return res; } throw new PasskeysAuthError( ErrorFrom.PasswordlessDev, ErrorCodes.HTTP, `${response.status} ${res.message}` ); } private async signinComplete( credential: PublicKeyCredential, session: string, isPRFSupportRequired: boolean ): PromiseResult<TokenPrfResponse> { const assertionResponse = credential.response as AuthenticatorAssertionResponse; const clientExtensionResults = credential.getClientExtensionResults(); //@ts-ignore delete clientExtensionResults.prf; const extensionResults = credential.getClientExtensionResults(); //@ts-ignore const prfKey = extensionResults.prf?.results?.first ? btoa( String.fromCharCode.apply( null, Array.from( new Uint8Array( //@ts-ignore extensionResults.prf.results.first ) ) ) ) : null; if (!prfKey && isPRFSupportRequired) { throw new PasskeysAuthError( ErrorFrom.BrowserWebAuthn, ErrorCodes.PRFNotSupported, 'Browser or Authenticator does not support PRF!' ); } const response = await fetch(`${this.config.apiUrl}/signin/complete`, { method: 'POST', headers: this.createHeaders(), body: JSON.stringify({ session: session, response: { id: credential.id, rawId: this.arrayBufferToBase64Url(new Uint8Array(credential.rawId)), type: credential.type, clientExtensionResults, response: { authenticatorData: this.arrayBufferToBase64Url( assertionResponse.authenticatorData ), clientDataJson: this.arrayBufferToBase64Url( assertionResponse.clientDataJSON ), signature: this.arrayBufferToBase64Url(assertionResponse.signature), }, }, RPID: this.config.rpid, Origin: this.config.origin, }), }); const res = await response.json(); if (!response.ok) { throw new PasskeysAuthError( ErrorFrom.PasswordlessDev, ErrorCodes.HTTP, `${response.status} ${res.message}` ); } return { ...res, //@ts-ignore prfKey, }; } private async signin( signinMethod: SigninMethod, isPRFSupportRequired: boolean ): PromiseResult<TokenPrfResponse> { try { this.isBrowserSupported(); this.handleAbort(); // if signinMethod is undefined, set it to an empty object // this will cause a login using discoverable credentials if (!signinMethod) { signinMethod = {discoverable: true}; } const signin = await this.signinBegin(signinMethod); if (!signin.data) throw signin.error; signin.data.challenge = this.base64UrlToArrayBuffer( signin.data.challenge ); signin.data.allowCredentials?.forEach( (cred: {id: string | BufferSource}) => { cred.id = this.base64UrlToArrayBuffer(cred.id); } ); signin.data.userVerification = 'required'; //@ts-ignore signin.data.extensions = {prf: {eval: {}}}; //@ts-ignore signin.data.extensions.prf.eval.first = new TextEncoder().encode( prf_salt ); const credential = (await navigator.credentials.get({ publicKey: signin.data, mediation: 'autofill' in signinMethod ? ('conditional' as CredentialMediationRequirement) : undefined, // Typescript doesn't know about 'conditational' yet signal: this.abortController.signal, })) as PublicKeyCredential; const response = await this.signinComplete( credential, signin.session, isPRFSupportRequired ); return { token: response.token ?? '', prfKey: response.prfKey ?? '', passkeyCredentialId: credential.id, error: undefined, }; } catch (caughtError: any) { if (caughtError instanceof PasskeysAuthError) { throw caughtError; } const errorMessage = this.getErrorMessage(caughtError); let errorCode; if (!caughtError) { errorCode = ErrorCodes.Unknown; } else if (caughtError.name === 'AbortError') { errorCode = ErrorCodes.AbortError; } else if (caughtError.name === 'NotAllowedError') { errorCode = ErrorCodes.NotAllowedError; } else if (caughtError.name === 'InvalidStateError') { errorCode = ErrorCodes.InvalidStateError; } else { errorCode = ErrorCodes.Unknown; } throw new PasskeysAuthError( ErrorFrom.BrowserWebAuthn, errorCode, errorMessage ); } } public async signup( name: string, email: string, isPRFSupportRequired = false, authtoken?: string, nickname?: string, backendData?: unknown ): PromiseResult<PasskeysRegisterResult> { const headers: any = {'Content-Type': 'application/json'}; if (authtoken) { headers['Authorization'] = `Bearer ${authtoken}`; } try { let response = await fetch( `${this.config.backendApiRootUrl}/passkeys-auth/signup/begin`, { method: 'POST', headers, body: JSON.stringify( backendData ? {name, email, ...backendData} : {name, email} ), } ); if (!response.ok) { throw new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(await response.json()).message}` ); } const data: SignupBeginResponse = await response.json(); const {token: registrationToken, userId} = data; const result = await this.register( registrationToken, prf_salt, isPRFSupportRequired, nickname ); let prfKey; let passkeyCredentialId; if (result) { prfKey = result.prfKey; passkeyCredentialId = result.passkeyCredentialId; } response = await fetch( `${this.config.backendApiRootUrl}/passkeys-auth/signup/complete`, { method: 'POST', headers, body: JSON.stringify( backendData ? {userId, ...backendData} : {userId} ), } ); if (!response.ok) { throw new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(await response.json()).message}` ); } return { isPrfSupported: Boolean(prfKey), prfKey, passkeyCredentialId, error: undefined, }; } catch (err: any) { return {error: err}; } } private async verifySignin(token: string): Promise<VerifyAuthResponse> { const response = await fetch( `${this.config.backendApiRootUrl}/passkeys-auth/signin/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({token}), } ); if (!response.ok) { throw new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(await response.json()).message}` ); } return (await response.json()) as VerifyAuthResponse; } private async verifyReAuth( token: string, authToken?: string, backendData?: unknown ): Promise<VerifyReAuthResponse> { const headers: any = {'Content-Type': 'application/json'}; if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } let body = {token}; if (backendData) { body = {token, ...backendData}; } const response = await fetch( `${this.config.backendApiRootUrl}/passkeys-auth/re-auth/verify`, { method: 'POST', headers, body: JSON.stringify(body), } ); if (!response.ok) { throw new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(await response.json()).message}` ); } return (await response.json()) as VerifyReAuthResponse; } public signupOrSigninAbort() { this.abort(); } //Passkeys Credentials Management public async getUserPasskeyCredentials( authtoken?: string, userId?: string ): PromiseResult<GetUserPasskeyCredentialsResult> { if (!authtoken && !userId) { return { error: new PasskeysAuthError( ErrorFrom.Unknown, ErrorCodes.Unknown, 'Please provide auth token or userId! ' ), }; } const headers: any = {'Content-Type': 'application/json'}; if (authtoken) { headers.Authorization = `Bearer ${authtoken}`; } const response = await fetch( `${this.config.backendApiRootUrl}/passkeys-auth/credentials/list${userId ? '?userId=' + userId : ''}`, { method: 'GET', headers, } ); const res: GetUserPasskeyCredentialsBackendResponse = await response.json(); if (!response.ok) { return { error: new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${res.message}` ), }; } return {credentials: res.credentials, error: undefined}; } public async deleteUserPasskeyCredential( credentialId: string, backendData?: unknown, authtoken?: string ): PromiseResult<DeleteUserPasskeyCredentialResult> { const headers: any = {'Content-Type': 'application/json'}; if (authtoken) { headers['Authorization'] = `Bearer ${authtoken}`; } let body = {credentialId}; if (backendData) { body = {credentialId, ...backendData}; } const response = await fetch( `${this.config.backendApiRootUrl}/passkeys-auth/credentials/delete`, { method: 'DELETE', headers, body: JSON.stringify(body), } ); if (!response.ok) { return { error: new PasskeysAuthError( ErrorFrom.Backend, ErrorCodes.HTTP, `${response.status} ${(await response.json()).message}` ), }; } return {isDeletionSuccessful: true, error: undefined}; } }