From d63316e0a4dbc93f97d20eba7640b996c7546dc4 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 02:40:05 +0000 Subject: [PATCH] feat: use server-side Keycloak OIDC sessions --- .env.example | 4 +- package.json | 1 - server/auth.js | 195 +++++++++++++++++++++++++++++++++++++++++------- server/index.js | 20 +++-- 4 files changed, 186 insertions(+), 34 deletions(-) diff --git a/.env.example b/.env.example index dd9f697..de3cd4d 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ PORT=3000 DATABASE_URL=postgresql://template_studio:change-me@localhost:5432/template_studio -TEMPLATE_TENANT_ID=default +TENANT_ID=template-studio AUTH_REQUIRED=false +APP_BASE_URL=http://localhost:5173 KEYCLOAK_ISSUER=https://auth.example.com/realms/dev KEYCLOAK_CLIENT_ID=template-studio +KEYCLOAK_CLIENT_SECRET=do-not-commit-real-secrets CHROMIUM_PATH=/usr/bin/chromium diff --git a/package.json b/package.json index c94b563..2889906 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "express": "^5.1.0", "idb-keyval": "^6.2.1", "jose": "^6.1.0", - "keycloak-js": "^26.2.0", "mjml": "^4.15.3", "pg": "^8.16.3", "playwright-core": "^1.55.0", diff --git a/server/auth.js b/server/auth.js index 528a052..051b5d3 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,45 +1,188 @@ +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 authConfig() { - const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, '') - const clientId = process.env.KEYCLOAK_CLIENT_ID - return { issuer, clientId } -} - -export function publicAuthConfig() { - const { issuer, clientId } = authConfig() +function config() { return { - authRequired: process.env.AUTH_REQUIRED !== 'false', - issuer: issuer || '', - clientId: clientId || '' + 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(/\/$/, '') || '' } } -export async function requireAuth(req, res, next) { - if (process.env.AUTH_REQUIRED === 'false') { - req.user = { sub: 'local-dev', preferred_username: 'local-dev' } - return next() +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 { - const { issuer, clientId } = authConfig() - if (!issuer || !clientId) throw new Error('Keycloak is not configured') - const header = req.headers.authorization || '' - if (!header.startsWith('Bearer ')) return res.status(401).json({ error: 'missing_bearer_token' }) + return JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) + } catch { + return null + } +} - jwks ||= createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`)) - const { payload } = await jwtVerify(header.slice(7), jwks, { issuer }) +function cookie(name, value, maxAge) { + return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}` +} - const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean) - if (payload.azp !== clientId && !aud.includes(clientId)) { - return res.status(403).json({ error: 'wrong_client' }) +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) } + }) - req.user = payload + 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) { - res.status(401).json({ error: 'invalid_token', detail: error.message }) + next(error) } } diff --git a/server/index.js b/server/index.js index 64959a5..2af2bdb 100644 --- a/server/index.js +++ b/server/index.js @@ -2,14 +2,16 @@ import crypto from 'node:crypto' import express from 'express' import { chromium } from 'playwright-core' import { initDb, getPool } from './db.js' -import { publicAuthConfig, requireAuth } from './auth.js' +import { registerAuthRoutes, requireAuth } from './auth.js' import { renderEmailHtml, renderReportHtml } from './templateEngine.js' const app = express() const port = Number(process.env.PORT || 3000) -const tenantId = process.env.TEMPLATE_TENANT_ID || 'default' +const tenantId = process.env.TENANT_ID || process.env.TEMPLATE_TENANT_ID || 'default' +app.set('trust proxy', 1) app.use(express.json({ limit: '2mb' })) + app.get('/api/health/ready', async (_req, res) => { try { await getPool().query('SELECT 1') @@ -18,7 +20,8 @@ app.get('/api/health/ready', async (_req, res) => { res.status(503).json({ ok: false, error: error.message }) } }) -app.get('/api/config', (_req, res) => res.json(publicAuthConfig())) + +registerAuthRoutes(app) app.use('/api', requireAuth) function userId(req) { @@ -35,7 +38,7 @@ function defaultDocument(type) { } } -app.get('/api/templates', async (req, res) => { +app.get('/api/templates', async (_req, res) => { const { rows } = await getPool().query( 'SELECT id,name,type,status,published_version,updated_at FROM templates WHERE tenant_id=$1 ORDER BY updated_at DESC', [tenantId] @@ -49,7 +52,12 @@ app.post('/api/templates', async (req, res) => { if (!name) return res.status(400).json({ error: 'name_required' }) const id = crypto.randomUUID() const document = defaultDocument(type) - const sampleData = { customer: { name: 'Example Customer' }, report: { date: new Date().toISOString().slice(0, 10) }, summary: { total: 125 } } + const sampleData = { + customer: { name: 'Example Customer' }, + report: { date: new Date().toISOString().slice(0, 10) }, + summary: { total: 125 }, + items: [{ name: 'Example row', value: 125 }] + } const { rows } = await getPool().query( `INSERT INTO templates (id,tenant_id,name,type,draft_document,sample_data,created_by,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$7) RETURNING *`, @@ -137,7 +145,7 @@ app.post('/api/templates/:id/render', async (req, res) => { }) app.use(express.static('dist')) -app.get(/^(?!\/api).*/, (_req, res) => res.sendFile('index.html', { root: 'dist' })) +app.get(/^(?!\/api|\/auth).*/, (_req, res) => res.sendFile('index.html', { root: 'dist' })) app.use((error, _req, res, _next) => { console.error(error) res.status(500).json({ error: 'internal_error' })