mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
3c91691d26
commit
ff7dc5d875
4 changed files with 35 additions and 7 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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}`, {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue