From ff7dc5d875fd69736218850fb0297eaf09e92a3e Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 8 Apr 2026 12:40:51 +0200 Subject: [PATCH] feat(auth): structured error codes + conditional passkey UI - Add AuthErrorCode union and typed twoFactorRedirect/retryAfter fields on AuthResult so the frontend can branch on stable codes instead of locale-dependent error strings. - Extend signInWithPasskey with an optional { conditional } flag, threaded through to @simplewebauthn/browser via useBrowserAutofill, so hosts can opt into WebAuthn Conditional UI (passkey suggestions inline in the email autofill dropdown). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/stores/createManaAuthStore.svelte.ts | 4 ++-- packages/shared-auth-ui/src/types.ts | 20 +++++++++++++++++++ packages/shared-auth/src/core/authService.ts | 16 +++++++++++---- packages/shared-auth/src/types/index.ts | 2 +- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/shared-auth-ui/src/stores/createManaAuthStore.svelte.ts b/packages/shared-auth-ui/src/stores/createManaAuthStore.svelte.ts index 9635d8b5c..0ec2436d8 100644 --- a/packages/shared-auth-ui/src/stores/createManaAuthStore.svelte.ts +++ b/packages/shared-auth-ui/src/stores/createManaAuthStore.svelte.ts @@ -186,11 +186,11 @@ export function createManaAuthStore(config: ManaAuthStoreConfig = {}) { return authService.isPasskeyAvailable(); }, - async signInWithPasskey() { + async signInWithPasskey(options?: { conditional?: boolean }) { const authService = getAuthService(); if (!authService) return { success: false, error: 'Auth not available on server' }; try { - const result = await authService.signInWithPasskey(); + const result = await authService.signInWithPasskey(options); if (!result.success) return { success: false, error: result.error || 'Passkey authentication failed' }; user = await authService.getUserFromToken(); diff --git a/packages/shared-auth-ui/src/types.ts b/packages/shared-auth-ui/src/types.ts index 017e34da2..fd70c73ed 100644 --- a/packages/shared-auth-ui/src/types.ts +++ b/packages/shared-auth-ui/src/types.ts @@ -35,13 +35,33 @@ export interface AuthServiceInterface { forgotPassword(email: string): Promise; } +/** + * Structured error code for an auth operation. Frontend should branch on + * this rather than parsing the human-readable `error` string, which is + * locale-dependent. + */ +export type AuthErrorCode = + | 'INVALID_CREDENTIALS' + | 'EMAIL_NOT_VERIFIED' + | 'RATE_LIMITED' + | 'ACCOUNT_LOCKED' + | 'NETWORK_ERROR' + | 'UNKNOWN'; + /** * Result from auth operations */ export interface AuthResult { success: boolean; + /** Human-readable, possibly localized error message */ error?: string; + /** Stable, locale-independent error code for branching */ + errorCode?: AuthErrorCode; needsVerification?: boolean; + /** Set when sign-in succeeded but a 2FA challenge must be completed */ + twoFactorRedirect?: boolean; + /** Seconds until the user may retry, set on RATE_LIMITED / ACCOUNT_LOCKED */ + retryAfter?: number; } /** diff --git a/packages/shared-auth/src/core/authService.ts b/packages/shared-auth/src/core/authService.ts index f45967b2e..d31184c16 100644 --- a/packages/shared-auth/src/core/authService.ts +++ b/packages/shared-auth/src/core/authService.ts @@ -436,9 +436,14 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa }, /** - * Sign in with a passkey + * Sign in with a passkey. + * + * Pass `{ conditional: true }` to use the WebAuthn Conditional UI flow, + * where the browser surfaces passkeys directly inside the email autofill + * dropdown instead of opening a modal. The host MUST verify + * `PublicKeyCredential.isConditionalMediationAvailable()` first. */ - async signInWithPasskey(): Promise { + async signInWithPasskey(options: { conditional?: boolean } = {}): Promise { try { const { startAuthentication } = await import('@simplewebauthn/browser'); const storage = getStorageAdapter(); @@ -454,10 +459,13 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa return { success: false, error: err.message || 'Failed to get authentication options' }; } - const { options, challengeId } = await optionsRes.json(); + const { options: webauthnOptions, challengeId } = await optionsRes.json(); // Step 2: Authenticate via browser WebAuthn API - const credential = await startAuthentication({ optionsJSON: options }); + const credential = await startAuthentication({ + optionsJSON: webauthnOptions, + useBrowserAutofill: options.conditional === true, + }); // Step 3: Send credential to server for verification const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyAuthVerify}`, { diff --git a/packages/shared-auth/src/types/index.ts b/packages/shared-auth/src/types/index.ts index 2a78e6a01..f57bf412b 100644 --- a/packages/shared-auth/src/types/index.ts +++ b/packages/shared-auth/src/types/index.ts @@ -211,7 +211,7 @@ export interface AuthServiceInterface { // Passkeys isPasskeyAvailable(): boolean; registerPasskey(friendlyName?: string): Promise; - signInWithPasskey(): Promise; + signInWithPasskey(options?: { conditional?: boolean }): Promise; listPasskeys(): Promise; deletePasskey(passkeyId: string): Promise; renamePasskey(passkeyId: string, friendlyName: string): Promise;