feat(auth): error-classification layer + passkey end-to-end

Two interlocking fixes driven by a production lockout incident.

## Bug that motivated this

A fresh schema-drift column (auth.users.onboarding_completed_at) made
every Better Auth query crash with Postgres 42703. The /login wrapper
swallowed the non-2xx and mapped it onto a generic "401 Invalid
credentials" AND bumped the password lockout counter — so 5 legit
login attempts against a broken DB would have locked every real user
out of their own account. Same wrapper pattern on /register, /refresh,
/reset-password etc. The 30-minute hunt ended in a one-off repro
script that finally surfaced the real Postgres error.

The user-facing passkey button additionally returned generic 404s on
every login-page mount because the route wasn't registered (the DB
schema existed, the Better Auth plugin wasn't wired).

## Phase 1 — Error classification (services/mana-auth/src/lib/auth-errors)

- 19-code AuthErrorCode taxonomy (INVALID_CREDENTIALS, EMAIL_NOT_VERIFIED,
  ACCOUNT_LOCKED, SERVICE_UNAVAILABLE, PASSKEY_VERIFICATION_FAILED, …)
- classifyFromResponse/classifyFromError handle: Better Auth APIError
  (duck-typed on `name === 'APIError'`), Postgres errors (23505 unique,
  42703/08xxx → infra), ZodError, fetch/ECONNREFUSED network errors,
  bare Error, unknown.
- respondWithError routes the structured response, logs at the right
  level, fires the correct security event, and CRITICALLY only bumps
  the lockout counter for actual credential failures — SERVICE_UNAVAILABLE
  and INTERNAL never touch lockout.
- All 12 endpoints in routes/auth.ts refactored (/login, /register,
  /logout, /session-to-token, /refresh, /validate, /forgot-password,
  /reset-password, /resend-verification, /profile GET+POST,
  /change-email, /change-password, /account DELETE).
- Fixed pre-existing auth.api.forgetPassword typo (→ requestPasswordReset).
- shared-logger + requestLogger middleware wired in index.ts; all
  console.* calls in the service removed.

## Phase 2 — Passkey end-to-end (@better-auth/passkey 1.6+)

- sql/007_passkey_bootstrap.sql: idempotent schema alignment —
  friendly_name→name, +aaguid, transports jsonb→text, +method column
  on login_attempts.
- better-auth.config.ts: passkey plugin wired with rpID/rpName/origin
  from new webauthn config section. rpID defaults to mana.how in prod
  (from COOKIE_DOMAIN), localhost in dev.
- routes/passkeys.ts: 7 wrapper endpoints (capability probe,
  register/options+verify, authenticate/options+verify with JWT mint,
  list, delete, rename). Each routes errors through the classifier;
  authenticate/verify promotes generic INVALID_CREDENTIALS to
  PASSKEY_VERIFICATION_FAILED.
- PasskeyRateLimitService: in-memory per-IP (options: 20/min) and
  per-credential (verify: 10 failures/min → 5 min cooldown) buckets.
  Deliberately separate from the password lockout — different factor,
  different blast radius.
- Client: authService.getPasskeyCapability() async probe, memoised per
  session. authStore.passkeyAvailable reactive state. LoginPage gates
  on === true so a slow probe doesn't flash the button in.
- AuthResult grew a code: AuthErrorCode field; handleAuthError in
  shared-auth prefers the server envelope over the legacy message
  heuristics.

## Tests

- 30 unit tests for the classifier covering every branch (including
  the exact Postgres 42703 shape that started this).
- 9 unit tests for the rate limiter.
- 14 integration tests for the auth routes — the regression test
  explicitly asserts "upstream 500 → 503 + zero lockout bumps".
- 101 tests pass, 0 fail, 30 pre-existing skips unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-24 01:52:51 +02:00
parent b204958007
commit e66654068f
24 changed files with 3450 additions and 552 deletions

View file

