Fix auth: separate API routes, verify registration with real DB

- Move auth logic from page actions to dedicated API routes
  (/api/auth/register, /api/auth/login) — React Router page
  actions don't handle JSON fetch() properly
- Remove server imports from page components
- Verify registration start works end-to-end with PostgreSQL:
  WebAuthn challenge generated, user ID created, RP set to localhost

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

View file

@ -6,5 +6,7 @@ export default [
route("auth/login", "routes/auth.login.tsx"),
route("auth/verify", "routes/auth.verify.tsx"),
route("auth/logout", "routes/auth.logout.tsx"),
route("api/auth/register", "routes/api.auth.register.ts"),
route("api/auth/login", "routes/api.auth.login.ts"),
route("users/:username", "routes/users.$username.tsx"),
] satisfies RouteConfig;

View file

@ -0,0 +1,32 @@
import { data } from "react-router";
import type { Route } from "./+types/api.auth.login";
import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server";
export async function action({ request }: Route.ActionArgs) {
const body = await request.json();
const { step, response, challenge, email } = body;
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 data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
}
if (step === "magic-link") {
const token = await createMagicToken(email);
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 });
}
}

View file

@ -0,0 +1,25 @@
import { data } from "react-router";
import type { Route } from "./+types/api.auth.register";
import { startRegistration, finishRegistration, createSession } from "~/lib/auth.server";
export async function action({ request }: Route.ActionArgs) {
const body = await request.json();
const { step, email, username, response, challenge, userId } = body;
try {
if (step === "start") {
const result = await startRegistration(email, username);
return data({ step: "challenge", options: result.options, userId: result.userId });
}
if (step === "finish") {
const newUserId = await finishRegistration(userId, email, username, response, challenge);
const cookie = await createSession(newUserId, request);
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
}
return data({ error: "Invalid step" }, { status: 400 });
} catch (e) {
return data({ error: (e as Error).message }, { status: 400 });
}
}

View file

@ -1,37 +1,4 @@
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");
@ -45,7 +12,7 @@ export default function LoginPage() {
setLoading(true);
try {
const startResp = await fetch("/auth/login", {
const startResp = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "start-passkey" }),
@ -61,7 +28,7 @@ export default function LoginPage() {
const { startAuthentication: startWebAuthn } = await import("@simplewebauthn/browser");
const webAuthnResp = await startWebAuthn(startData.options);
const finishResp = await fetch("/auth/login", {
const finishResp = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@ -71,13 +38,12 @@ export default function LoginPage() {
}),
});
if (finishResp.redirected) {
window.location.href = finishResp.url;
return;
}
const finishData = await finishResp.json();
if (finishData.error) setError(finishData.error);
if (finishData.error) {
setError(finishData.error);
} else if (finishData.step === "done") {
window.location.href = "/";
}
} catch (err) {
setError((err as Error).message);
} finally {
@ -91,7 +57,7 @@ export default function LoginPage() {
setLoading(true);
try {
const resp = await fetch("/auth/login", {
const resp = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "magic-link", email }),

View file

@ -1,29 +1,4 @@
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("");
@ -38,7 +13,7 @@ export default function RegisterPage() {
try {
// Step 1: Get registration options from server
const startResp = await fetch("/auth/register", {
const startResp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "start", email, username }),
@ -56,7 +31,7 @@ export default function RegisterPage() {
const webAuthnResp = await startWebAuthn(startData.options);
// Step 3: Send response to server
const finishResp = await fetch("/auth/register", {
const finishResp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@ -69,14 +44,11 @@ export default function RegisterPage() {
}),
});
if (finishResp.redirected) {
window.location.href = finishResp.url;
return;
}
const finishData = await finishResp.json();
if (finishData.error) {
setError(finishData.error);
} else if (finishData.step === "done") {
window.location.href = "/";
}
} catch (err) {
setError((err as Error).message);