Add Keycloak protected CRUD application
This commit is contained in:
parent
b2ef8101ef
commit
9ea075070f
203
server.js
Normal file
203
server.js
Normal file
@ -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 `<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Arrowstack Tasks</title>
|
||||||
|
<style>
|
||||||
|
:root{font-family:Inter,system-ui,sans-serif;color:#e7edf7;background:#0b1020}*{box-sizing:border-box}body{margin:0;background:linear-gradient(160deg,#0b1020,#111a31 55%,#0b1020);min-height:100vh}.wrap{max-width:920px;margin:auto;padding:36px 20px}.top{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:28px}.brand h1{margin:0;font-size:28px}.muted{color:#9ba9bf}.card{background:#121a2d;border:1px solid #27334d;border-radius:16px;padding:18px;box-shadow:0 18px 50px #0005}.add{display:grid;grid-template-columns:1fr auto;gap:10px;margin-bottom:18px}input,button{font:inherit;border-radius:10px;border:1px solid #34415e;padding:11px 13px}input{background:#0d1527;color:#eef4ff;width:100%}button{background:#4f7cff;color:white;border:0;font-weight:650;cursor:pointer}button.secondary{background:#25314a}button.danger{background:#7e3040}.task{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:14px 0;border-top:1px solid #26334c}.task:first-of-type{border-top:0}.done .title{text-decoration:line-through;color:#8190a7}.actions{display:flex;gap:8px;flex-wrap:wrap}.actions form{margin:0}.inline-edit{display:flex;gap:8px}.pill{display:inline-block;background:#202d47;border:1px solid #34415e;border-radius:999px;padding:6px 10px;color:#b9c7db;font-size:13px}.empty{text-align:center;padding:42px 10px;color:#91a0b8}a{color:#9fc0ff;text-decoration:none}@media(max-width:700px){.task{grid-template-columns:auto 1fr}.actions{grid-column:1/-1}.top{align-items:flex-start;flex-direction:column}.add{grid-template-columns:1fr}}
|
||||||
|
</style></head><body><div class="wrap">
|
||||||
|
<div class="top"><div class="brand"><h1>Arrowstack Tasks</h1><div class="muted">Keycloak + dedicated PostgreSQL CRUD demo</div></div><div><span class="pill">${esc(user.preferred_username || user.email || 'user')}</span> <a href="/logout">Logout</a></div></div>
|
||||||
|
${content}
|
||||||
|
</div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => `
|
||||||
|
<div class="task ${task.completed ? 'done' : ''}">
|
||||||
|
<form method="post" action="/tasks/${task.id}/toggle"><button class="secondary" title="Toggle complete">${task.completed ? '✓' : '○'}</button></form>
|
||||||
|
<div><form class="inline-edit" method="post" action="/tasks/${task.id}/update"><input name="title" maxlength="200" value="${esc(task.title)}" aria-label="Task title"><button class="secondary">Save</button></form><div class="muted" style="font-size:12px;margin-top:6px">Created ${esc(new Date(task.created_at).toLocaleString())}</div></div>
|
||||||
|
<div class="actions"><form method="post" action="/tasks/${task.id}/delete"><button class="danger">Delete</button></form></div>
|
||||||
|
</div>`).join('');
|
||||||
|
|
||||||
|
return send(res, 200, layout(session.user, `<div class="card"><form class="add" method="post" action="/tasks"><input name="title" maxlength="200" required placeholder="Add a task…" autofocus><button>Add task</button></form>${rows || '<div class="empty">No tasks yet. Add one above.</div>'}</div>`));
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`));
|
||||||
Loading…
x
Reference in New Issue
Block a user