From b52fbb1bed6d1a7da27606c7b08707d3ff5365b9 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 29 Sep 2026 06:07:02 +0000 Subject: [PATCH] Sync Keycloak-protected UI --- src/auth.ts | 262 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/index.ts | 80 +++++++++++++--- 2 files changed, 313 insertions(+), 29 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 29143cf..954a133 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,13 +1,112 @@ import type { NextFunction, Request, Response } from "express"; -import { createRemoteJWKSet, jwtVerify } from "jose"; +import { createHash, randomBytes } from "node:crypto"; +import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose"; -let jwks: ReturnType | null = null; +let mcpJwks: ReturnType | null = null; +let webJwks: ReturnType | null = null; + +const SESSION_COOKIE = "media_session"; +const STATE_COOKIE = "media_oidc_state"; +const VERIFIER_COOKIE = "media_oidc_verifier"; +const NONCE_COOKIE = "media_oidc_nonce"; +const RETURN_COOKIE = "media_return_to"; + +function publicUrl(req: Request) { + return (process.env.PUBLIC_URL || `${req.protocol}://${req.get("host")}`).replace(/\/$/, ""); +} + +function base64url(value: Buffer) { + return value.toString("base64url"); +} + +function randomToken(bytes = 32) { + return base64url(randomBytes(bytes)); +} + +function readCookie(req: Request, name: string): string | null { + const raw = req.get("cookie") ?? ""; + for (const part of raw.split(";")) { + const [key, ...rest] = part.trim().split("="); + if (key === name) return decodeURIComponent(rest.join("=")); + } + return null; +} + +function cookie(name: string, value: string, maxAgeSeconds: number) { + return [ + `${name}=${encodeURIComponent(value)}`, + "Path=/", + "HttpOnly", + "Secure", + "SameSite=Lax", + `Max-Age=${maxAgeSeconds}` + ].join("; "); +} + +function clearCookie(name: string) { + return [ + `${name}=`, + "Path=/", + "HttpOnly", + "Secure", + "SameSite=Lax", + "Max-Age=0" + ].join("; "); +} + +function safeReturnTo(value: unknown) { + const text = String(value ?? "/"); + return text.startsWith("/") && !text.startsWith("//") ? text : "/"; +} + +function webConfig(req: Request) { + const issuer = process.env.WEB_OIDC_ISSUER || process.env.KEYCLOAK_ISSUER; + const clientId = process.env.WEB_OIDC_CLIENT_ID; + const clientSecret = process.env.WEB_OIDC_CLIENT_SECRET; + if (!issuer || !clientId || !clientSecret) { + throw new Error("Web OIDC authentication is not configured"); + } + return { + issuer: issuer.replace(/\/$/, ""), + clientId, + clientSecret, + redirectUri: `${publicUrl(req)}/auth/callback` + }; +} + +function sessionKey() { + const secret = process.env.WEB_OIDC_CLIENT_SECRET; + if (!secret) throw new Error("Web OIDC client secret is unavailable"); + return createHash("sha256") + .update(`media-intelligence-session:${secret}`) + .digest(); +} + +async function createSession(claims: Record) { + return new SignJWT(claims) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuer("media-intelligence-web") + .setAudience("media-intelligence-web") + .setIssuedAt() + .setExpirationTime("8h") + .sign(sessionKey()); +} + +async function verifySession(token: string) { + const { payload } = await jwtVerify(token, sessionKey(), { + issuer: "media-intelligence-web", + audience: "media-intelligence-web" + }); + return payload; +} export function protectedResourceMetadata(req: Request) { - const base = process.env.PUBLIC_URL || `${req.protocol}://${req.get("host")}`; + const base = publicUrl(req); return { - resource: `${base.replace(/\/$/, "")}/api/mcp`, - authorization_servers: [process.env.KEYCLOAK_ISSUER || "https://auth.arrowstack.dev/realms/dev"], + resource: `${base}/api/mcp`, + authorization_servers: [ + process.env.KEYCLOAK_ISSUER || "https://auth.arrowstack.dev/realms/dev" + ], bearer_methods_supported: ["header"], scopes_supported: ["openid", "profile", "email"] }; @@ -19,27 +118,160 @@ export async function requireMcpAuth(req: Request, res: Response, next: NextFunc const issuer = process.env.KEYCLOAK_ISSUER; const clientId = process.env.KEYCLOAK_CLIENT_ID; if (!issuer || !clientId) { - return res.status(503).json({ error: "MCP authentication is not configured" }); + return res.status(503).json({ error: "MCP OAuth is not configured" }); } const header = req.get("authorization") ?? ""; if (!header.startsWith("Bearer ")) { - res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${(process.env.PUBLIC_URL || `${req.protocol}://${req.get("host")}`).replace(/\/$/, "")}/.well-known/oauth-protected-resource/api/mcp"`); - return res.status(401).json({ error: "Bearer token required" }); + res.setHeader( + "WWW-Authenticate", + `Bearer resource_metadata="${publicUrl(req)}/.well-known/oauth-protected-resource/api/mcp"` + ); + return res.status(401).json({ error: "OAuth access token required" }); } try { - if (!jwks) { - jwks = createRemoteJWKSet(new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`)); + if (!mcpJwks) { + mcpJwks = createRemoteJWKSet( + new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`) + ); } - const { payload } = await jwtVerify(header.slice(7), jwks, { issuer }); + const { payload } = await jwtVerify(header.slice(7), mcpJwks, { issuer }); const aud = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []; if (payload.azp !== clientId && !aud.includes(clientId)) { - return res.status(403).json({ error: "Token was not issued for this MCP client" }); + return res.status(403).json({ error: "OAuth token was not issued for this MCP client" }); } (req as any).auth = payload; - next(); - } catch (error) { - return res.status(401).json({ error: "Invalid bearer token" }); + return next(); + } catch { + return res.status(401).json({ error: "Invalid OAuth access token" }); } } + +export async function beginWebLogin(req: Request, res: Response) { + try { + const cfg = webConfig(req); + const state = randomToken(); + const verifier = randomToken(48); + const nonce = randomToken(); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + const returnTo = safeReturnTo(req.query.returnTo); + + res.append("Set-Cookie", cookie(STATE_COOKIE, state, 600)); + res.append("Set-Cookie", cookie(VERIFIER_COOKIE, verifier, 600)); + res.append("Set-Cookie", cookie(NONCE_COOKIE, nonce, 600)); + res.append("Set-Cookie", cookie(RETURN_COOKIE, returnTo, 600)); + + const url = new URL(`${cfg.issuer}/protocol/openid-connect/auth`); + url.searchParams.set("client_id", cfg.clientId); + url.searchParams.set("redirect_uri", cfg.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", "openid profile email"); + url.searchParams.set("state", state); + url.searchParams.set("nonce", nonce); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + + return res.redirect(url.toString()); + } catch (error) { + return res.status(503).send(error instanceof Error ? error.message : String(error)); + } +} + +export async function finishWebLogin(req: Request, res: Response) { + try { + const cfg = webConfig(req); + const code = String(req.query.code ?? ""); + const state = String(req.query.state ?? ""); + const expectedState = readCookie(req, STATE_COOKIE); + const verifier = readCookie(req, VERIFIER_COOKIE); + const nonce = readCookie(req, NONCE_COOKIE); + const returnTo = safeReturnTo(readCookie(req, RETURN_COOKIE)); + + if (!code || !state || !expectedState || state !== expectedState || !verifier || !nonce) { + return res.status(400).send("Invalid or expired login state."); + } + + const tokenResponse = await fetch( + `${cfg.issuer}/protocol/openid-connect/token`, + { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + code, + redirect_uri: cfg.redirectUri, + code_verifier: verifier + }), + signal: AbortSignal.timeout(15000) + } + ); + + if (!tokenResponse.ok) { + const detail = (await tokenResponse.text()).slice(0, 500); + return res.status(401).send(`Login token exchange failed: ${detail}`); + } + + const tokens = await tokenResponse.json() as { + id_token?: string; + access_token?: string; + }; + + if (!tokens.id_token) { + return res.status(401).send("Keycloak did not return an ID token."); + } + + if (!webJwks) { + webJwks = createRemoteJWKSet( + new URL(`${cfg.issuer}/protocol/openid-connect/certs`) + ); + } + + const { payload } = await jwtVerify(tokens.id_token, webJwks, { + issuer: cfg.issuer, + audience: cfg.clientId + }); + + if (payload.nonce !== nonce) { + return res.status(401).send("OIDC nonce validation failed."); + } + + const session = await createSession({ + sub: payload.sub, + email: payload.email, + preferred_username: payload.preferred_username, + name: payload.name + }); + + res.append("Set-Cookie", cookie(SESSION_COOKIE, session, 8 * 60 * 60)); + for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) { + res.append("Set-Cookie", clearCookie(name)); + } + + return res.redirect(returnTo); + } catch (error) { + return res.status(401).send(error instanceof Error ? error.message : String(error)); + } +} + +export async function requireWebAuth(req: Request, res: Response, next: NextFunction) { + const token = readCookie(req, SESSION_COOKIE); + if (!token) { + return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl || "/")}`); + } + + try { + (req as any).user = await verifySession(token); + return next(); + } catch { + res.append("Set-Cookie", clearCookie(SESSION_COOKIE)); + return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl || "/")}`); + } +} + +export async function logoutWeb(_req: Request, res: Response) { + res.append("Set-Cookie", clearCookie(SESSION_COOKIE)); + return res.redirect("/"); +} diff --git a/src/index.ts b/src/index.ts index ba63447..89a95a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,13 @@ import express from "express"; import { migrate, pool, seedSources } from "./db.js"; -import { protectedResourceMetadata, requireMcpAuth } from "./auth.js"; +import { + beginWebLogin, + finishWebLogin, + logoutWeb, + protectedResourceMetadata, + requireMcpAuth, + requireWebAuth +} from "./auth.js"; import { handleMcp } from "./mcp.js"; import { SOURCES } from "./sources.js"; import { syncAll } from "./sync.js"; @@ -49,16 +56,33 @@ app.get("/ready", async (_req, res) => { } }); -app.get("/.well-known/oauth-protected-resource", (req, res) => res.json(protectedResourceMetadata(req))); -app.get("/.well-known/oauth-protected-resource/api/mcp", (req, res) => res.json(protectedResourceMetadata(req))); +app.get("/.well-known/oauth-protected-resource", (req, res) => + res.json(protectedResourceMetadata(req)) +); +app.get("/.well-known/oauth-protected-resource/api/mcp", (req, res) => + res.json(protectedResourceMetadata(req)) +); + +app.get("/auth/login", beginWebLogin); +app.get("/auth/callback", finishWebLogin); +app.get("/auth/logout", logoutWeb); app.post("/api/mcp", requireMcpAuth, handleMcp); app.get("/api/mcp", requireMcpAuth, (_req, res) => { - res.status(405).setHeader("Allow", "POST").json({ error: "Use POST for MCP JSON-RPC requests" }); + res.status(405).setHeader("Allow", "POST").json({ + error: "Use POST for MCP JSON-RPC requests" + }); }); -app.get("/", async (_req, res) => { - const sourceRows = await pool.query("SELECT id,name,last_synced_at,last_error FROM sources ORDER BY id"); +app.get("/", requireWebAuth, async (req, res) => { + const sourceRows = await pool.query( + `SELECT s.id,s.name,s.last_synced_at,s.last_error, + COUNT(sig.id)::int AS signal_count + FROM sources s + LEFT JOIN signals sig ON sig.source_id=s.id + GROUP BY s.id,s.name,s.last_synced_at,s.last_error + ORDER BY s.id` + ); const counts = await pool.query(` SELECT (SELECT COUNT(*) FROM signals) AS signals, @@ -66,26 +90,35 @@ app.get("/", async (_req, res) => { (SELECT COUNT(*) FROM opportunities WHERE status='new') AS new_opportunities `); const c = counts.rows[0] ?? {}; + const user = (req as any).user ?? {}; + const identity = user.name || user.preferred_username || user.email || "Signed-in user"; + res.type("html").send(` Media Intelligence -

