Add Keycloak shared DB demo

This commit is contained in:
Admin 2026-09-16 04:11:44 +00:00
parent 0a9ab013e1
commit 3ff8fda339

160
server.js Normal file
View File

@ -0,0 +1,160 @@
const crypto = require('crypto');
const express = require('express');
const session = require('express-session');
const { Issuer, generators } = require('openid-client');
const { Pool } = require('pg');
const app = express();
const port = Number(process.env.PORT || 3000);
const appUrl = String(process.env.APP_URL || process.env.COOLIFY_URL || '').replace(/\/$/, '');
const issuerUrl = process.env.KEYCLOAK_ISSUER;
const clientId = process.env.KEYCLOAK_CLIENT_ID;
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
const databaseUrl = process.env.DATABASE_URL;
const tenantId = process.env.TENANT_ID || 'shared-demo-tenant';
const tenantMode = process.env.TENANT_DB_MODE || 'unknown';
for (const [key, value] of Object.entries({ APP_URL: appUrl, KEYCLOAK_ISSUER: issuerUrl, KEYCLOAK_CLIENT_ID: clientId, KEYCLOAK_CLIENT_SECRET: clientSecret, DATABASE_URL: databaseUrl })) {
if (!value) throw new Error(`${key} is required`);
}
const pool = new Pool({ connectionString: databaseUrl });
let oidcClient;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForDatabase() {
let lastError;
for (let attempt = 1; attempt <= 45; attempt++) {
try {
await pool.query('SELECT 1');
await pool.query(`
CREATE TABLE IF NOT EXISTS shared_tenant_visits (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
subject TEXT NOT NULL,
username TEXT,
visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await pool.query('CREATE INDEX IF NOT EXISTS idx_shared_tenant_visits_tenant ON shared_tenant_visits (tenant_id)');
return;
} catch (error) {
lastError = error;
console.log(`database not ready (attempt ${attempt}/45): ${error.message}`);
await sleep(2000);
}
}
throw lastError;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function asyncRoute(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
async function main() {
await waitForDatabase();
const issuer = await Issuer.discover(issuerUrl);
oidcClient = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [`${appUrl}/callback`],
response_types: ['code'],
});
app.set('trust proxy', 1);
app.use(session({
secret: process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000 },
}));
app.get('/health', asyncRoute(async (_req, res) => {
await pool.query('SELECT 1');
res.status(200).send('ok');
}));
app.get('/login', (req, res) => {
const codeVerifier = generators.codeVerifier();
const state = generators.state();
req.session.codeVerifier = codeVerifier;
req.session.state = state;
res.redirect(oidcClient.authorizationUrl({
scope: 'openid profile email',
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: 'S256',
state,
}));
});
app.get('/callback', asyncRoute(async (req, res) => {
const params = oidcClient.callbackParams(req);
const tokenSet = await oidcClient.callback(`${appUrl}/callback`, params, {
code_verifier: req.session.codeVerifier,
state: req.session.state,
});
req.session.user = tokenSet.claims();
delete req.session.codeVerifier;
delete req.session.state;
res.redirect('/');
}));
app.get('/logout', (req, res) => req.session.destroy(() => res.redirect('/logged-out')));
app.get('/logged-out', (_req, res) => res.type('html').send('<!doctype html><html><body style="font-family:sans-serif;max-width:760px;margin:4rem auto"><h1>Signed out</h1><p><a href="/login">Sign in again</a></p></body></html>'));
app.get('/', asyncRoute(async (req, res) => {
if (!req.session.user) return res.redirect('/login');
const user = req.session.user;
const username = user.preferred_username || user.email || user.name || user.sub;
await pool.query(
'INSERT INTO shared_tenant_visits (tenant_id, subject, username) VALUES ($1, $2, $3)',
[tenantId, user.sub, username]
);
const tenantCount = await pool.query('SELECT COUNT(*)::bigint AS count FROM shared_tenant_visits WHERE tenant_id = $1', [tenantId]);
const totalCount = await pool.query('SELECT COUNT(*)::bigint AS count FROM shared_tenant_visits');
const tenantTotal = await pool.query('SELECT COUNT(DISTINCT tenant_id)::bigint AS count FROM shared_tenant_visits');
const lastWrite = await pool.query('SELECT visited_at FROM shared_tenant_visits WHERE tenant_id = $1 ORDER BY id DESC LIMIT 1', [tenantId]);
res.type('html').send(`<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Arrowstack Shared DB Demo</title></head>
<body style="font-family:system-ui,sans-serif;max-width:820px;margin:4rem auto;padding:0 1.5rem;line-height:1.5">
<h1>Authenticated + shared DB persisted ✅</h1>
<p>Keycloak authenticated this request, then the app wrote a tenant-scoped row into Arrowstack's shared PostgreSQL database.</p>
<table style="border-collapse:collapse;width:100%;max-width:720px">
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>User</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(username)}</td></tr>
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>Tenant</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(tenantId)}</td></tr>
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>DB mode</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(tenantMode)}</td></tr>
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>This tenant visits</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(tenantCount.rows[0].count)}</td></tr>
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>All shared rows</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(totalCount.rows[0].count)}</td></tr>
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>Distinct tenants in table</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(tenantTotal.rows[0].count)}</td></tr>
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>Last write</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(lastWrite.rows[0]?.visited_at || '')}</td></tr>
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>Keycloak client</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(clientId)}</td></tr>
</table>
<p style="margin-top:2rem"><a href="/">Record another visit</a> · <a href="/logout">Local sign out</a></p>
</body></html>`);
}));
app.use((error, _req, res, _next) => {
console.error(error);
res.status(500).type('text').send('Application error');
});
app.listen(port, '0.0.0.0', () => console.log(`shared-db-keycloak-demo listening on ${port}`));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});