Implement Journal auth: passkeys + magic links, no passwords

Auth flows:
- Passkey registration: email + username → WebAuthn create → account + session
- Passkey login: WebAuthn get → session (instant, no form)
- Magic link fallback: email → token → verify link → session
- Logout via POST /auth/logout

Infrastructure:
- auth.server.ts: WebAuthn (SimpleWebAuthn), magic tokens, cookie sessions
- DB schema: credentials (WebAuthn), magic_tokens tables, no password_hash
- Session middleware via getSessionUser()

Pages:
- /auth/register — email + username + passkey creation
- /auth/login — passkey button + magic link fallback
- /auth/verify — magic link token verification
- /users/:username — public profile page
- Home page shows auth state (register/signin or welcome)

Dynamic imports for @simplewebauthn/browser to avoid SSR issues.
Magic links logged to console in dev (email integration later).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-23 17:38:46 +01:00
parent 3604a2d066
commit b65b61cbc5
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
13 changed files with 1004 additions and 23 deletions

View file

@ -0,0 +1,234 @@
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";
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),
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: response.response.transports,
});
return userId;
}
// --- 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);
}

View file

@ -0,0 +1,10 @@
import { createDb, type Database } from "@trails-cool/db";
let _db: Database | null = null;
export function getDb(): Database {
if (!_db) {
_db = createDb();
}
return _db;
}

View file

@ -1,3 +1,10 @@
import { type RouteConfig, index } from "@react-router/dev/routes";
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig;
export default [
index("routes/home.tsx"),
route("auth/register", "routes/auth.register.tsx"),
route("auth/login", "routes/auth.login.tsx"),
route("auth/verify", "routes/auth.verify.tsx"),
route("auth/logout", "routes/auth.logout.tsx"),
route("users/:username", "routes/users.$username.tsx"),
] satisfies RouteConfig;

View file

@ -0,0 +1,200 @@
import { useState } from "react";
import { data, redirect } from "react-router";
import type { Route } from "./+types/auth.login";
import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.json();
const { step, response, challenge, email } = formData;
try {
if (step === "start-passkey") {
const options = await startAuthentication();
return data({ step: "challenge", options });
}
if (step === "finish-passkey") {
const userId = await finishAuthentication(response, challenge);
const cookie = await createSession(userId, request);
return redirect("/", { headers: { "Set-Cookie": cookie } });
}
if (step === "magic-link") {
const token = await createMagicToken(email);
// In production, send email. For dev, log the link.
const link = `${process.env.ORIGIN ?? "http://localhost:3000"}/auth/verify?token=${token}`;
console.log(`[Magic Link] ${email}: ${link}`);
return data({ step: "magic-link-sent" });
}
return data({ error: "Invalid step" }, { status: 400 });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}
}
export default function LoginPage() {
const [mode, setMode] = useState<"passkey" | "magic-link">("passkey");
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [magicLinkSent, setMagicLinkSent] = useState(false);
const handlePasskeyLogin = async () => {
setError(null);
setLoading(true);
try {
const startResp = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "start-passkey" }),
});
const startData = await startResp.json();
if (startData.error) {
setError(startData.error);
setLoading(false);
return;
}
const { startAuthentication: startWebAuthn } = await import("@simplewebauthn/browser");
const webAuthnResp = await startWebAuthn(startData.options);
const finishResp = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
step: "finish-passkey",
response: webAuthnResp,
challenge: startData.options.challenge,
}),
});
if (finishResp.redirected) {
window.location.href = finishResp.url;
return;
}
const finishData = await finishResp.json();
if (finishData.error) setError(finishData.error);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const resp = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "magic-link", email }),
});
const result = await resp.json();
if (result.error) {
setError(result.error);
} else {
setMagicLinkSent(true);
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
return (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">Sign In</h1>
{mode === "passkey" && (
<div className="mt-8">
<button
onClick={handlePasskeyLogin}
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Authenticating..." : "Sign in with Passkey"}
</button>
<div className="mt-6 text-center">
<button
onClick={() => setMode("magic-link")}
className="text-sm text-gray-500 hover:text-gray-700"
>
No passkey on this device? Use a magic link instead
</button>
</div>
</div>
)}
{mode === "magic-link" && !magicLinkSent && (
<form onSubmit={handleMagicLink} className="mt-8 space-y-4">
<p className="text-sm text-gray-600">
We'll send a login link to your email.
</p>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-gray-800 px-4 py-2 text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? "Sending..." : "Send Magic Link"}
</button>
<div className="text-center">
<button
type="button"
onClick={() => setMode("passkey")}
className="text-sm text-gray-500 hover:text-gray-700"
>
Back to passkey login
</button>
</div>
</form>
)}
{magicLinkSent && (
<div className="mt-8 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
Check your email! We sent a login link to <strong>{email}</strong>.
</p>
<p className="mt-2 text-xs text-green-600">
The link expires in 15 minutes.
</p>
</div>
)}
{error && (
<p className="mt-4 text-sm text-red-600">{error}</p>
)}
<p className="mt-6 text-center text-sm text-gray-500">
Don't have an account?{" "}
<a href="/auth/register" className="text-blue-600 hover:underline">
Register
</a>
</p>
</div>
);
}

