Sync Keycloak-protected UI
This commit is contained in:
parent
82c7bdd196
commit
b52fbb1bed
260
src/auth.ts
260
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<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();
|
||||
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(401).json({ error: "Invalid bearer token" });
|
||||
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("/");
|
||||
}
|
||||
|
||||
80
src/index.ts
80
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(`<!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) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user