import crypto from "crypto"; import { Router } from "express"; import { Issuer, generators } from "openid-client"; import config from "../config.js"; import { getLogger } from "../logger.js"; const logger = getLogger("OIDC"); export const SESSION_COOKIE = "ta.sid"; const DEFAULT_APP_REDIRECT = "allotment://auth"; let client; /** Same format as the express-session cookie value (`s:` + cookie-signature). */ function signSessionId(sessionId) { const mac = crypto .createHmac("sha256", config.auth.sessionSecret) .update(sessionId) .digest("base64") .replace(/=+$/, ""); return `s:${sessionId}.${mac}`; } /** * Lets the iOS app authenticate with `Authorization: Bearer ` by * presenting the token from the app login redirect as the session cookie. * Must run before the session middleware. */ export function bearerSessionMiddleware(req, _res, next) { const match = /^Bearer\s+(s:.+)$/i.exec(req.headers.authorization || ""); if (match) { req.headers.cookie = `${SESSION_COOKIE}=${encodeURIComponent(match[1].trim())}`; } next(); } export async function createOidcClient() { const { issuer: issuerUrl, clientId, clientSecret, redirectUri, appCallbackUri, } = config.auth.oidc; if (!issuerUrl || !clientId || !redirectUri) { logger.warn( "OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI not set — OIDC disabled.", ); return null; } const issuer = await Issuer.discover(issuerUrl); client = new issuer.Client({ client_id: clientId, client_secret: clientSecret || undefined, redirect_uris: appCallbackUri ? [redirectUri, appCallbackUri] : [redirectUri], response_types: ["code"], }); return client; } export function createOidcRouter(clientUrl) { const router = Router(); router.get("/auth/login", async (req, res, next) => { if (!client) { return res.status(503).send("OIDC not configured"); } try { const nonce = generators.nonce(); const state = generators.state(); const code_verifier = generators.codeVerifier(); const code_challenge = generators.codeChallenge(code_verifier); const appLogin = req.query.app === "1"; const redirectUri = (appLogin && config.auth.oidc.appCallbackUri) || config.auth.oidc.redirectUri; req.session.oidc = { nonce, state, code_verifier, appLogin, redirectUri }; const authorizationUrl = client.authorizationUrl({ redirect_uri: redirectUri, scope: "openid profile email", state, nonce, code_challenge, code_challenge_method: "S256", }); await new Promise((resolve, reject) => { req.session.save((err) => (err ? reject(err) : resolve())); }); res.redirect(authorizationUrl); } catch (e) { next(e); } }); router.get("/auth/callback", async (req, res, next) => { if (!client) { return res.status(503).send("OIDC not configured"); } try { const params = client.callbackParams(req); const expectedState = req.session.oidc?.state; const checks = { state: expectedState, nonce: req.session.oidc?.nonce, code_verifier: req.session.oidc?.code_verifier, }; const redirectUri = req.session.oidc?.redirectUri || config.auth.oidc.redirectUri; const tokenSet = await client.callback(redirectUri, params, checks); const claims = tokenSet.claims(); req.session.user = { sub: claims.sub, email: claims.email, name: claims.name, }; const appLogin = req.session.oidc?.appLogin === true; delete req.session.oidc; await new Promise((resolve, reject) => { req.session.save((err) => (err ? reject(err) : resolve())); }); if (appLogin) { const token = signSessionId(req.sessionID); const target = new URL(config.auth.appRedirectUri || DEFAULT_APP_REDIRECT); target.searchParams.set("token", token); res.redirect(target.toString()); return; } res.redirect(clientUrl || "/"); } catch (e) { logger.error("callback error:", e); next(e); } }); router.post("/auth/logout", (req, res) => { req.session.destroy(() => { res.json({ ok: true }); }); }); router.get("/auth/logout", (req, res) => { req.session.destroy(() => { res.redirect(clientUrl || "/"); }); }); return router; }