Add Keycloak and tenant database demo
This commit is contained in:
parent
b559914ab4
commit
768edc1541
183
server.js
Normal file
183
server.js
Normal file
@ -0,0 +1,183 @@
|
||||
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 || 'demo-tenant';
|
||||
const tenantMode = process.env.TENANT_DB_MODE || 'unknown';
|
||||
const tenantSchema = process.env.TENANT_DB_SCHEMA || '';
|
||||
|
||||
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;
|
||||
|
||||
function sleep(ms) {
|
||||
return 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 tenant_visits (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
username TEXT,
|
||||
visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
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 codeChallenge = generators.codeChallenge(codeVerifier);
|
||||
const state = generators.state();
|
||||
req.session.codeVerifier = codeVerifier;
|
||||
req.session.state = state;
|
||||
const url = oidcClient.authorizationUrl({
|
||||
scope: 'openid profile email',
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
});
|
||||
res.redirect(url);
|
||||
});
|
||||
|
||||
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 tenant_visits (tenant_id, subject, username) VALUES ($1, $2, $3)',
|
||||
[tenantId, user.sub, username]
|
||||
);
|
||||
|
||||
const countResult = await pool.query(
|
||||
'SELECT COUNT(*)::bigint AS count FROM tenant_visits WHERE tenant_id = $1',
|
||||
[tenantId]
|
||||
);
|
||||
const lastResult = await pool.query(
|
||||
'SELECT visited_at FROM 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 Tenant 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 + persisted ✅</h1>
|
||||
<p>This page required Keycloak login and then wrote your visit to PostgreSQL provisioned by Arrowstack.</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>Schema</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(tenantSchema || '(default)')}</td></tr>
|
||||
<tr><td style="padding:.5rem;border-bottom:1px solid #ddd"><strong>Persisted visits</strong></td><td style="padding:.5rem;border-bottom:1px solid #ddd">${escapeHtml(countResult.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(lastResult.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(`tenant-db-keycloak-demo listening on ${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user