228 lines
7.8 KiB
JavaScript
228 lines
7.8 KiB
JavaScript
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(/\/$/, '') || '',
|
|
allowedApiClients: String(process.env.TEMPLATE_API_ALLOWED_CLIENTS || '')
|
|
.split(',')
|
|
.map(value => value.trim())
|
|
.filter(Boolean),
|
|
allowedMcpClients: String(process.env.TEMPLATE_MCP_ALLOWED_CLIENTS || '')
|
|
.split(',')
|
|
.map(value => value.trim())
|
|
.filter(Boolean)
|
|
}
|
|
}
|
|
|
|
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`
|
|
}
|
|
|
|
export 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')
|
|
}
|
|
|
|
async function verifyAllowedMachineToken(token, allowedClients, purpose) {
|
|
const { issuer } = config()
|
|
if (!issuer) throw new Error('KEYCLOAK_ISSUER is required')
|
|
if (!allowedClients.length) throw new Error(`No ${purpose} clients are allowed`)
|
|
|
|
jwks ||= createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`))
|
|
const { payload } = await jwtVerify(token, jwks, { issuer })
|
|
|
|
const client = String(payload.azp || '')
|
|
if (!client || !allowedClients.includes(client)) {
|
|
throw new Error(`Token client is not allowed for ${purpose}`)
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
export async function verifyMachineToken(token) {
|
|
const { allowedApiClients } = config()
|
|
return verifyAllowedMachineToken(token, allowedApiClients, 'template consumption')
|
|
}
|
|
|
|
export async function verifyMcpToken(token) {
|
|
const { allowedMcpClients } = config()
|
|
return verifyAllowedMachineToken(token, allowedMcpClients, 'template authoring')
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|