Files
tool-evaluator/artifacts/api-server/src/routes/auth.ts
T
opencode 2f66fff993
Build & Push Docker Image / build (push) Failing after 1m18s
chore(deps): upgrade all dependencies to latest and pin exact
Policy: every direct dependency is now an exact pin; lockfile + --frozen-lockfile keep builds reproducible; save-exact=true enforced.

Toolchain:
- pnpm 10.26 -> 11.18 (lockfile migrated; supportedArchitectures moved to pnpm-workspace.yaml)
- typescript 5.9 -> 7.0, vite 7 -> 8, @vitejs/plugin-react 5 -> 6
- esbuild override 0.27.3 -> 0.28.1 (closes GHSA-g7r4-m6w7-qqqr); added @esbuild/darwin-arm64 for local dev
- orval 8.9 -> 8.23, regenerated clients

Backend (prod image):
- openid-client 5.7 -> 6.8 (rewritten OIDC flow in routes/auth.ts: discovery + functional API, PKCE/state, fetchUserInfo, buildEndSessionUrl)
- pino 9 -> 10, pino-http 10 -> 11, thread-stream 3 -> 4
- zod 3.25 -> 4.4 (catalog; supported by drizzle-zod 0.8.3)
- pg 8.20 -> 8.22; removed deprecated @types/bcryptjs

