trails/apps/journal/app/lib/auth.server.ts
Ullrich Schäfer d95d142122
Fix passkey credential ID encoding and improve error handling
Bug: @simplewebauthn v13 changed credential.id from Uint8Array to
base64url string. Buffer.from(string) without encoding stored the
ASCII text instead of decoded bytes. Authentication then failed to
match because it correctly decoded with "base64url".

Fix: Buffer.from(credential.id, "base64url") in both registration
and add-passkey flows.

Also: catch WebAuthn "not allowed" errors and show a friendly message
instead of the raw browser error.

Existing passkeys must be re-registered after deploy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:18:14 +01:00

288 lines
7.5 KiB
TypeScript

import { randomUUID, randomBytes } from "node:crypto";
import { eq, and, gt, isNull } from "drizzle-orm";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
} from "@simplewebauthn/types";
import { getDb } from "./db.ts";
import { users, credentials, magicTokens } from "@trails-cool/db/schema/journal";
const RP_NAME = "trails.cool";
const RP_ID = process.env.DOMAIN ?? "localhost";
const ORIGIN = process.env.ORIGIN ?? `http://localhost:3000`;
// --- Registration ---
export async function startRegistration(email: string, username: string) {
const db = getDb();
// Check for existing email/username
const [existingEmail] = await db.select().from(users).where(eq(users.email, email));
if (existingEmail) throw new Error("Email already in use");
const [existingUsername] = await db.select().from(users).where(eq(users.username, username));
if (existingUsername) throw new Error("Username already taken");
const userId = randomUUID();
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: new TextEncoder().encode(userId),
userName: username,
userDisplayName: username,
attestationType: "none",
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
return { options, userId };
}
export async function finishRegistration(
userId: string,
email: string,
username: string,
response: RegistrationResponseJSON,
challenge: string,
) {
const db = getDb();
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Registration verification failed");
}
const { credential } = verification.registrationInfo;
const domain = process.env.DOMAIN ?? "localhost";
// Create user and credential in a transaction
await db.insert(users).values({
id: userId,
email,
username,
domain,
});
await db.insert(credentials).values({
id: randomUUID(),
userId,
credentialId: Buffer.from(credential.id, "base64url"),
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: response.response.transports,
});
return userId;
}
// --- Add Passkey to Existing Account ---
export async function addPasskeyStart(userId: string) {
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.id, userId));
if (!user) throw new Error("User not found");
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: new TextEncoder().encode(userId),
userName: user.username,
userDisplayName: user.displayName ?? user.username,
attestationType: "none",
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
return options;
}
export async function addPasskeyFinish(
userId: string,
response: RegistrationResponseJSON,
challenge: string,
) {
const db = getDb();
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Passkey verification failed");
}
const { credential } = verification.registrationInfo;
await db.insert(credentials).values({
id: randomUUID(),
userId,
credentialId: Buffer.from(credential.id, "base64url"),
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: response.response.transports,
});
}
// --- Passkey Login ---
export async function startAuthentication() {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
userVerification: "preferred",
});
return options;
}
export async function finishAuthentication(
response: AuthenticationResponseJSON,
challenge: string,
) {
const db = getDb();
const credentialIdBuffer = Buffer.from(response.rawId, "base64url");
// Find the credential
const [cred] = await db
.select()
.from(credentials)
.where(eq(credentials.credentialId, credentialIdBuffer));
if (!cred) throw new Error("Credential not found");
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: Buffer.from(cred.credentialId).toString("base64url"),
publicKey: new Uint8Array(cred.publicKey),
counter: cred.counter,
transports: cred.transports as AuthenticatorTransportFuture[] | undefined,
},
});
if (!verification.verified) {
throw new Error("Authentication verification failed");
}
// Update counter
await db
.update(credentials)
.set({ counter: verification.authenticationInfo.newCounter })
.where(eq(credentials.id, cred.id));
return cred.userId;
}
// --- Magic Links ---
export async function createMagicToken(email: string): Promise<string> {
const db = getDb();
// Check user exists
const [user] = await db.select().from(users).where(eq(users.email, email));
if (!user) throw new Error("No account found for this email");
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await db.insert(magicTokens).values({
id: randomUUID(),
email,
token,
expiresAt,
});
return token;
}
export async function verifyMagicToken(token: string): Promise<string> {
const db = getDb();
const [record] = await db
.select()
.from(magicTokens)
.where(
and(
eq(magicTokens.token, token),
gt(magicTokens.expiresAt, new Date()),
isNull(magicTokens.usedAt),
),
);
if (!record) throw new Error("Invalid or expired magic link");
// Mark as used
await db
.update(magicTokens)
.set({ usedAt: new Date() })
.where(eq(magicTokens.id, record.id));
// Find user
const [user] = await db.select().from(users).where(eq(users.email, record.email));
if (!user) throw new Error("User not found");
return user.id;
}
// --- Sessions ---
import { createCookieSessionStorage } from "react-router";
const sessionSecret = process.env.SESSION_SECRET ?? "dev-secret-change-in-production";
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30, // 30 days
secrets: [sessionSecret],
},
});
export async function createSession(userId: string, request: Request) {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
session.set("userId", userId);
return sessionStorage.commitSession(session);
}
export async function getSessionUser(request: Request) {
const db = getDb();
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) return null;
const [user] = await db.select().from(users).where(eq(users.id, userId));
return user ?? null;
}
export async function destroySession(request: Request) {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
return sessionStorage.destroySession(session);
}