@ -3,6 +3,8 @@ import type {
AuthServiceInterface,
AuthEndpoints,
AuthResult,
AuthErrorCode,
PasskeyCapability,
TokenRefreshResult,
UserData,
StorageKeys,
@ -64,6 +66,7 @@ const DEFAULT_ENDPOINTS: AuthEndpoints = {
passkeyAuthOptions: '/api/v1/auth/passkeys/authenticate/options',
passkeyAuthVerify: '/api/v1/auth/passkeys/authenticate/verify',
passkeyList: '/api/v1/auth/passkeys',
passkeyCapability: '/api/v1/auth/passkeys/capability',
};
/**
@ -77,6 +80,12 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
// Callback for token refresh events
let onTokenRefreshCallback: ((userData: UserData) => void) | null = null;
// Passkey-capability cache: one-time probe per service instance.
// Deduplicates concurrent callers via `inFlight`. See
// getPasskeyCapability() for the rationale.
let passkeyCapabilityCache: PasskeyCapability | null = null;
let passkeyCapabilityInFlight: Promise<PasskeyCapability> | null = null;
const service = {
/**
* Sign in with email and password
@ -158,21 +167,8 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 409) {
return {
success: false,
error:
errorData.code === 'EMAIL_ALREADY_REGISTERED'
? 'EMAIL_ALREADY_REGISTERED'
: 'Email already in use',
};
} else if (response.status === 400) {
return { success: false, error: errorData.message || 'Invalid email or password' };
}
return { success: false, error: errorData.message || 'Sign up failed' };
const errorData = await response.json().catch(() => ({}));
return service.handleAuthError(response.status, errorData);
}
const data = await response.json();
@ -228,13 +224,8 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('rate limit')) {
return { success: false, error: 'Too many attempts. Please wait before trying again.' };
}
return { success: false, error: errorData.message || 'Password reset failed' };
const errorData = await response.json().catch(() => ({}));
return service.handleAuthError(response.status, errorData);
}
trackAuth('password_reset_requested');
@ -260,17 +251,8 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('expired')) {
return { success: false, error: 'Reset link has expired. Please request a new one.' };
}
if (errorData.message?.includes('invalid')) {
return { success: false, error: 'Invalid reset link. Please request a new one.' };
}
return { success: false, error: errorData.message || 'Password reset failed' };
const errorData = await response.json().catch(() => ({}));
return service.handleAuthError(response.status, errorData);
}
return { success: true };
@ -297,11 +279,8 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Failed to resend verification email',
};
const errorData = await response.json().catch(() => ({}));
return service.handleAuthError(response.status, errorData);
}
return { success: true };
@ -370,13 +349,85 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
},
/**
* Check if WebAuthn/Passkeys are supported in this browser
* Check if WebAuthn/Passkeys are supported in this browser.
*
* Browser-only gate does NOT tell you whether the server has
* the passkey plugin wired up. Use `getPasskeyCapability()` for
* the full gate before rendering UI; this stays as a cheap
* pre-check that avoids an HTTP round-trip when WebAuthn isn't
* available at all (e.g. in a non-browser runtime).
*/
isPasskeyAvailable(): boolean {
if (typeof window === 'undefined') return false;
return !!window.PublicKeyCredential;
},
/**
* Probe both browser + server support for passkeys.
*
* Cached for the lifetime of the service instance so the login
* page can call this on mount + the settings page can call it
* when the security tab opens without re-hitting the server.
*
* The cache deliberately doesn't persist across page reloads
* a fresh tab means a fresh probe. Server capability can change
* after a deploy and we want that to take effect without the
* user having to clear their storage.
*/
async getPasskeyCapability(): Promise<PasskeyCapability> {
if (passkeyCapabilityCache) return passkeyCapabilityCache;
if (passkeyCapabilityInFlight) return passkeyCapabilityInFlight;
passkeyCapabilityInFlight = (async (): Promise<PasskeyCapability> => {
const browser = typeof window !== 'undefined' && !!window.PublicKeyCredential;
let conditionalUI = false;
if (browser) {
const PKC = window.PublicKeyCredential as unknown as {
isConditionalMediationAvailable?: () => Promise<boolean>;
};
if (typeof PKC.isConditionalMediationAvailable === 'function') {
try {
conditionalUI = await PKC.isConditionalMediationAvailable();
} catch {
conditionalUI = false;
}
}
}
let server = false;
let rpId: string | null = null;
try {
const res = await fetch(`${baseUrl}${endpoints.passkeyCapability}`);
if (res.ok) {
const data = (await res.json()) as { enabled?: boolean; rpId?: string | null };
server = !!data.enabled;
rpId = data.rpId ?? null;
}
} catch {
// Network error → server disabled from the client's POV.
// Don't log — the probe runs on every app boot and a flaky
// network shouldn't spam the error tracker.
}
const capability: PasskeyCapability = {
browser,
conditionalUI,
server,
available: browser && server,
rpId,
};
passkeyCapabilityCache = capability;
return capability;
})();
try {
return await passkeyCapabilityInFlight;
} finally {
passkeyCapabilityInFlight = null;
}
},
/**
* Register a new passkey for the current user
*/
@ -456,7 +507,15 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
if (!optionsRes.ok) {
const err = await optionsRes.json().catch(() => ({}));
return { success: false, error: err.message || 'Failed to get authentication options' };
// PASSKEY_NOT_ENABLED is a feature gate, not an error. The
// login page may call this on mount for conditional UI; we
// must not log anything or the error tracker fires on
// every visitor. The caller branches on `code`.
return {
success: false,
error: err.message || 'Failed to get authentication options',
code: err.error as AuthErrorCode | undefined,
};
}
const { options: webauthnOptions, challengeId } = await optionsRes.json();
@ -1054,34 +1113,72 @@ export function createAuthService(config: AuthServiceConfig): AuthServiceInterfa
},
/**
* Handle authentication errors
* Handle authentication errors from the mana-auth wrapper.
*
* As of the auth-errors refactor, the server returns a structured
* envelope:
*
* { error: AuthErrorCode, message: string, status: number,
* retryAfterSec?: number }
*
* `error` is a stable machine-readable code the UI switches on
* (and translates). `message` is a localised, user-safe fallback
* for codes the UI doesn't recognise yet (forward-compat).
*
* The legacy string-matching heuristics below are kept for
* resilience against a) handlers that haven't been refactored
* yet and b) Better Auth native endpoints the client still calls
* directly (passkey options, /api/auth/* fallbacks).
*/
handleAuthError(status: number, errorData: Record<string, unknown>): AuthResult {
if (status === 401) {
const isFirebaseUserNeedsReset =
String(errorData.message).includes('Firebase user detected') ||
String(errorData.message).includes('password reset required') ||
errorData.code === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED';
// New envelope: trust `error` as the canonical code.
const envelopeCode = typeof errorData.error === 'string' ? errorData.error : undefined;
const envelopeMessage = typeof errorData.message === 'string' ? errorData.message : undefined;
const retryAfterSec =
typeof errorData.retryAfterSec === 'number' ? errorData.retryAfterSec : undefined;
if (isFirebaseUserNeedsReset) {
if (envelopeCode && /^[A-Z_]+$/.test(envelopeCode)) {
return {
success: false,
error: envelopeCode,
code: envelopeCode as AuthResult['code'],
retryAfter: retryAfterSec,
};
}
// Legacy paths: infer from status + message heuristics.
if (status === 401) {
const message = String(errorData.message || '');
const code = errorData.code;
if (
message.includes('Firebase user detected') ||
message.includes('password reset required') ||
code === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED'
) {
return { success: false, error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED' };
}
const isEmailNotConfirmed =
String(errorData.message).includes('Email not confirmed') ||
String(errorData.message).includes('Email not verified') ||
errorData.code === 'EMAIL_NOT_VERIFIED';
if (isEmailNotConfirmed) {
return { success: false, error: 'EMAIL_NOT_VERIFIED' };
if (
message.includes('Email not confirmed') ||
message.includes('Email not verified') ||
code === 'EMAIL_NOT_VERIFIED'
) {
return { success: false, error: 'EMAIL_NOT_VERIFIED', code: 'EMAIL_NOT_VERIFIED' };
}
return { success: false, error: 'INVALID_CREDENTIALS' };
} else if (status === 403) {
return { success: false, error: 'EMAIL_NOT_VERIFIED' };
return {
success: false,
error: 'INVALID_CREDENTIALS',
code: 'INVALID_CREDENTIALS',
};
}
return { success: false, error: String(errorData.message) || 'Authentication failed' };
if (status === 403) {
return { success: false, error: 'EMAIL_NOT_VERIFIED', code: 'EMAIL_NOT_VERIFIED' };
}
return { success: false, error: envelopeMessage || 'Authentication failed' };
},
/**

View file

@ -77,11 +77,50 @@ export interface UserData {
}
/**
* Authentication result from sign in/up
* Machine-readable auth error codes. Mirrors `AuthErrorCode` in
* `services/mana-auth/src/lib/auth-errors.ts`. Kept as a string union
* (not enum) so the server and client type can evolve independently
* without a cross-package dependency.
*
* When the server adds a new code, add it here too otherwise TS
* exhaustiveness checks in the UI still pass but the client can't
* surface a tailored message.
*/
export type AuthErrorCode =
| 'INVALID_CREDENTIALS'
| 'EMAIL_NOT_VERIFIED'
| 'EMAIL_ALREADY_REGISTERED'
| 'WEAK_PASSWORD'
| 'ACCOUNT_LOCKED'
| 'SIGNUP_LIMIT_REACHED'
| 'RATE_LIMITED'
| 'TOKEN_EXPIRED'
| 'TOKEN_INVALID'
| 'TWO_FACTOR_REQUIRED'
| 'TWO_FACTOR_FAILED'
| 'PASSKEY_NOT_ENABLED'
| 'PASSKEY_CANCELLED'
| 'PASSKEY_VERIFICATION_FAILED'
| 'VALIDATION'
| 'UNAUTHORIZED'
| 'NOT_FOUND'
| 'SERVICE_UNAVAILABLE'
| 'INTERNAL';
/**
* Authentication result from sign in/up.
*
* `error` is a human-readable message (from the server's localised
* message field) that the UI can show as-is. `code` is the stable,
* switchable identifier UIs that want per-code i18n should branch on
* it and render their own translation, falling back to `error` when
* the code is unknown (forward-compat with future codes).
*/
export interface AuthResult {
success: boolean;
error?: string;
/** Machine-readable error code; absent on success. */
code?: AuthErrorCode;
needsVerification?: boolean;
twoFactorRedirect?: boolean;
retryAfter?: number;
@ -164,6 +203,25 @@ export interface AuthEndpoints {
passkeyAuthOptions: string;
passkeyAuthVerify: string;
passkeyList: string;
passkeyCapability: string;
}
/**
* Passkey capability combines browser WebAuthn support with the
* server's "is the passkey plugin enabled" gate. The UI should only
* render passkey affordances when `available` is true.
*/
export interface PasskeyCapability {
/** `window.PublicKeyCredential` is present */
browser: boolean;
/** `PublicKeyCredential.isConditionalMediationAvailable()` resolved true */
conditionalUI: boolean;
/** The server's /capability endpoint returned `enabled: true` */
server: boolean;
/** Alias for `browser && server` — the common gate for UI rendering. */
available: boolean;
/** The WebAuthn Relying Party ID the server uses. Null when not enabled. */
rpId: string | null;
}
/**
@ -232,6 +290,8 @@ export interface AuthServiceInterface {
// Passkeys
isPasskeyAvailable(): boolean;
/** Full browser + server capability probe. Memoised per-service. */
getPasskeyCapability(): Promise<PasskeyCapability>;
registerPasskey(friendlyName?: string): Promise<AuthResult>;
signInWithPasskey(options?: { conditional?: boolean }): Promise<AuthResult>;
listPasskeys(): Promise<any[]>;