Files
ProjectE/apps/api/src/routes/auth.ts
T
Hermes edec2d72d4 T11/Bug #1: fix login persistence — setCookie on /api/auth/credentials
Server now sets the session cookie on successful login. SPA at same
origin includes the cookie on every subsequent request, so the auth
middleware can verify and the user is no longer bounced back to /login.

Parent: t_e1cbd87d (T10 test report)
2026-08-01 03:57:53 +00:00

154 lines
5.0 KiB
TypeScript

import { Hono } from "hono";
import { setCookie } from "hono/cookie";
import bcrypt from "bcryptjs";
import { db, users } from "@project-e/db";
import { count, eq } from "drizzle-orm";
import { createToken, requireAuth, createErrorResponse, AuthError } from "../middleware/auth";
export const authRoutes = new Hono();
// POST /api/auth/credentials — Login with email + password
authRoutes.post("/credentials", async (c) => {
try {
const { email, password } = await c.req.json();
if (!email || !password) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Email and password required" } }, 400);
}
const normalizedEmail = email.trim().toLowerCase();
let [user] = await db.select().from(users).where(eq(users.email, normalizedEmail)).limit(1);
if (!user) {
// First-user auto-creation (same as legacy auth-config.ts)
const [{ total }] = await db.select({ total: count() }).from(users);
const initialEmail = process.env.INITIAL_ADMIN_EMAIL?.trim().toLowerCase();
const initialPassword = process.env.INITIAL_ADMIN_PASSWORD;
if (total === 0 && normalizedEmail === initialEmail && password === initialPassword) {
[user] = await db.insert(users).values({
email: normalizedEmail,
name: process.env.INITIAL_ADMIN_NAME || normalizedEmail,
passwordHash: await bcrypt.hash(password, 12),
}).returning();
}
}
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return c.json({ error: { code: "UNAUTHORIZED", message: "Invalid email or password" } }, 401);
}
const token = await createToken({ id: user.id, email: user.email, name: user.name });
setCookie(c, "session", token, {
httpOnly: true,
secure: false,
sameSite: "Lax",
path: "/",
maxAge: 30 * 24 * 60 * 60,
});
return c.json({
user: { id: user.id, email: user.email, name: user.name },
token,
});
} catch (error) {
console.error("[auth/credentials] error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Login failed" } }, 500);
}
});
// GET /api/auth/session — Return current session or null
authRoutes.get("/session", async (c) => {
try {
const user = c.get("user");
if (!user) {
return c.json({ authenticated: false, user: null });
}
return c.json({ authenticated: true, user });
} catch {
return c.json({ authenticated: false, user: null });
}
});
// GET /api/auth/me — Return current user profile
authRoutes.get("/me", async (c) => {
try {
const user = c.get("user");
if (!user) {
return c.json({ error: { code: "UNAUTHORIZED", message: "Not authenticated" } }, 401);
}
return c.json({ user });
} catch {
return c.json({ error: { code: "AUTH_ERROR", message: "Invalid or expired token" } }, 401);
}
});
// POST /api/auth/passkey/register — Register a passkey
authRoutes.post("/passkey/register", async (c) => {
try {
const user = c.get("user");
if (!user) {
return c.json({ error: { code: "UNAUTHORIZED", message: "Not authenticated" } }, 401);
}
const body = await c.req.json();
const { credentialId, publicKey, counter } = body;
if (!credentialId || !publicKey) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and publicKey are required" } }, 400);
}
await db
.update(users)
.set({
passkeyCredentialId: credentialId,
passkeyPublicKey: publicKey,
passkeyCounter: counter ?? 0,
})
.where(eq(users.id, user.id));
return c.json({ success: true });
} catch (error) {
console.error("[passkey/register] error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to register passkey" } }, 500);
}
});
// POST /api/auth/passkey/authenticate — Verify passkey login
authRoutes.post("/passkey/authenticate", async (c) => {
try {
const body = await c.req.json();
const { credentialId, signature, authenticatorData, clientDataJSON } = body;
if (!credentialId || !signature) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and signature are required" } }, 400);
}
const [user] = await db
.select()
.from(users)
.where(eq(users.passkeyCredentialId, credentialId))
.limit(1);
if (!user) {
return c.json({ error: { code: "UNAUTHORIZED", message: "Passkey not found" } }, 401);
}
const token = await createToken({ id: user.id, email: user.email, name: user.name });
setCookie(c, "session", token, {
httpOnly: true,
secure: false,
sameSite: "Lax",
path: "/",
maxAge: 30 * 24 * 60 * 60,
});
return c.json({
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
console.error("[passkey/authenticate] error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to verify passkey" } }, 500);
}
});