Sync Keycloak-protected UI

This commit is contained in:
Admin 2026-09-29 06:07:02 +00:00
parent 82c7bdd196
commit b52fbb1bed
2 changed files with 313 additions and 29 deletions

View File

@ -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<typeof createRemoteJWKSet> | null = null;
let mcpJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
let webJwks: ReturnType<typeof createRemoteJWKSet> | 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<string, unknown>) {
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("/");
}

View File

@ -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(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Media Intelligence</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;max-width:920px;margin:40px auto;padding:0 20px;color:#1f2937}
header{display:flex;align-items:center;justify-content:space-between;gap:16px}
.card{border:1px solid #d1d5db;border-radius:12px;padding:18px;margin:12px 0}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
strong.big{font-size:2rem;display:block}
.ok{color:#166534}.bad{color:#991b1b}code{background:#f3f4f6;padding:2px 6px;border-radius:5px}
.ok{color:#166534}.bad{color:#991b1b}
code{background:#f3f4f6;padding:2px 6px;border-radius:5px}
a{color:inherit}
.user{font-size:.9rem;color:#4b5563;text-align:right}
</style></head><body>
<h1>Media Intelligence MVP</h1>
<p>Human-led NZ public-source monitoring behind a dedicated MCP.</p>
<header>
<div><h1>Media Intelligence MVP</h1><p>Human-led NZ public-source monitoring behind a dedicated MCP.</p></div>
<div class="user"><strong>${identity}</strong><br><a href="/auth/logout">Sign out</a></div>
</header>
<div class="grid">
<div class="card"><strong class="big">${c.signals ?? 0}</strong>signals</div>
<div class="card"><strong class="big">${c.opportunities ?? 0}</strong>opportunities</div>
<div class="card"><strong class="big">${c.new_opportunities ?? 0}</strong>new</div>
</div>
<h2>Sources</h2>
${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br><code>${s.id}</code><br><span class="${s.last_error?'bad':'ok'}">${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}</span></div>`).join("")}
<p>MCP endpoint: <code>/api/mcp</code></p>
${sourceRows.rows.map((s:any)=>`<div class="card"><strong>${s.name}</strong><br><code>${s.id}</code><br>${s.signal_count} stored signals<br><span class="${s.last_error?'bad':'ok'}">${s.last_error ? 'Error: '+s.last_error : (s.last_synced_at ? 'Last sync '+new Date(s.last_synced_at).toLocaleString() : 'Waiting for first sync')}</span></div>`).join("")}
<p>MCP endpoint: <code>/api/mcp</code> — OAuth protected separately from this browser session.</p>
</body></html>`);
});
@ -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) => {