import crypto from 'node:crypto' import { createRemoteJWKSet, jwtVerify } from 'jose' const SESSION_COOKIE = 'template_studio_session' const FLOW_COOKIE = 'template_studio_oidc' let jwks function config() { return { issuer: process.env.KEYCLOAK_ISSUER?.replace(/\/$/, '') || '', clientId: process.env.KEYCLOAK_CLIENT_ID || '', clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '', baseUrl: process.env.APP_BASE_URL?.replace(/\/$/, '') || '' } } function authRequired() { return process.env.AUTH_REQUIRED !== 'false' } function parseCookies(req) { const values = {} for (const pair of String(req.headers.cookie || '').split(';')) { const index = pair.indexOf('=') if (index <= 0) continue values[pair.slice(0, index).trim()] = decodeURIComponent(pair.slice(index + 1).trim()) } return values } function keyFor(purpose) { const { clientSecret } = config() if (!clientSecret) throw new Error('KEYCLOAK_CLIENT_SECRET is required') return crypto.createHash('sha256').update(`template-studio:${purpose}:${clientSecret}`).digest() } function pack(value, purpose) { const body = Buffer.from(JSON.stringify(value)).toString('base64url') const signature = crypto.createHmac('sha256', keyFor(purpose)).update(body).digest('base64url') return `${body}.${signature}` } function unpack(value, purpose) { if (!value) return null const [body, signature] = String(value).split('.') if (!body || !signature) return null const expected = crypto.createHmac('sha256', keyFor(purpose)).update(body).digest() const received = Buffer.from(signature, 'base64url') if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) return null try { return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) } catch { return null } } function cookie(name, value, maxAge) { return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}` } function clearCookie(name) { return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` } function redirectUri(req) { const { baseUrl } = config() if (baseUrl) return `${baseUrl}/auth/callback` const proto = req.headers['x-forwarded-proto'] || req.protocol || 'https' return `${proto}://${req.get('host')}/auth/callback` } function readSession(req) { if (!authRequired()) { return { sub: 'local-dev', preferred_username: 'local-dev', name: 'Local developer', exp: Math.floor(Date.now() / 1000) + 3600 } } const session = unpack(parseCookies(req)[SESSION_COOKIE], 'session') if (!session || Number(session.exp || 0) <= Math.floor(Date.now() / 1000)) return null return session } function ensureConfigured() { const { issuer, clientId, clientSecret } = config() if (!issuer || !clientId || !clientSecret) throw new Error('Keycloak OIDC is not fully configured') } export function registerAuthRoutes(app) { app.get('/auth/login', (req, res, next) => { try { if (!authRequired()) return res.redirect('/') ensureConfigured() const { issuer, clientId } = config() const state = crypto.randomBytes(24).toString('base64url') const nonce = crypto.randomBytes(24).toString('base64url') const verifier = crypto.randomBytes(48).toString('base64url') const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') const flow = pack({ state, nonce, verifier, exp: Math.floor(Date.now() / 1000) + 600 }, 'oidc') res.setHeader('Set-Cookie', cookie(FLOW_COOKIE, flow, 600)) const url = new URL(`${issuer}/protocol/openid-connect/auth`) url.search = new URLSearchParams({ client_id: clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri(req), state, nonce, code_challenge: challenge, code_challenge_method: 'S256' }).toString() res.redirect(url.toString()) } catch (error) { next(error) } }) app.get('/auth/callback', async (req, res, next) => { try { ensureConfigured() const { issuer, clientId, clientSecret } = config() const flow = unpack(parseCookies(req)[FLOW_COOKIE], 'oidc') if (!flow || flow.exp <= Math.floor(Date.now() / 1000) || flow.state !== req.query.state || !req.query.code) { return res.status(400).send('Invalid or expired sign-in request.') } const tokenResponse = await fetch(`${issuer}/protocol/openid-connect/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri(req), code: String(req.query.code), code_verifier: flow.verifier }) }) if (!tokenResponse.ok) throw new Error(`OIDC token exchange failed: ${tokenResponse.status}`) const tokens = await tokenResponse.json() if (!tokens.id_token) throw new Error('OIDC response did not contain an ID token') jwks ||= createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`)) const { payload } = await jwtVerify(tokens.id_token, jwks, { issuer, audience: clientId }) if (payload.nonce !== flow.nonce) throw new Error('OIDC nonce mismatch') const now = Math.floor(Date.now() / 1000) const session = { sub: payload.sub, preferred_username: payload.preferred_username || payload.email || payload.sub, email: payload.email, name: payload.name || payload.preferred_username || payload.email, exp: Math.min(Number(payload.exp || now + 8 * 3600), now + 8 * 3600) } res.setHeader('Set-Cookie', [ cookie(SESSION_COOKIE, pack(session, 'session'), Math.max(1, session.exp - now)), clearCookie(FLOW_COOKIE) ]) res.redirect('/') } catch (error) { next(error) } }) app.get('/auth/logout', (_req, res) => { res.setHeader('Set-Cookie', clearCookie(SESSION_COOKIE)) res.redirect('/') }) app.get('/api/session', (req, res) => { const session = readSession(req) if (!session) return res.status(401).json({ authenticated: false }) res.setHeader('Cache-Control', 'no-store') res.json({ authenticated: true, user: session }) }) } export function requireAuth(req, res, next) { try { const session = readSession(req) if (!session) return res.status(401).json({ error: 'authentication_required' }) req.user = session next() } catch (error) { next(error) } }