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;