278 lines
8.7 KiB
TypeScript
278 lines
8.7 KiB
TypeScript
import type { NextFunction, Request, Response } from "express";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { createRemoteJWKSet, jwtVerify, SignJWT } from "jose";
|
|
|
|
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 = publicUrl(req);
|
|
return {
|
|
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"]
|
|
};
|
|
}
|
|
|
|
export async function requireMcpAuth(req: Request, res: Response, next: NextFunction) {
|
|
if (process.env.AUTH_DISABLED === "true") return next();
|
|
|
|
const issuer = process.env.KEYCLOAK_ISSUER;
|
|
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
|
if (!issuer || !clientId) {
|
|
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="${publicUrl(req)}/.well-known/oauth-protected-resource/api/mcp"`
|
|
);
|
|
return res.status(401).json({ error: "OAuth access token required" });
|
|
}
|
|
|
|
try {
|
|
if (!mcpJwks) {
|
|
mcpJwks = createRemoteJWKSet(
|
|
new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/certs`)
|
|
);
|
|
}
|
|
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: "OAuth token was not issued for this MCP client" });
|
|
}
|
|
(req as any).auth = payload;
|
|
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("/");
|
|
}
|