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:
cheffe01
2026-05-25 13:20:15 +00:00
parent e03c51a75e
commit 7abb048edc
22 changed files with 1027 additions and 68 deletions
+138
View File
@@ -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;