feat: use server-side Keycloak OIDC sessions
This commit is contained in:
parent
6d50f9d42f
commit
d63316e0a4
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
193
server/auth.js
193
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
|
||||
}
|
||||
}
|
||||
|
||||
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(header.slice(7), jwks, { issuer })
|
||||
const { payload } = await jwtVerify(tokens.id_token, jwks, {
|
||||
issuer,
|
||||
audience: clientId
|
||||
})
|
||||
if (payload.nonce !== flow.nonce) throw new Error('OIDC nonce mismatch')
|
||||
|
||||
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' })
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
||||
req.user = payload
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' })
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user