2f66fff993
Build & Push Docker Image / build (push) Failing after 1m18s
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
446 lines
14 KiB
TypeScript
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;
|