View file

@ -0,0 +1,8 @@
import { redirect } from "react-router";
import type { Route } from "./+types/auth.logout";
import { destroySession } from "~/lib/auth.server";
export async function action({ request }: Route.ActionArgs) {
const cookie = await destroySession(request);
return redirect("/auth/login", { headers: { "Set-Cookie": cookie } });
}

View file

@ -0,0 +1,150 @@
import { useState } from "react";
import { data, redirect } from "react-router";
import type { Route } from "./+types/auth.register";
import { startRegistration, finishRegistration, createSession } from "~/lib/auth.server";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.json();
const { step, email, username, response, challenge, userId } = formData;
try {
if (step === "start") {
const { options, userId } = await startRegistration(email, username);
return data({ step: "challenge", options, userId });
}
if (step === "finish") {
const newUserId = await finishRegistration(userId, email, username, response, challenge);
const cookie = await createSession(newUserId, request);
return redirect("/", { headers: { "Set-Cookie": cookie } });
}
return data({ error: "Invalid step" }, { status: 400 });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}
}
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
// Step 1: Get registration options from server
const startResp = await fetch("/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "start", email, username }),
});
const startData = await startResp.json();
if (startData.error) {
setError(startData.error);
setLoading(false);
return;
}
// Step 2: Create passkey via browser
const { startRegistration: startWebAuthn } = await import("@simplewebauthn/browser");
const webAuthnResp = await startWebAuthn(startData.options);
// Step 3: Send response to server
const finishResp = await fetch("/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
step: "finish",
email,
username,
response: webAuthnResp,
challenge: startData.options.challenge,
userId: startData.userId,
}),
});
if (finishResp.redirected) {
window.location.href = finishResp.url;
return;
}
const finishData = await finishResp.json();
if (finishData.error) {
setError(finishData.error);
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
return (
<div className="mx-auto max-w-md px-4 py-16">
<h1 className="text-2xl font-bold text-gray-900">Create Account</h1>
<p className="mt-2 text-sm text-gray-600">
Register with a passkey no password needed.
</p>
<form onSubmit={handleRegister} className="mt-8 space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Username
</label>
<input
id="username"
type="text"
required
pattern="[a-z0-9_-]+"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="alice"
/>
<p className="mt-1 text-xs text-gray-500">
Your handle will be @{username || "..."}@{typeof window !== "undefined" ? window.location.hostname : "trails.cool"}
</p>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Creating passkey..." : "Register with Passkey"}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-500">
Already have an account?{" "}
<a href="/auth/login" className="text-blue-600 hover:underline">
Sign in
</a>
</p>
</div>
);
}

View file

