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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 12:40:51 +02:00
parent 3c91691d26
commit ff7dc5d875
4 changed files with 35 additions and 7 deletions

View file

@ -186,11 +186,11 @@ export function createManaAuthStore(config: ManaAuthStoreConfig = {}) {
return authService.isPasskeyAvailable(); return authService.isPasskeyAvailable();
}, },
async signInWithPasskey() { async signInWithPasskey(options?: { conditional?: boolean }) {
const authService = getAuthService(); const authService = getAuthService();
if (!authService) return { success: false, error: 'Auth not available on server' }; if (!authService) return { success: false, error: 'Auth not available on server' };
try { try {
const result = await authService.signInWithPasskey(); const result = await authService.signInWithPasskey(options);
if (!result.success) if (!result.success)
return { success: false, error: result.error || 'Passkey authentication failed' }; return { success: false, error: result.error || 'Passkey authentication failed' };
user = await authService.getUserFromToken(); user = await authService.getUserFromToken();

View file

@ -35,13 +35,33 @@ export interface AuthServiceInterface {
forgotPassword(email: string): Promise<AuthResult>; forgotPassword(email: string): Promise<AuthResult>;
} }
/**
* 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 * Result from auth operations
*/ */
export interface AuthResult { export interface AuthResult {
success: boolean; success: boolean;
/** Human-readable, possibly localized error message */
error?: string; error?: string;
/** Stable, locale-independent error code for branching */
errorCode?: AuthErrorCode;
needsVerification?: boolean; 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;
} }
/** /**

View file

@ -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<AuthResult> { async signInWithPasskey(options: { conditional?: boolean } = {}): Promise<AuthResult> {
try { try {
const { startAuthentication } = await import('@simplewebauthn/browser'); const { startAuthentication } = await import('@simplewebauthn/browser');
const storage = getStorageAdapter(); const storage = getStorageAdapter();
@ -454,10 +459,13 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
return { success: false, error: err.message || 'Failed to get authentication options' }; 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 // 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 // Step 3: Send credential to server for verification
const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyAuthVerify}`, { const verifyRes = await fetch(`${baseUrl}${endpoints.passkeyAuthVerify}`, {

View file

@ -211,7 +211,7 @@ export interface AuthServiceInterface {
// Passkeys // Passkeys
isPasskeyAvailable(): boolean; isPasskeyAvailable(): boolean;
registerPasskey(friendlyName?: string): Promise<AuthResult>; registerPasskey(friendlyName?: string): Promise<AuthResult>;
signInWithPasskey(): Promise<AuthResult>; signInWithPasskey(options?: { conditional?: boolean }): Promise<AuthResult>;
listPasskeys(): Promise<any[]>; listPasskeys(): Promise<any[]>;
deletePasskey(passkeyId: string): Promise<AuthResult>; deletePasskey(passkeyId: string): Promise<AuthResult>;
renamePasskey(passkeyId: string, friendlyName: string): Promise<AuthResult>; renamePasskey(passkeyId: string, friendlyName: string): Promise<AuthResult>;