diff --git a/__test__/webauthnMethods.test.ts b/__test__/webauthnMethods.test.ts new file mode 100644 index 0000000..ed632e2 --- /dev/null +++ b/__test__/webauthnMethods.test.ts @@ -0,0 +1,135 @@ +// Fast unit tests (no Docker/testcontainers, no real browser): assert that +// each WebAuthn SDK method sends the right GraphQL operation and unwraps the +// response correctly. The actual browser ceremony glue (src/webauthn.ts) is +// exercised live in a real browser, not here - PublicKeyCredential doesn't +// exist in this jsdom-less Node test environment. +jest.mock('cross-fetch', () => jest.fn()); + +import crossFetch from 'cross-fetch'; +import { Authorizer } from '../lib'; + +const mockFetch = crossFetch as unknown as jest.Mock; + +const jsonResponse = (body: unknown) => + Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(body)), + }); + +describe('WebAuthn SDK methods', () => { + const authorizerRef = new Authorizer({ + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:8080/app', + }); + + afterEach(() => mockFetch.mockReset()); + + it('webauthnRegistrationOptions sends the right operation and unwraps options', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { webauthn_registration_options: { options: '{"challenge":"abc"}' } }, + }), + ); + const res = await authorizerRef.webauthnRegistrationOptions(); + expect(res.errors).toHaveLength(0); + expect(res.data?.options).toBe('{"challenge":"abc"}'); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.operationName).toBe('webauthn_registration_options'); + }); + + it('webauthnRegistrationVerify sends the credential and returns the message', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { webauthn_registration_verify: { message: 'Passkey registered successfully.' } }, + }), + ); + const res = await authorizerRef.webauthnRegistrationVerify({ + name: 'MacBook', + credential: '{"id":"cred-id"}', + }); + expect(res.errors).toHaveLength(0); + expect(res.data?.message).toBe('Passkey registered successfully.'); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.variables.data.credential).toBe('{"id":"cred-id"}'); + }); + + it('webauthnLoginOptions supports the usernameless (no email) call', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { webauthn_login_options: { options: '{"challenge":"xyz"}' } }, + }), + ); + const res = await authorizerRef.webauthnLoginOptions(); + expect(res.errors).toHaveLength(0); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.variables.email).toBeUndefined(); + }); + + it('webauthnLoginVerify returns the full auth token shape on success', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + webauthn_login_verify: { + message: 'Logged in successfully with passkey.', + access_token: 'token-abc', + user: { id: 'user-1', email: 'a@b.com' }, + }, + }, + }), + ); + const res = await authorizerRef.webauthnLoginVerify({ + credential: '{"id":"cred-id"}', + }); + expect(res.errors).toHaveLength(0); + expect(res.data?.access_token).toBe('token-abc'); + }); + + it('webauthnLoginVerify surfaces the email-verification gate error with its code', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + errors: [ + { + message: + 'email is not verified. please verify your email before signing in with a passkey', + extensions: { code: 'FAILED_PRECONDITION' }, + }, + ], + data: null, + }), + ); + const res = await authorizerRef.webauthnLoginVerify({ + credential: '{"id":"cred-id"}', + }); + expect(res.errors).toHaveLength(1); + expect(res.errors[0].code).toBe('FAILED_PRECONDITION'); + }); + + it('webauthnCredentials lists the caller\'s own passkeys', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + webauthn_credentials: [ + { id: 'cred-1', name: 'MacBook', transports: ['internal'] }, + ], + }, + }), + ); + const res = await authorizerRef.webauthnCredentials(); + expect(res.errors).toHaveLength(0); + expect(res.data).toHaveLength(1); + expect(res.data?.[0].name).toBe('MacBook'); + }); + + it('webauthnDeleteCredential sends the id and returns the message', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { webauthn_delete_credential: { message: 'Passkey deleted successfully.' } }, + }), + ); + const res = await authorizerRef.webauthnDeleteCredential('cred-1'); + expect(res.errors).toHaveLength(0); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.variables.id).toBe('cred-1'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 1b338a2..f31cc14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { trimURL, } from './utils'; import { toSDKError } from './errors'; +import * as webauthn from './webauthn'; // re-usable gql response fragment const userFragment = @@ -51,6 +52,7 @@ function toErrorList(errors: unknown): Types.AuthorizerSDKError[] { export * from './types'; export { AuthorizerAdmin } from './admin'; +export { isWebauthnSupported } from './webauthn'; export { CLIENT_ASSERTION_TYPE_JWT_BEARER, GRANT_TYPE_AUTHORIZATION_CODE, @@ -611,6 +613,151 @@ export class Authorizer { } }; + // WebAuthn / passkey ops are GraphQL-only (no REST route), so these call + // graphqlQuery directly rather than going through dispatch. + + webauthnRegistrationOptions = async ( + email?: string, + ): Promise> => { + try { + const res = await this.graphqlQuery({ + query: + 'mutation webauthn_registration_options($email: String) { webauthn_registration_options(email: $email) { options } }', + variables: { email }, + operationName: 'webauthn_registration_options', + }); + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.webauthn_registration_options); + } catch (err) { + return this.errorResponse([err]); + } + }; + + webauthnRegistrationVerify = async ( + data: Types.WebauthnRegistrationVerifyRequest, + ): Promise> => { + try { + const res = await this.graphqlQuery({ + query: + 'mutation webauthn_registration_verify($data: WebauthnRegistrationVerifyRequest!) { webauthn_registration_verify(params: $data) { message } }', + variables: { data }, + operationName: 'webauthn_registration_verify', + }); + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.webauthn_registration_verify); + } catch (err) { + return this.errorResponse([err]); + } + }; + + webauthnLoginOptions = async ( + email?: string, + ): Promise> => { + try { + const res = await this.graphqlQuery({ + query: + 'mutation webauthn_login_options($email: String) { webauthn_login_options(email: $email) { options } }', + variables: { email }, + operationName: 'webauthn_login_options', + }); + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.webauthn_login_options); + } catch (err) { + return this.errorResponse([err]); + } + }; + + webauthnLoginVerify = async ( + data: Types.WebauthnLoginVerifyRequest, + ): Promise> => { + try { + const res = await this.graphqlQuery({ + query: `mutation webauthn_login_verify($data: WebauthnLoginVerifyRequest!) { webauthn_login_verify(params: $data) { ${authTokenFragment} } }`, + variables: { data }, + operationName: 'webauthn_login_verify', + }); + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.webauthn_login_verify); + } catch (err) { + return this.errorResponse([err]); + } + }; + + webauthnCredentials = async (): Promise< + Types.ApiResponse + > => { + try { + const res = await this.graphqlQuery({ + query: + 'query webauthn_credentials { webauthn_credentials { id name transports created_at updated_at last_used_at } }', + operationName: 'webauthn_credentials', + }); + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.webauthn_credentials); + } catch (err) { + return this.errorResponse([err]); + } + }; + + webauthnDeleteCredential = async ( + id: string, + ): Promise> => { + try { + const res = await this.graphqlQuery({ + query: + 'mutation webauthn_delete_credential($id: ID!) { webauthn_delete_credential(id: $id) { message } }', + variables: { id }, + operationName: 'webauthn_delete_credential', + }); + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.webauthn_delete_credential); + } catch (err) { + return this.errorResponse([err]); + } + }; + + // registerPasskey drives the full registration ceremony end to end: fetch + // options from the server, prompt the platform authenticator via the + // browser's WebAuthn API, and send the resulting credential back for + // verification. Requires an authenticated session (a passkey is always + // added to the caller's own account). + registerPasskey = async ( + name?: string, + ): Promise> => { + try { + const optRes = await this.webauthnRegistrationOptions(); + if (optRes.errors.length) return this.errorResponse(optRes.errors); + const credential = await webauthn.registerPasskey( + optRes.data!.options, + ); + return this.webauthnRegistrationVerify({ name, credential }); + } catch (err) { + return this.errorResponse([err]); + } + }; + + // loginWithPasskey drives the full login ceremony end to end. Omit `email` + // for a usernameless (discoverable-credential) login; pass it to scope the + // ceremony to one account's own passkeys (the MFA-alternative flow). + loginWithPasskey = async ( + email?: string, + ): Promise> => { + try { + const optRes = await this.webauthnLoginOptions(email); + if (optRes.errors.length) return this.errorResponse(optRes.errors); + const credential = await webauthn.loginWithPasskey(optRes.data!.options); + return this.webauthnLoginVerify({ credential }); + } catch (err) { + return this.errorResponse([err]); + } + }; + resetPassword = async ( data: Types.ResetPasswordRequest, ): Promise> => { diff --git a/src/types.ts b/src/types.ts index ca18e6f..104b195 100644 --- a/src/types.ts +++ b/src/types.ts @@ -232,6 +232,38 @@ export interface VerifyOTPRequest { // Keep VerifyOtpRequest as alias for backward compatibility export type VerifyOtpRequest = VerifyOTPRequest; +// WebAuthn / passkey types. `options`/`credential` are opaque JSON strings - +// the server's PublicKeyCredentialCreationOptionsJSON / RequestOptionsJSON on +// the way out, and the browser's RegistrationResponseJSON / +// AuthenticationResponseJSON (from PublicKeyCredential.toJSON()) on the way +// back in. See src/webauthn.ts for the browser ceremony glue. +export interface WebauthnRegistrationOptionsResponse { + options: string; +} + +export interface WebauthnRegistrationVerifyRequest { + name?: string | null; + credential: string; +} + +export interface WebauthnLoginOptionsResponse { + options: string; +} + +export interface WebauthnLoginVerifyRequest { + state?: string | null; + credential: string; +} + +export interface WebauthnCredentialInfo { + id: string; + name: string; + transports?: string[] | null; + created_at?: number | null; + updated_at?: number | null; + last_used_at?: number | null; +} + // ResendOTPRequest export interface ResendOTPRequest { email?: string | null; diff --git a/src/webauthn.ts b/src/webauthn.ts new file mode 100644 index 0000000..3e44276 --- /dev/null +++ b/src/webauthn.ts @@ -0,0 +1,96 @@ +// Browser-side glue for the WebAuthn/passkey ceremony. The server emits +// options and expects a credential response in the exact JSON shapes defined +// by the WebAuthn spec (PublicKeyCredentialCreationOptionsJSON / +// RequestOptionsJSON on the way out, RegistrationResponseJSON / +// AuthenticationResponseJSON on the way back) - go-webauthn (the server-side +// library) is built around this exact wire format specifically to interop +// with the browser's own PublicKeyCredential.parseCreationOptionsFromJSON / +// parseRequestOptionsFromJSON / credential.toJSON(), so we use those directly +// rather than hand-rolling base64url<->ArrayBuffer conversion. +import { hasWindow } from './utils'; +import * as Types from './types'; + +export const isWebauthnSupported = (): boolean => + hasWindow() && + typeof window.PublicKeyCredential !== 'undefined' && + typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === + 'function' && + typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === + 'function' && + typeof window.PublicKeyCredential.prototype?.toJSON === 'function'; + +const assertSupported = () => { + if (!isWebauthnSupported()) { + throw new Error( + 'Passkeys are not supported in this browser (missing the WebAuthn PublicKeyCredential JSON APIs).', + ); + } +}; + +// wrapCeremonyError normalizes what navigator.credentials.create/get can +// throw. A DOMException (e.g. NotAllowedError for "user cancelled or the +// ceremony timed out") gets its standard `.name` attached as `.code` - the +// same additive, non-enumerable `code` field GraphQL errors carry - so +// callers can branch on it via one consistent field instead of string- +// matching a message, without us having to invent new code names for +// exceptions the browser already names. +const wrapCeremonyError = (err: unknown, action: string): Error => { + if (err instanceof DOMException) { + const wrapped: Types.AuthorizerSDKError = new Error( + err.message || `${action} was cancelled or failed.`, + ); + Object.defineProperty(wrapped, 'code', { + value: err.name, + enumerable: false, + writable: true, + configurable: true, + }); + return wrapped; + } + if (err instanceof Error) return err; + return new Error(`${action} was cancelled or failed.`); +}; + +// registerPasskey drives a full registration ceremony against the browser: +// pass the `options` string returned by webauthn_registration_options, get +// back the `credential` string to send to webauthn_registration_verify. +export const registerPasskey = async (optionsJSON: string): Promise => { + assertSupported(); + const options = window.PublicKeyCredential.parseCreationOptionsFromJSON( + JSON.parse(optionsJSON), + ); + let credential: Credential | null; + try { + credential = await navigator.credentials.create({ publicKey: options }); + } catch (err) { + throw wrapCeremonyError(err, 'Passkey registration'); + } + if (!credential || !('toJSON' in credential)) { + throw new Error('Passkey registration was cancelled or failed.'); + } + return JSON.stringify( + (credential as unknown as { toJSON: () => unknown }).toJSON(), + ); +}; + +// loginWithPasskey drives a full login (assertion) ceremony: pass the +// `options` string returned by webauthn_login_options, get back the +// `credential` string to send to webauthn_login_verify. +export const loginWithPasskey = async (optionsJSON: string): Promise => { + assertSupported(); + const options = window.PublicKeyCredential.parseRequestOptionsFromJSON( + JSON.parse(optionsJSON), + ); + let credential: Credential | null; + try { + credential = await navigator.credentials.get({ publicKey: options }); + } catch (err) { + throw wrapCeremonyError(err, 'Passkey login'); + } + if (!credential || !('toJSON' in credential)) { + throw new Error('Passkey login was cancelled or failed.'); + } + return JSON.stringify( + (credential as unknown as { toJSON: () => unknown }).toJSON(), + ); +};