From 9ea075070ff205963803cc361ce94f860e6590dc Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 17 Sep 2026 08:25:54 +0000 Subject: [PATCH] Add Keycloak protected CRUD application --- server.js | 203 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 server.js diff --git a/server.js b/server.js new file mode 100644 index 0000000..8a16906 --- /dev/null +++ b/server.js @@ -0,0 +1,203 @@ +import http from 'node:http'; +import crypto from 'node:crypto'; +import { Pool } from 'pg'; + +const PORT = Number(process.env.PORT || 3000); +const APP_BASE_URL = (process.env.APP_BASE_URL || '').replace(/\/$/, ''); +const ISSUER = (process.env.KEYCLOAK_ISSUER || '').replace(/\/$/, ''); +const CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || ''; +const CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; +const DATABASE_URL = process.env.DATABASE_URL || ''; + +for (const [name, value] of Object.entries({ APP_BASE_URL, ISSUER, CLIENT_ID, CLIENT_SECRET, DATABASE_URL })) { + if (!value) throw new Error(`Missing required environment variable: ${name}`); +} + +const pool = new Pool({ connectionString: DATABASE_URL, max: 5 }); +const sessions = new Map(); + +function esc(value) { + return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]); +} + +function parseCookies(req) { + const out = {}; + for (const part of (req.headers.cookie || '').split(';')) { + const i = part.indexOf('='); + if (i < 0) continue; + out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1)); + } + return out; +} + +function setCookie(res, name, value, maxAge = 3600) { + const cookie = `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`; + const existing = res.getHeader('Set-Cookie'); + res.setHeader('Set-Cookie', existing ? [...(Array.isArray(existing) ? existing : [existing]), cookie] : cookie); +} + +function redirect(res, location) { + res.writeHead(302, { Location: location }); + res.end(); +} + +function send(res, status, body, type = 'text/html; charset=utf-8') { + res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' }); + res.end(body); +} + +async function readForm(req) { + let raw = ''; + for await (const chunk of req) { + raw += chunk; + if (raw.length > 1024 * 1024) throw new Error('Request too large'); + } + return Object.fromEntries(new URLSearchParams(raw)); +} + +function sessionFor(req) { + const sid = parseCookies(req).crud_session; + if (!sid) return null; + const session = sessions.get(sid); + if (!session || session.expiresAt < Date.now()) { + if (sid) sessions.delete(sid); + return null; + } + return session; +} + +function layout(user, content) { + return ` + +Arrowstack Tasks +
+

Arrowstack Tasks

Keycloak + dedicated PostgreSQL CRUD demo
${esc(user.preferred_username || user.email || 'user')}   Logout
+${content} +
`; +} + +async function initDb() { + await pool.query(` + CREATE TABLE IF NOT EXISTS tasks ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200), + completed BOOLEAN NOT NULL DEFAULT FALSE, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_tasks_created_by_created_at ON tasks (created_by, created_at DESC)`); +} + +await initDb(); +console.log('database schema ready'); + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url, APP_BASE_URL); + + if (url.pathname === '/health') { + await pool.query('SELECT 1'); + return send(res, 200, JSON.stringify({ ok: true, database: 'connected' }), 'application/json; charset=utf-8'); + } + + if (url.pathname === '/login') { + const state = crypto.randomBytes(24).toString('base64url'); + setCookie(res, 'oidc_state', state, 300); + const auth = new URL(`${ISSUER}/protocol/openid-connect/auth`); + auth.searchParams.set('client_id', CLIENT_ID); + auth.searchParams.set('redirect_uri', `${APP_BASE_URL}/callback`); + auth.searchParams.set('response_type', 'code'); + auth.searchParams.set('scope', 'openid profile email'); + auth.searchParams.set('state', state); + return redirect(res, auth.toString()); + } + + if (url.pathname === '/callback') { + const cookies = parseCookies(req); + const state = url.searchParams.get('state') || ''; + const code = url.searchParams.get('code') || ''; + if (!code || !state || cookies.oidc_state !== state) return send(res, 400, 'Invalid OAuth callback state', 'text/plain; charset=utf-8'); + + const tokenRes = await fetch(`${ISSUER}/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: `${APP_BASE_URL}/callback`, + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET + }) + }); + const token = await tokenRes.json(); + if (!tokenRes.ok || !token.access_token) return send(res, 502, `Keycloak token exchange failed (${tokenRes.status})`, 'text/plain; charset=utf-8'); + + const userRes = await fetch(`${ISSUER}/protocol/openid-connect/userinfo`, { headers: { Authorization: `Bearer ${token.access_token}` } }); + const user = await userRes.json(); + if (!userRes.ok || !user.sub) return send(res, 502, 'Keycloak userinfo failed', 'text/plain; charset=utf-8'); + + const sid = crypto.randomBytes(32).toString('base64url'); + sessions.set(sid, { user, expiresAt: Date.now() + Math.min(Number(token.expires_in || 900), 3600) * 1000 }); + setCookie(res, 'crud_session', sid, Math.min(Number(token.expires_in || 900), 3600)); + setCookie(res, 'oidc_state', '', 0); + return redirect(res, '/'); + } + + if (url.pathname === '/logout') { + const sid = parseCookies(req).crud_session; + if (sid) sessions.delete(sid); + setCookie(res, 'crud_session', '', 0); + return redirect(res, '/login'); + } + + const session = sessionFor(req); + if (!session) return redirect(res, '/login'); + const userKey = session.user.sub; + + if (req.method === 'POST' && url.pathname === '/tasks') { + const form = await readForm(req); + const title = String(form.title || '').trim().slice(0, 200); + if (title) await pool.query('INSERT INTO tasks (title, created_by) VALUES ($1, $2)', [title, userKey]); + return redirect(res, '/'); + } + + const match = url.pathname.match(/^\/tasks\/(\d+)\/(toggle|update|delete)$/); + if (req.method === 'POST' && match) { + const id = Number(match[1]); + const action = match[2]; + if (action === 'toggle') { + await pool.query('UPDATE tasks SET completed = NOT completed, updated_at = NOW() WHERE id = $1 AND created_by = $2', [id, userKey]); + } else if (action === 'update') { + const form = await readForm(req); + const title = String(form.title || '').trim().slice(0, 200); + if (title) await pool.query('UPDATE tasks SET title = $1, updated_at = NOW() WHERE id = $2 AND created_by = $3', [title, id, userKey]); + } else if (action === 'delete') { + await pool.query('DELETE FROM tasks WHERE id = $1 AND created_by = $2', [id, userKey]); + } + return redirect(res, '/'); + } + + if (req.method === 'GET' && url.pathname === '/') { + const result = await pool.query('SELECT id, title, completed, created_at FROM tasks WHERE created_by = $1 ORDER BY created_at DESC', [userKey]); + const rows = result.rows.map(task => ` +
+
+
Created ${esc(new Date(task.created_at).toLocaleString())}
+
+
`).join(''); + + return send(res, 200, layout(session.user, `
${rows || '
No tasks yet. Add one above.
'}
`)); + } + + return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); + } catch (err) { + console.error(err); + return send(res, 500, 'Internal server error', 'text/plain; charset=utf-8'); + } +}); + +server.listen(PORT, '0.0.0.0', () => console.log(`arrowstack-crud-demo listening on :${PORT}`));