@ -0,0 +1,32 @@
import { redirect, data } from "react-router";
import type { Route } from "./+types/auth.verify";
import { verifyMagicToken, createSession } from "~/lib/auth.server";
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
if (!token) {
return data({ error: "Missing token" }, { status: 400 });
}
try {
const userId = await verifyMagicToken(token);
const cookie = await createSession(userId, request);
// Redirect to home with prompt to add passkey
return redirect("/?add-passkey=1", { headers: { "Set-Cookie": cookie } });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}
}
export default function VerifyPage() {
return (
<div className="mx-auto max-w-md px-4 py-16 text-center">
<p className="text-red-600">Invalid or expired magic link.</p>
<a href="/auth/login" className="mt-4 inline-block text-blue-600 hover:underline">
Request a new one
</a>
</div>
);
}

View file

@ -1,4 +1,6 @@
import { data } from "react-router";
import type { Route } from "./+types/home";
import { getSessionUser } from "~/lib/auth.server";
export function meta(_args: Route.MetaArgs) {
return [
@ -7,11 +9,41 @@ export function meta(_args: Route.MetaArgs) {
];
}
export default function Home() {
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
return data({ user: user ? { username: user.username, displayName: user.displayName } : null });
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { user } = loaderData;
return (
<div className="mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold text-gray-900">trails.cool</h1>
<p className="mt-4 text-lg text-gray-600">Your outdoor activity journal</p>
{user ? (
<div className="mt-8">
<p className="text-gray-700">
Welcome, <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
</p>
</div>
) : (
<div className="mt-8 flex gap-4">
<a
href="/auth/register"
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
Register
</a>
<a
href="/auth/login"
className="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
>
Sign in
</a>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,75 @@
import { data } from "react-router";
import type { Route } from "./+types/users.$username";
import { getDb } from "~/lib/db";
import { users } from "@trails-cool/db/schema/journal";
import { eq } from "drizzle-orm";
import { getSessionUser } from "~/lib/auth.server";
export async function loader({ params, request }: Route.LoaderArgs) {
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.username, params.username));
if (!user) {
throw data({ error: "User not found" }, { status: 404 });
}
const currentUser = await getSessionUser(request);
const isOwn = currentUser?.id === user.id;
return data({
user: {
username: user.username,
displayName: user.displayName,
bio: user.bio,
domain: user.domain,
createdAt: user.createdAt.toISOString(),
},
isOwn,
});
}
export function meta({ data: loaderData }: Route.MetaArgs) {
const username = (loaderData as { user: { username: string } })?.user?.username ?? "User";
return [{ title: `@${username} — trails.cool` }];
}
export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
const { user, isOwn } = loaderData;
return (
<div className="mx-auto max-w-2xl px-4 py-8">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 text-2xl font-bold text-blue-600">
{user.username[0]?.toUpperCase()}
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{user.displayName ?? user.username}
</h1>
<p className="text-sm text-gray-500">
@{user.username}@{user.domain}
</p>
{user.bio && <p className="mt-2 text-gray-700">{user.bio}</p>}
</div>
</div>
{isOwn && (
<div className="mt-6 flex gap-2">
<form action="/auth/logout" method="post">
<button
type="submit"
className="rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"
>
Log out
</button>
</form>
</div>
)}
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900">Routes</h2>
<p className="mt-2 text-sm text-gray-500">No routes yet.</p>
</div>
</div>
);
}

View file

@ -11,22 +11,25 @@
"lint": "eslint ."
},
"dependencies": {
"@trails-cool/db": "workspace:*",
"@trails-cool/ui": "workspace:*",
"@trails-cool/types": "workspace:*",
"@trails-cool/map": "workspace:*",
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
"drizzle-orm": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:",
"@react-router/node": "catalog:",
"@react-router/serve": "catalog:",
"isbot": "^5.1.0"
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.0",
"@trails-cool/db": "workspace:*",
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
"@trails-cool/map": "workspace:*",
"@trails-cool/types": "workspace:*",
"@trails-cool/ui": "workspace:*",
"drizzle-orm": "catalog:",
"isbot": "^5.1.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:"
},
"devDependencies": {
"@react-router/dev": "catalog:",
"@simplewebauthn/types": "^12.0.0",
"@tailwindcss/vite": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",

View file

@ -1,9 +1,15 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import path from "node:path";
export default defineConfig({
plugins: [tailwindcss(), reactRouter()],
resolve: {
alias: {
"~": path.resolve(__dirname, "./app"),
},
},
server: {
port: 3000,
},