From 2b22cc3a47b06a56ffeef393bde61c2aeee863fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carsten=20F=C3=BChrmann?= Date: Sun, 15 Feb 2026 21:57:38 +0100 Subject: [PATCH 1/2] Rename AuthError to ServerAuthError and add BrowserAuthError - Rename AuthError to ServerAuthError in @repo/fido2-auth to clarify it's server-side - Add BrowserAuthError in web demo for client-side WebAuthn errors - Add toBrowserAuthError() utility to convert browser errors - Update all UI error handling to use the new error types - Standardize error message to 'Cancelled or timed out' across all pages --- .../src/app/(app)/profile/page.tsx | 17 +++++++---- .../src/app/(auth)/login/page.tsx | 13 +++++---- .../src/app/(auth)/register/page.tsx | 20 +++++++------ apps/fido2-web-demo/src/lib/errors.ts | 28 +++++++++++++++++++ .../src/server/trpc/routers/auth.ts | 11 +++++--- .../src/server/trpc/routers/profile.ts | 10 +++---- packages/fido2-auth/src/errors.ts | 8 +++--- packages/fido2-auth/src/index.ts | 2 +- packages/fido2-auth/src/services.ts | 22 +++++++-------- 9 files changed, 86 insertions(+), 45 deletions(-) diff --git a/apps/fido2-web-demo/src/app/(app)/profile/page.tsx b/apps/fido2-web-demo/src/app/(app)/profile/page.tsx index 050874e..f4039da 100644 --- a/apps/fido2-web-demo/src/app/(app)/profile/page.tsx +++ b/apps/fido2-web-demo/src/app/(app)/profile/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { startRegistration } from "@simplewebauthn/browser"; import { trpc } from "@/lib/trpc"; +import { toBrowserAuthError } from "@/lib/errors"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -76,13 +77,19 @@ export default function ProfilePage() { }); await addPasskeyFinish.mutateAsync({ authenticatorResponse }); } catch (err) { - if (err instanceof Error) { - if (err.name === "NotAllowedError") { - setError("Passkey creation was cancelled"); - } else { - setError(err.message); + const browserAuthError = toBrowserAuthError(err); + if (browserAuthError) { + switch (browserAuthError.code) { + case "CANCELLED_OR_DENIED": + setError("Cancelled or timed out"); + break; + case "ALREADY_REGISTERED": + setError("This passkey is already registered"); + break; } + return; } + setError(err instanceof Error ? err.message : "Failed to add passkey"); } finally { setIsAddingPasskey(false); } diff --git a/apps/fido2-web-demo/src/app/(auth)/login/page.tsx b/apps/fido2-web-demo/src/app/(auth)/login/page.tsx index 480b5f3..39048e4 100644 --- a/apps/fido2-web-demo/src/app/(auth)/login/page.tsx +++ b/apps/fido2-web-demo/src/app/(auth)/login/page.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { startAuthentication } from "@simplewebauthn/browser"; import { trpc } from "@/lib/trpc"; -import { getErrorMessage } from "@/lib/errors"; +import { getErrorMessage, toBrowserAuthError } from "@/lib/errors"; import { Button } from "@/components/ui/button"; import { Card, @@ -90,12 +90,13 @@ function LoginForm() { // Success - redirect to profile router.push("/profile"); } catch (err) { - // Handle WebAuthn errors specially - if (err instanceof Error && err.name === "NotAllowedError") { - setError("Authentication was cancelled or timed out"); - } else { - setError(getErrorMessage(err)); + const browserAuthError = toBrowserAuthError(err); + if (browserAuthError) { + // Login only sees CANCELLED_OR_DENIED (no ALREADY_REGISTERED possible) + setError("Cancelled or timed out"); + return; } + setError(getErrorMessage(err)); } finally { setIsLoading(false); } diff --git a/apps/fido2-web-demo/src/app/(auth)/register/page.tsx b/apps/fido2-web-demo/src/app/(auth)/register/page.tsx index d7d512f..d90204f 100644 --- a/apps/fido2-web-demo/src/app/(auth)/register/page.tsx +++ b/apps/fido2-web-demo/src/app/(auth)/register/page.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { startRegistration } from "@simplewebauthn/browser"; import { trpc } from "@/lib/trpc"; -import { getErrorMessage } from "@/lib/errors"; +import { getErrorMessage, toBrowserAuthError } from "@/lib/errors"; import { usernameSchema } from "@repo/fido2-auth"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -132,15 +132,17 @@ function RegisterForm() { // Success - redirect to profile router.push("/profile"); } catch (err) { - // Handle WebAuthn errors specially - if (err instanceof Error) { - if (err.name === "NotAllowedError") { - setError("Passkey creation was cancelled or timed out"); - return; - } else if (err.name === "InvalidStateError") { - setError("This passkey is already registered"); - return; + const browserAuthError = toBrowserAuthError(err); + if (browserAuthError) { + switch (browserAuthError.code) { + case "CANCELLED_OR_DENIED": + setError("Cancelled or timed out"); + break; + case "ALREADY_REGISTERED": + setError("This passkey is already registered"); + break; } + return; } setError(getErrorMessage(err)); } finally { diff --git a/apps/fido2-web-demo/src/lib/errors.ts b/apps/fido2-web-demo/src/lib/errors.ts index cd385b6..4184457 100644 --- a/apps/fido2-web-demo/src/lib/errors.ts +++ b/apps/fido2-web-demo/src/lib/errors.ts @@ -1,5 +1,33 @@ import { TRPCClientError } from "@trpc/client"; +// Browser-side authentication errors (from WebAuthn API) +export type BrowserAuthErrorCode = + | "CANCELLED_OR_DENIED" // NotAllowedError - user cancelled, timed out, or blocked + | "ALREADY_REGISTERED"; // InvalidStateError - credential already exists + +export class BrowserAuthError extends Error { + constructor( + public readonly code: BrowserAuthErrorCode, + message: string + ) { + super(message); + this.name = "BrowserAuthError"; + } +} + +// Convert browser WebAuthn errors to BrowserAuthError +export function toBrowserAuthError(err: unknown): BrowserAuthError | null { + if (err instanceof Error) { + if (err.name === "NotAllowedError") { + return new BrowserAuthError("CANCELLED_OR_DENIED", err.message); + } + if (err.name === "InvalidStateError") { + return new BrowserAuthError("ALREADY_REGISTERED", err.message); + } + } + return null; +} + export function getErrorMessage(err: unknown): string { if (err instanceof TRPCClientError) { // Check for Zod validation errors (properly formatted by server) diff --git a/apps/fido2-web-demo/src/server/trpc/routers/auth.ts b/apps/fido2-web-demo/src/server/trpc/routers/auth.ts index 125eed9..9b6280e 100644 --- a/apps/fido2-web-demo/src/server/trpc/routers/auth.ts +++ b/apps/fido2-web-demo/src/server/trpc/routers/auth.ts @@ -10,7 +10,7 @@ import { getAndClearChallengeUsernameless, } from "@/server/auth/session"; import { auth } from "@/server/auth"; -import { AuthError, usernameSchema } from "@repo/fido2-auth"; +import { ServerAuthError, usernameSchema } from "@repo/fido2-auth"; export const authRouter = router({ // Get current session @@ -37,7 +37,10 @@ export const authRouter = router({ return { options }; } catch (error) { - if (error instanceof AuthError && error.code === "USERNAME_TAKEN") { + if ( + error instanceof ServerAuthError && + error.code === "USERNAME_TAKEN" + ) { throw new TRPCError({ code: "CONFLICT", message: error.message }); } throw error; @@ -71,7 +74,7 @@ export const authRouter = router({ await createSession(userId, username); return { success: true }; } catch (error) { - if (error instanceof AuthError) { + if (error instanceof ServerAuthError) { throw new TRPCError({ code: error.code === "USERNAME_TAKEN" ? "CONFLICT" : "BAD_REQUEST", message: error.message, @@ -115,7 +118,7 @@ export const authRouter = router({ await createSession(userId, username); return { success: true }; } catch (error) { - if (error instanceof AuthError) { + if (error instanceof ServerAuthError) { throw new TRPCError({ code: "UNAUTHORIZED", message: error.message, diff --git a/apps/fido2-web-demo/src/server/trpc/routers/profile.ts b/apps/fido2-web-demo/src/server/trpc/routers/profile.ts index f37d2c3..e690c4f 100644 --- a/apps/fido2-web-demo/src/server/trpc/routers/profile.ts +++ b/apps/fido2-web-demo/src/server/trpc/routers/profile.ts @@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server"; import { router, protectedProcedure } from "../trpc"; import { storeChallenge, getAndClearChallenge } from "@/server/auth/session"; import { auth } from "@/server/auth"; -import { AuthError } from "@repo/fido2-auth"; +import { ServerAuthError } from "@repo/fido2-auth"; export const profileRouter = router({ // Get current user's profile @@ -11,7 +11,7 @@ export const profileRouter = router({ try { return await auth.getUser(ctx.user.userId); } catch (error) { - if (error instanceof AuthError && error.code === "USER_NOT_FOUND") { + if (error instanceof ServerAuthError && error.code === "USER_NOT_FOUND") { throw new TRPCError({ code: "NOT_FOUND", message: error.message }); } throw error; @@ -98,7 +98,7 @@ export const profileRouter = router({ input.authenticatorResponse ); } catch (error) { - if (error instanceof AuthError) { + if (error instanceof ServerAuthError) { throw new TRPCError({ code: "BAD_REQUEST", message: error.message, @@ -117,7 +117,7 @@ export const profileRouter = router({ try { await auth.removeCredential(ctx.user.userId, input.credentialId); } catch (error) { - if (error instanceof AuthError) { + if (error instanceof ServerAuthError) { throw new TRPCError({ code: "BAD_REQUEST", message: error.message, @@ -145,7 +145,7 @@ export const profileRouter = router({ input.name ); } catch (error) { - if (error instanceof AuthError) { + if (error instanceof ServerAuthError) { throw new TRPCError({ code: "BAD_REQUEST", message: error.message, diff --git a/packages/fido2-auth/src/errors.ts b/packages/fido2-auth/src/errors.ts index c18446c..943770d 100644 --- a/packages/fido2-auth/src/errors.ts +++ b/packages/fido2-auth/src/errors.ts @@ -1,4 +1,4 @@ -export type AuthErrorCode = +export type ServerAuthErrorCode = | "USERNAME_TAKEN" | "USER_NOT_FOUND" | "CREDENTIAL_NOT_FOUND" @@ -7,12 +7,12 @@ export type AuthErrorCode = | "REGISTRATION_FAILED" | "AUTHENTICATION_FAILED"; -export class AuthError extends Error { +export class ServerAuthError extends Error { constructor( - public readonly code: AuthErrorCode, + public readonly code: ServerAuthErrorCode, message: string ) { super(message); - this.name = "AuthError"; + this.name = "ServerAuthError"; } } diff --git a/packages/fido2-auth/src/index.ts b/packages/fido2-auth/src/index.ts index aa0ee29..efa9da4 100644 --- a/packages/fido2-auth/src/index.ts +++ b/packages/fido2-auth/src/index.ts @@ -1,4 +1,4 @@ // Client-safe exports (no Node.js dependencies) export { usernameSchema } from "./validation"; -export { AuthError, type AuthErrorCode } from "./errors"; +export { ServerAuthError, type ServerAuthErrorCode } from "./errors"; export type { WebAuthnConfig } from "./fido2"; diff --git a/packages/fido2-auth/src/services.ts b/packages/fido2-auth/src/services.ts index 32dc0aa..5cea891 100644 --- a/packages/fido2-auth/src/services.ts +++ b/packages/fido2-auth/src/services.ts @@ -16,7 +16,7 @@ import { deleteCredential as fido2DeleteCredential, type WebAuthnConfig, } from "./fido2"; -import { AuthError } from "./errors"; +import { ServerAuthError } from "./errors"; type AuthDatabase = BetterSQLite3Database; @@ -35,7 +35,7 @@ export async function registerStart( .get(); if (existing) { - throw new AuthError("USERNAME_TAKEN", "Username is already taken"); + throw new ServerAuthError("USERNAME_TAKEN", "Username is already taken"); } const userId = randomUUID(); @@ -65,7 +65,7 @@ export async function registerFinish( .get(); if (existing) { - throw new AuthError("USERNAME_TAKEN", "Username is already taken"); + throw new ServerAuthError("USERNAME_TAKEN", "Username is already taken"); } // Create the user FIRST (before storing credential due to FK constraint) @@ -89,7 +89,7 @@ export async function registerFinish( } catch (error) { // Rollback: delete the user if credential storage fails await db.delete(schema.users).where(eq(schema.users.id, userId)); - throw new AuthError( + throw new ServerAuthError( "REGISTRATION_FAILED", error instanceof Error ? error.message @@ -122,7 +122,7 @@ export async function loginFinish( ); return { userId: result.userId, username: result.username }; } catch (error) { - throw new AuthError( + throw new ServerAuthError( "AUTHENTICATION_FAILED", error instanceof Error ? error.message @@ -146,7 +146,7 @@ export async function getUser(db: AuthDatabase, userId: string) { .get(); if (!user) { - throw new AuthError("USER_NOT_FOUND", "User not found"); + throw new ServerAuthError("USER_NOT_FOUND", "User not found"); } return user; @@ -203,7 +203,7 @@ export async function addPasskeyFinish( authenticatorResponse as RegistrationResponseJSON ); } catch (error) { - throw new AuthError( + throw new ServerAuthError( "REGISTRATION_FAILED", error instanceof Error ? error.message : "Failed to add passkey" ); @@ -218,8 +218,8 @@ export async function removeCredential( try { await fido2DeleteCredential(db, userId, credentialId); } catch (error) { - if (error instanceof AuthError) throw error; - throw new AuthError( + if (error instanceof ServerAuthError) throw error; + throw new ServerAuthError( "CREDENTIAL_NOT_FOUND", error instanceof Error ? error.message : "Failed to delete credential" ); @@ -235,8 +235,8 @@ export async function renameCredential( try { await fido2RenameCredential(db, userId, credentialId, newName); } catch (error) { - if (error instanceof AuthError) throw error; - throw new AuthError( + if (error instanceof ServerAuthError) throw error; + throw new ServerAuthError( "CREDENTIAL_NOT_FOUND", error instanceof Error ? error.message : "Failed to rename credential" ); From 77928e6a0ebfed5324f576d34d32b709bf4b479b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carsten=20F=C3=BChrmann?= Date: Sun, 15 Feb 2026 22:05:29 +0100 Subject: [PATCH 2/2] Simplify: remove BrowserAuthError class, use getBrowserAuthErrorCode() Replace the BrowserAuthError class with a simpler function that just returns the error code directly. The class added indirection without providing additional value for the current usage pattern. --- .../src/app/(app)/profile/page.tsx | 8 +++---- .../src/app/(auth)/login/page.tsx | 6 ++--- .../src/app/(auth)/register/page.tsx | 8 +++---- apps/fido2-web-demo/src/lib/errors.ts | 24 +++++-------------- 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/apps/fido2-web-demo/src/app/(app)/profile/page.tsx b/apps/fido2-web-demo/src/app/(app)/profile/page.tsx index f4039da..e1eb262 100644 --- a/apps/fido2-web-demo/src/app/(app)/profile/page.tsx +++ b/apps/fido2-web-demo/src/app/(app)/profile/page.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { startRegistration } from "@simplewebauthn/browser"; import { trpc } from "@/lib/trpc"; -import { toBrowserAuthError } from "@/lib/errors"; +import { getBrowserAuthErrorCode } from "@/lib/errors"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -77,9 +77,9 @@ export default function ProfilePage() { }); await addPasskeyFinish.mutateAsync({ authenticatorResponse }); } catch (err) { - const browserAuthError = toBrowserAuthError(err); - if (browserAuthError) { - switch (browserAuthError.code) { + const errorCode = getBrowserAuthErrorCode(err); + if (errorCode) { + switch (errorCode) { case "CANCELLED_OR_DENIED": setError("Cancelled or timed out"); break; diff --git a/apps/fido2-web-demo/src/app/(auth)/login/page.tsx b/apps/fido2-web-demo/src/app/(auth)/login/page.tsx index 39048e4..2c00662 100644 --- a/apps/fido2-web-demo/src/app/(auth)/login/page.tsx +++ b/apps/fido2-web-demo/src/app/(auth)/login/page.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { startAuthentication } from "@simplewebauthn/browser"; import { trpc } from "@/lib/trpc"; -import { getErrorMessage, toBrowserAuthError } from "@/lib/errors"; +import { getErrorMessage, getBrowserAuthErrorCode } from "@/lib/errors"; import { Button } from "@/components/ui/button"; import { Card, @@ -90,8 +90,8 @@ function LoginForm() { // Success - redirect to profile router.push("/profile"); } catch (err) { - const browserAuthError = toBrowserAuthError(err); - if (browserAuthError) { + const errorCode = getBrowserAuthErrorCode(err); + if (errorCode) { // Login only sees CANCELLED_OR_DENIED (no ALREADY_REGISTERED possible) setError("Cancelled or timed out"); return; diff --git a/apps/fido2-web-demo/src/app/(auth)/register/page.tsx b/apps/fido2-web-demo/src/app/(auth)/register/page.tsx index d90204f..a1e7e68 100644 --- a/apps/fido2-web-demo/src/app/(auth)/register/page.tsx +++ b/apps/fido2-web-demo/src/app/(auth)/register/page.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { startRegistration } from "@simplewebauthn/browser"; import { trpc } from "@/lib/trpc"; -import { getErrorMessage, toBrowserAuthError } from "@/lib/errors"; +import { getErrorMessage, getBrowserAuthErrorCode } from "@/lib/errors"; import { usernameSchema } from "@repo/fido2-auth"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -132,9 +132,9 @@ function RegisterForm() { // Success - redirect to profile router.push("/profile"); } catch (err) { - const browserAuthError = toBrowserAuthError(err); - if (browserAuthError) { - switch (browserAuthError.code) { + const errorCode = getBrowserAuthErrorCode(err); + if (errorCode) { + switch (errorCode) { case "CANCELLED_OR_DENIED": setError("Cancelled or timed out"); break; diff --git a/apps/fido2-web-demo/src/lib/errors.ts b/apps/fido2-web-demo/src/lib/errors.ts index 4184457..89de69b 100644 --- a/apps/fido2-web-demo/src/lib/errors.ts +++ b/apps/fido2-web-demo/src/lib/errors.ts @@ -5,25 +5,13 @@ export type BrowserAuthErrorCode = | "CANCELLED_OR_DENIED" // NotAllowedError - user cancelled, timed out, or blocked | "ALREADY_REGISTERED"; // InvalidStateError - credential already exists -export class BrowserAuthError extends Error { - constructor( - public readonly code: BrowserAuthErrorCode, - message: string - ) { - super(message); - this.name = "BrowserAuthError"; - } -} - -// Convert browser WebAuthn errors to BrowserAuthError -export function toBrowserAuthError(err: unknown): BrowserAuthError | null { +// Convert browser WebAuthn errors to a typed error code +export function getBrowserAuthErrorCode( + err: unknown +): BrowserAuthErrorCode | null { if (err instanceof Error) { - if (err.name === "NotAllowedError") { - return new BrowserAuthError("CANCELLED_OR_DENIED", err.message); - } - if (err.name === "InvalidStateError") { - return new BrowserAuthError("ALREADY_REGISTERED", err.message); - } + if (err.name === "NotAllowedError") return "CANCELLED_OR_DENIED"; + if (err.name === "InvalidStateError") return "ALREADY_REGISTERED"; } return null; }