From a6a662994817aa229dc43db8443fb6abd350d446 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 11 Jul 2026 07:21:07 +0530 Subject: [PATCH 1/2] feat: add WebAuthn/passkey SDK methods Wraps the 6 new webauthn_* GraphQL operations (authorizerdev/authorizer#671) and adds the browser-side ceremony glue. Raw methods: webauthnRegistrationOptions/Verify, webauthnLoginOptions/Verify, webauthnCredentials, webauthnDeleteCredential - GraphQL-only (no REST route), so these call graphqlQuery directly rather than through dispatch. High-level orchestration: registerPasskey(name?) and loginWithPasskey(email?) drive the full options -> browser API -> verify round trip in one call. src/webauthn.ts uses the browser's native PublicKeyCredential. parseCreationOptionsFromJSON / parseRequestOptionsFromJSON / credential. toJSON() rather than hand-rolled base64url<->ArrayBuffer conversion - go-webauthn's JSON wire format is the spec-defined RegistrationResponseJSON/ AuthenticationResponseJSON shape those APIs are built around, confirmed by reading the library's struct tags directly rather than assuming. isWebauthnSupported() is exported for callers to feature-detect before showing passkey UI. --- __test__/webauthnMethods.test.ts | 135 ++++++++++++++++++++++++++++ src/index.ts | 147 +++++++++++++++++++++++++++++++ src/types.ts | 32 +++++++ src/webauthn.ts | 62 +++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 __test__/webauthnMethods.test.ts create mode 100644 src/webauthn.ts 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..edbd088 --- /dev/null +++ b/src/webauthn.ts @@ -0,0 +1,62 @@ +// 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'; + +export const isWebauthnSupported = (): boolean => + hasWindow() && + typeof window.PublicKeyCredential !== 'undefined' && + typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === + 'function' && + typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === + 'function'; + +const assertSupported = () => { + if (!isWebauthnSupported()) { + throw new Error( + 'Passkeys are not supported in this browser (missing the WebAuthn PublicKeyCredential JSON APIs).', + ); + } +}; + +// 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), + ); + const credential = await navigator.credentials.create({ + publicKey: options, + }); + 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), + ); + const credential = await navigator.credentials.get({ publicKey: options }); + if (!credential || !('toJSON' in credential)) { + throw new Error('Passkey login was cancelled or failed.'); + } + return JSON.stringify( + (credential as unknown as { toJSON: () => unknown }).toJSON(), + ); +}; From be799b6f398d4459d203e87e4ce89e9152e9ace1 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sat, 11 Jul 2026 11:58:28 +0530 Subject: [PATCH 2/2] fix: surface the browser's DOMException name as .code on ceremony errors registerPasskey/loginWithPasskey previously collapsed every navigator.credentials.create/get rejection (including the very common "user dismissed the passkey picker" case, a NotAllowedError) into a generic Error with only a message - no way for a caller to distinguish cancellation from a real failure the way GraphQL errors already allow via .code. wrapCeremonyError now attaches the DOMException's own standard .name (NotAllowedError, InvalidStateError, etc.) as an additive, non-enumerable .code - the same field GraphQL errors carry - so callers get one consistent way to branch on error kind rather than matching message text. Reuses the browser's own exception names instead of inventing new ones. Also fixed isWebauthnSupported to check for credential.toJSON() support, not just the two parseOptionsFromJSON statics - all three shipped together in the same WebAuthn Level 3 browser releases, but checking only two left a gap where the capability check could pass while the actual toJSON call later fails. --- src/webauthn.ts | 44 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/webauthn.ts b/src/webauthn.ts index edbd088..3e44276 100644 --- a/src/webauthn.ts +++ b/src/webauthn.ts @@ -8,6 +8,7 @@ // 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() && @@ -15,7 +16,8 @@ export const isWebauthnSupported = (): boolean => typeof window.PublicKeyCredential.parseCreationOptionsFromJSON === 'function' && typeof window.PublicKeyCredential.parseRequestOptionsFromJSON === - 'function'; + 'function' && + typeof window.PublicKeyCredential.prototype?.toJSON === 'function'; const assertSupported = () => { if (!isWebauthnSupported()) { @@ -25,6 +27,30 @@ const assertSupported = () => { } }; +// 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. @@ -33,9 +59,12 @@ export const registerPasskey = async (optionsJSON: string): Promise => { const options = window.PublicKeyCredential.parseCreationOptionsFromJSON( JSON.parse(optionsJSON), ); - const credential = await navigator.credentials.create({ - publicKey: options, - }); + 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.'); } @@ -52,7 +81,12 @@ export const loginWithPasskey = async (optionsJSON: string): Promise => const options = window.PublicKeyCredential.parseRequestOptionsFromJSON( JSON.parse(optionsJSON), ); - const credential = await navigator.credentials.get({ publicKey: options }); + 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.'); }