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 { 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): 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 => { 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((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 => { 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 => { 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((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 => { 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 => { 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 => { 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 => { 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((resolve, reject) => { req.session.regenerate((err) => (err ? reject(err) : resolve())); }); res.sendStatus(204); }); type SessionUser = NonNullable; 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 => { 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 => { 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(); 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 => { 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;