Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions apps/fido2-web-demo/src/app/(app)/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { startRegistration } from "@simplewebauthn/browser";
import { trpc } from "@/lib/trpc";
import { getBrowserAuthErrorCode } from "@/lib/errors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand Down Expand Up @@ -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 errorCode = getBrowserAuthErrorCode(err);
if (errorCode) {
switch (errorCode) {
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);
}
Expand Down
13 changes: 7 additions & 6 deletions apps/fido2-web-demo/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, getBrowserAuthErrorCode } from "@/lib/errors";
import { Button } from "@/components/ui/button";
import {
Card,
Expand Down Expand Up @@ -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 errorCode = getBrowserAuthErrorCode(err);
if (errorCode) {
// Login only sees CANCELLED_OR_DENIED (no ALREADY_REGISTERED possible)
setError("Cancelled or timed out");
return;
}
setError(getErrorMessage(err));
} finally {
setIsLoading(false);
}
Expand Down
20 changes: 11 additions & 9 deletions apps/fido2-web-demo/src/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, getBrowserAuthErrorCode } from "@/lib/errors";
import { usernameSchema } from "@repo/fido2-auth";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand Down Expand Up @@ -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 errorCode = getBrowserAuthErrorCode(err);
if (errorCode) {
switch (errorCode) {
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 {
Expand Down
16 changes: 16 additions & 0 deletions apps/fido2-web-demo/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
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

// 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 "CANCELLED_OR_DENIED";
if (err.name === "InvalidStateError") return "ALREADY_REGISTERED";
}
return null;
}

export function getErrorMessage(err: unknown): string {
if (err instanceof TRPCClientError) {
// Check for Zod validation errors (properly formatted by server)
Expand Down
11 changes: 7 additions & 4 deletions apps/fido2-web-demo/src/server/trpc/routers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions apps/fido2-web-demo/src/server/trpc/routers/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ 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
get: protectedProcedure.query(async ({ ctx }) => {
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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions packages/fido2-auth/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AuthErrorCode =
export type ServerAuthErrorCode =
| "USERNAME_TAKEN"
| "USER_NOT_FOUND"
| "CREDENTIAL_NOT_FOUND"
Expand All @@ -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";
}
}
2 changes: 1 addition & 1 deletion packages/fido2-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
22 changes: 11 additions & 11 deletions packages/fido2-auth/src/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
deleteCredential as fido2DeleteCredential,
type WebAuthnConfig,
} from "./fido2";
import { AuthError } from "./errors";
import { ServerAuthError } from "./errors";

type AuthDatabase = BetterSQLite3Database<typeof schema>;

Expand All @@ -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();
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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"
);
Expand All @@ -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"
);
Expand All @@ -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"
);
Expand Down