184 lines
6.9 KiB
JavaScript
184 lines
6.9 KiB
JavaScript
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);
|
|
});
|