Add Keycloak shared DB demo
This commit is contained in:
parent
0a9ab013e1
commit
3ff8fda339
160
server.js
Normal file
160
server.js
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user