Add user authentication and dynamic feature/category inputs
Implement Keycloak authentication, protected routes, and add combobox and autocomplete components for tool categories and features. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 0b145113-c016-4f54-b000-13bd3b0ba8f0 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/z4uWN6A Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -12,17 +12,22 @@
|
||||
"dependencies": {
|
||||
"@workspace/api-zod": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"drizzle-orm": "catalog:",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"openid-client": "^5.7.1",
|
||||
"pino": "^9.14.0",
|
||||
"pino-http": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/node": "catalog:",
|
||||
"esbuild": "0.27.3",
|
||||
"esbuild-plugin-pino": "^2.3.3",
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import express, { type Express } from "express";
|
||||
import cors from "cors";
|
||||
import pinoHttp from "pino-http";
|
||||
import session from "express-session";
|
||||
import ConnectPgSimple from "connect-pg-simple";
|
||||
import router from "./routes";
|
||||
import { logger } from "./lib/logger";
|
||||
import "./types/session.d.ts";
|
||||
|
||||
const PgStore = ConnectPgSimple(session);
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
@@ -25,10 +32,30 @@ app.use(
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.use(cors());
|
||||
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
app.use(
|
||||
session({
|
||||
store: new PgStore({
|
||||
conString: process.env.DATABASE_URL,
|
||||
tableName: "sessions",
|
||||
createTableIfMissing: true,
|
||||
}),
|
||||
secret: process.env.SESSION_SECRET || "dev-secret-change-in-production",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.use("/api", router);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type Request, type Response, type NextFunction } from "express";
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Router, type IRouter, type Request } from "express";
|
||||
import { Issuer, generators, type Client } from "openid-client";
|
||||
import { logger } from "../lib/logger";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
let cachedClient: Client | null = null;
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
async function getClient(): Promise<Client | null> {
|
||||
if (cachedClient) return cachedClient;
|
||||
|
||||
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 = `${keycloakUrl}/realms/${realm}`;
|
||||
const issuer = await Issuer.discover(issuerUrl);
|
||||
cachedClient = new issuer.Client({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
response_types: ["code"],
|
||||
});
|
||||
return cachedClient;
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to discover Keycloak issuer");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/auth/login", async (req, res): Promise<void> => {
|
||||
const client = await getClient();
|
||||
if (!client) {
|
||||
res.status(503).json({ error: "Keycloak is not configured. Set KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET." });
|
||||
return;
|
||||
}
|
||||
|
||||
const codeVerifier = generators.codeVerifier();
|
||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||
|
||||
req.session.codeVerifier = codeVerifier;
|
||||
if (req.query.returnTo && typeof req.query.returnTo === "string") {
|
||||
req.session.returnTo = req.query.returnTo;
|
||||
}
|
||||
|
||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||
const url = client.authorizationUrl({
|
||||
scope: "openid email profile",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
res.redirect(url);
|
||||
});
|
||||
|
||||
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 redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||
|
||||
try {
|
||||
const params = client.callbackParams(req);
|
||||
const tokenSet = await client.callback(redirectUri, params, {
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
|
||||
const userinfo = await client.userinfo(tokenSet.access_token!);
|
||||
|
||||
req.session.user = {
|
||||
sub: userinfo.sub,
|
||||
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,
|
||||
};
|
||||
delete req.session.codeVerifier;
|
||||
|
||||
const returnTo = req.session.returnTo || "/";
|
||||
delete req.session.returnTo;
|
||||
|
||||
res.redirect(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> => {
|
||||
const user = req.session.user;
|
||||
req.session.destroy(() => {});
|
||||
|
||||
const client = await getClient();
|
||||
if (client && client.issuer.metadata.end_session_endpoint) {
|
||||
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) });
|
||||
res.redirect(logoutUrl);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -3,9 +3,11 @@ import healthRouter from "./health";
|
||||
import toolsRouter from "./tools";
|
||||
import ratingsRouter from "./ratings";
|
||||
import analyticsRouter from "./analytics";
|
||||
import authRouter from "./auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authRouter);
|
||||
router.use(healthRouter);
|
||||
router.use(toolsRouter);
|
||||
router.use(ratingsRouter);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CreateRatingParams,
|
||||
CreateRatingBody,
|
||||
} from "@workspace/api-zod";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -31,7 +32,7 @@ router.get("/tools/:id/ratings", async (req, res): Promise<void> => {
|
||||
res.json(ratings);
|
||||
});
|
||||
|
||||
router.post("/tools/:id/ratings", async (req, res): Promise<void> => {
|
||||
router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = CreateRatingParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { eq, ilike, desc, sql, avg, count } from "drizzle-orm";
|
||||
import { eq, ilike, desc, sql } from "drizzle-orm";
|
||||
import { db, toolsTable, ratingsTable } from "@workspace/db";
|
||||
import {
|
||||
ListToolsQueryParams,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UpdateToolBody,
|
||||
DeleteToolParams,
|
||||
} from "@workspace/api-zod";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -71,7 +72,7 @@ router.get("/tools", async (req, res): Promise<void> => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post("/tools", async (req, res): Promise<void> => {
|
||||
router.post("/tools", requireAuth, async (req, res): Promise<void> => {
|
||||
const parsed = CreateToolBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
@@ -111,7 +112,7 @@ router.get("/tools/:id", async (req, res): Promise<void> => {
|
||||
res.json(buildToolWithStats(tool, ratings));
|
||||
});
|
||||
|
||||
router.patch("/tools/:id", async (req, res): Promise<void> => {
|
||||
router.patch("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = UpdateToolParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
@@ -146,7 +147,7 @@ router.patch("/tools/:id", async (req, res): Promise<void> => {
|
||||
res.json(tool);
|
||||
});
|
||||
|
||||
router.delete("/tools/:id", async (req, res): Promise<void> => {
|
||||
router.delete("/tools/:id", requireAuth, async (req, res): Promise<void> => {
|
||||
const params = DeleteToolParams.safeParse(req.params);
|
||||
if (!params.success) {
|
||||
res.status(400).json({ error: params.error.message });
|
||||
@@ -170,4 +171,15 @@ router.get("/categories", async (_req, res): Promise<void> => {
|
||||
res.json(rows.map((r) => r.category));
|
||||
});
|
||||
|
||||
router.get("/features/all", async (_req, res): Promise<void> => {
|
||||
const tools = await db.select({ features: toolsTable.features }).from(toolsTable);
|
||||
const featureSet = new Set<string>();
|
||||
for (const t of tools) {
|
||||
for (const f of t.features ?? []) {
|
||||
if (f && f.trim()) featureSet.add(f.trim());
|
||||
}
|
||||
}
|
||||
res.json([...featureSet].sort());
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
user?: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
};
|
||||
codeVerifier?: string;
|
||||
returnTo?: string;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user