Media Intelligence MVP

-

Human-led NZ public-source monitoring behind a dedicated MCP.

+
+

Media Intelligence MVP

Human-led NZ public-source monitoring behind a dedicated MCP.

+
${identity}
Sign out
+
${c.signals ?? 0}signals
${c.opportunities ?? 0}opportunities
${c.new_opportunities ?? 0}new

Sources

-${sourceRows.rows.map((s:any)=>`
${s.name}
${s.id}
${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}
`).join("")} -

MCP endpoint: /api/mcp

+${sourceRows.rows.map((s:any)=>`
${s.name}
${s.id}
${s.signal_count} stored signals
${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}
`).join("")} +

MCP endpoint: /api/mcp — OAuth protected separately from this browser session.

`); }); @@ -98,9 +131,28 @@ async function start() { console.log(JSON.stringify({ event: "server_started", port, version: "0.1.0" })); }); - const intervalMinutes = Math.max(Number(process.env.SYNC_INTERVAL_MINUTES ?? 15), 5); - setTimeout(() => syncAll().then((r) => console.log(JSON.stringify({ event: "initial_sync", result: r }))).catch((e) => console.error(e)), 5000); - setInterval(() => syncAll().then((r) => console.log(JSON.stringify({ event: "scheduled_sync", result: r }))).catch((e) => console.error(e)), intervalMinutes * 60_000); + const intervalMinutes = Math.max( + Number(process.env.SYNC_INTERVAL_MINUTES ?? 15), + 5 + ); + setTimeout( + () => + syncAll() + .then((r) => + console.log(JSON.stringify({ event: "initial_sync", result: r })) + ) + .catch((e) => console.error(e)), + 5000 + ); + setInterval( + () => + syncAll() + .then((r) => + console.log(JSON.stringify({ event: "scheduled_sync", result: r })) + ) + .catch((e) => console.error(e)), + intervalMinutes * 60_000 + ); } start().catch((error) => {