Frontend:
- react/react-dom 19.1.0 -> 19.2.8 (catalog pin lifted; expo note removed)
- react-day-picker 9 -> 10 (table classname -> month_grid)
- recharts 2 -> 3 (TooltipContentProps + DefaultLegendContentProps typing; safe keys)
- react-resizable-panels 2 -> 4 (Group/Separator rename)
- date-fns 3 -> 4, @hookform/resolvers 3 -> 5, lucide-react 0.545 -> 1.28
- all @radix-ui/*, tailwind, types, and remaining patch/minor deps bumped to latest

Security/process:
- overrides for body-parser >=2.3.0 (GHSA-v422-hmwv-36x6) + markdown-it/linkify-it/brace-expansion/fast-uri (dev tooling)
- pnpm audit now reports 0 vulnerabilities (prod and full)
- CI audit gate added to build.yaml; docs/dependency-policy.md; renovate.json
- Dockerfile pins node:24.18.1-alpine and pnpm@11.18.0
2026-08-03 12:56:00 +02:00

446 lines
14 KiB
TypeScript

import { Router, type IRouter, type Request } from "express";
import {
discovery,
randomPKCECodeVerifier,
calculatePKCECodeChallenge,
randomState,
buildAuthorizationUrl,
authorizationCodeGrant,
fetchUserInfo,
buildEndSessionUrl,
skipSubjectCheck,
type Configuration,
} from "openid-client";
import bcrypt from "bcryptjs";
import { eq, and, inArray, isNull } from "drizzle-orm";
import { z } from "zod";
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
import { logger } from "../lib/logger";
import { writeAuditLog } from "../lib/audit";
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
import { getEntitlements, requireFeature } from "../middleware/feature";
import { getCsrfToken } from "../middleware/csrf";
const router: IRouter = Router();
let cachedConfig: Configuration | null = null;
function isOidcConfigured(): boolean {
return !!(
process.env.KEYCLOAK_URL &&
process.env.KEYCLOAK_REALM &&
process.env.KEYCLOAK_CLIENT_ID &&
process.env.KEYCLOAK_CLIENT_SECRET
);
}
function getBaseUrl(req: Request): string {
if (process.env.APP_URL) return process.env.APP_URL;
const host = req.get("x-forwarded-host") || req.get("host") || "localhost";
const proto = req.get("x-forwarded-proto") || "https";
return `${proto}://${host}`;
}
function isSafeReturnTo(value: string): boolean {
if (!value.startsWith("/") || value.startsWith("//")) return false;
try {
return new URL(value, "http://localhost").origin === "http://localhost";
} catch {
return false;
}
}
async function getClient(): Promise<Configuration | null> {
if (cachedConfig) return cachedConfig;
const keycloakUrl = process.env.KEYCLOAK_URL;
const realm = process.env.KEYCLOAK_REALM;
const clientId = process.env.KEYCLOAK_CLIENT_ID;
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
if (!keycloakUrl || !realm || !clientId || !clientSecret) {
return null;
}
try {
const issuerUrl = new URL(`${keycloakUrl}/realms/${realm}`);
cachedConfig = await discovery(issuerUrl, clientId, clientSecret);
return cachedConfig;
} catch (err) {
logger.error({ err }, "Failed to discover Keycloak issuer");
return null;
}
}
async function upsertUserFromOidc(userinfo: Record<string, unknown>): Promise<{ id: number; tier: string; role: string }> {
const sub = String(userinfo.sub ?? "");
if (!sub) throw new Error("Missing sub claim");
const email = typeof userinfo.email === "string" ? userinfo.email : undefined;
const preferredUsername = typeof userinfo.preferred_username === "string" ? userinfo.preferred_username : undefined;
const name = typeof userinfo.name === "string" ? userinfo.name : preferredUsername;
const username = preferredUsername ?? email ?? `oidc-${sub.slice(0, 8)}`;
const [existing] = await db
.select({ id: usersTable.id, tier: usersTable.tier, role: usersTable.role })
.from(usersTable)
.where(eq(usersTable.authProviderId, sub))
.limit(1);
if (existing) {
await db
.update(usersTable)
.set({ email, displayName: name })
.where(eq(usersTable.id, existing.id));
return existing;
}
const [created] = await db
.insert(usersTable)
.values({
username,
email,
displayName: name,
role: "user",
tier: "free",
authProvider: "oidc",
authProviderId: sub,
})
.returning({ id: usersTable.id, tier: usersTable.tier, role: usersTable.role });
logger.info({ username, sub }, "OIDC user created");
return created;
}
router.get("/auth/mode", (_req, res): void => {
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
});
router.get("/auth/csrf", (req, res): void => {
res.json({ token: getCsrfToken(req) });
});
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
if (isOidcConfigured()) {
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
return;
}
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ error: "username and password are required" });
return;
}
const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.username, String(username)))
.limit(1);
if (!user || !user.passwordHash) {
res.status(401).json({ error: "Invalid username or password" });
return;
}
const valid = await bcrypt.compare(String(password), user.passwordHash);
if (!valid) {
res.status(401).json({ error: "Invalid username or password" });
return;
}
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
req.session.user = {
sub: String(user.id),
name: user.username,
preferred_username: user.username,
email: user.email ?? undefined,
role: (user.role as "admin" | "user") ?? "user",
tier: (user.tier as "free" | "premium" | "enterprise") ?? "free",
isLocal: true,
};
res.json({
sub: String(user.id),
email: user.email ?? null,
name: user.username,
preferredUsername: user.username,
role: user.role,
tier: user.tier,
entitlements: getEntitlements(user.tier, user.role),
isLocal: true,
});
});
router.get("/auth/login", async (req, res): Promise<void> => {
const client = await getClient();
if (!client) {
res.status(503).json({ error: "Keycloak is not configured." });
return;
}
const codeVerifier = randomPKCECodeVerifier();
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const state = randomState();
req.session.codeVerifier = codeVerifier;
req.session.oidcState = state;
if (req.query.returnTo && typeof req.query.returnTo === "string" && isSafeReturnTo(req.query.returnTo)) {
req.session.returnTo = req.query.returnTo;
}
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
const url = buildAuthorizationUrl(client, {
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
redirect_uri: redirectUri,
state,
});
res.redirect(url.href);
});
router.get("/auth/callback", async (req, res): Promise<void> => {
const client = await getClient();
if (!client) {
res.status(503).json({ error: "Keycloak is not configured." });
return;
}
const codeVerifier = req.session.codeVerifier;
if (!codeVerifier) {
res.status(400).json({ error: "Invalid session state." });
return;
}
const state = typeof req.query.state === "string" ? req.query.state : "";
if (!state || state !== req.session.oidcState) {
res.status(400).json({ error: "Invalid OAuth state." });
return;
}
delete req.session.oidcState;
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
try {
const tokens = await authorizationCodeGrant(
client,
new URL(req.originalUrl ?? "/", getBaseUrl(req)),
{ pkceCodeVerifier: codeVerifier, expectedState: state },
);
const userinfo = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
const dbUser = await upsertUserFromOidc(userinfo);
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
req.session.user = {
sub: dbUser.id.toString(),
email: typeof userinfo.email === "string" ? userinfo.email : undefined,
name: typeof userinfo.name === "string" ? userinfo.name : undefined,
preferred_username: typeof userinfo.preferred_username === "string" ? userinfo.preferred_username : undefined,
role: (dbUser.role as "admin" | "user") ?? "user",
tier: (dbUser.tier as "free" | "premium" | "enterprise") ?? "free",
isLocal: false,
};
delete req.session.codeVerifier;
const returnTo = req.session.returnTo ?? "/";
delete req.session.returnTo;
res.redirect(isSafeReturnTo(returnTo) ? returnTo : "/");
} catch (err) {
logger.error({ err }, "Keycloak callback failed");
res.status(500).json({ error: "Authentication failed." });
}
});
router.get("/auth/logout", async (req, res): Promise<void> => {
req.session.destroy(() => {});
const client = await getClient();
if (client && client.serverMetadata().end_session_endpoint) {
const logoutUrl = buildEndSessionUrl(client, { post_logout_redirect_uri: getBaseUrl(req) });
res.redirect(logoutUrl.href);
return;
}
res.redirect("/");
});
router.get("/auth/me", async (req, res): Promise<void> => {
if (!req.session.user) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const u = req.session.user;
res.json({
sub: u.sub,
email: u.email ?? null,
name: u.name ?? null,
preferredUsername: u.preferred_username ?? null,
role: u.role ?? "user",
tier: u.tier ?? "free",
entitlements: getEntitlements(u.tier, u.role),
isLocal: u.isLocal ?? false,
});
});
router.get("/auth/password-redirect", async (req, res): Promise<void> => {
const client = await getClient();
if (!client) {
res.json({ url: null });
return;
}
const realm = client.serverMetadata().issuer ?? "";
res.json({ url: `${realm}/account/password` });
});
const ChangePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6),
});
router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise<void> => {
if (!req.session.user) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const dbUser = await resolveDbUser(req.session.user);
if (!dbUser) {
res.status(401).json({ error: "User not found" });
return;
}
if (dbUser.authProvider !== "local") {
res.status(422).json({ error: "oidc" });
return;
}
const parsed = ChangePasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { currentPassword, newPassword } = parsed.data;
if (currentPassword === newPassword) {
res.status(400).json({ error: "New password must differ from current password" });
return;
}
if (!dbUser.passwordHash || !(await bcrypt.compare(currentPassword, dbUser.passwordHash))) {
res.status(400).json({ error: "Current password is incorrect" });
return;
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, dbUser.id));
await writeAuditLog(req, "user", dbUser.id, "change_password", {});
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
res.sendStatus(204);
});
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
async function resolveDbUser(u: SessionUser) {
if (u.isLocal) {
const id = Number(u.sub);
if (Number.isFinite(id)) {
const [byId] = await db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
if (byId) return byId;
}
if (u.preferred_username) {
const [byName] = await db.select().from(usersTable).where(eq(usersTable.username, u.preferred_username)).limit(1);
if (byName) return byName;
}
return null;
}
const [byProvider] = await db.select().from(usersTable).where(eq(usersTable.authProviderId, u.sub)).limit(1);
return byProvider ?? null;
}
const PreferenceSchema = z.object({
view: z.enum(["grid", "table", "rows"]).optional(),
density: z.enum(["cozy", "compact"]).optional(),
watchlist: z.array(z.number().int().positive()).max(50).optional(),
});
router.get("/auth/me/preferences", async (req, res): Promise<void> => {
if (!req.session.user) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const dbUser = await resolveDbUser(req.session.user);
if (!dbUser) {
res.status(401).json({ error: "User not found" });
return;
}
res.json(dbUser.preferences ?? {});
});
router.get("/auth/me/watchlist", requireFeature("watchlist"), async (req, res): Promise<void> => {
const dbUser = await resolveDbUser(req.session.user!);
if (!dbUser) {
res.status(401).json({ error: "User not found" });
return;
}
const ids = (dbUser.preferences?.watchlist ?? []).filter((id) => Number.isInteger(id));
if (ids.length === 0) {
res.json([]);
return;
}
const tools = await db
.select()
.from(toolsTable)
.where(and(isNull(toolsTable.deletedAt), inArray(toolsTable.id, ids)));
const ratings = await db
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability })
.from(ratingsTable)
.where(inArray(ratingsTable.toolId, tools.map((t) => t.id)));
const byTool = new Map<number, { usefulness: number; usability: number }[]>();
for (const r of ratings) {
const list = byTool.get(r.toolId) ?? [];
list.push({ usefulness: r.usefulness, usability: r.usability });
byTool.set(r.toolId, list);
}
const byId = new Map(tools.map((t) => [t.id, t]));
const ordered = ids
.filter((id) => byId.has(id))
.map((id) => {
const tool = byId.get(id)!;
const toolRatings = byTool.get(id) ?? [];
const ratingCount = toolRatings.length;
const avgUsefulness = ratingCount > 0 ? toolRatings.reduce((s, r) => s + r.usefulness, 0) / ratingCount : null;
const avgUsability = ratingCount > 0 ? toolRatings.reduce((s, r) => s + r.usability, 0) / ratingCount : null;
const avgCombined = avgUsefulness != null && avgUsability != null ? (avgUsefulness + avgUsability) / 2 : null;
return { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined };
});
res.json(ordered);
});
router.put("/auth/me/preferences", async (req, res): Promise<void> => {
if (!req.session.user) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const parsed = PreferenceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const dbUser = await resolveDbUser(req.session.user);
if (!dbUser) {
res.status(401).json({ error: "User not found" });
return;
}
const merged = { ...(dbUser.preferences ?? {}), ...parsed.data };
await db.update(usersTable).set({ preferences: merged }).where(eq(usersTable.id, dbUser.id));
res.json(merged);
});
export default router;