diff --git a/server.js b/server.js new file mode 100644 index 0000000..dd71d28 --- /dev/null +++ b/server.js @@ -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('
This page required Keycloak login and then wrote your visit to PostgreSQL provisioned by Arrowstack.
+| User | ${escapeHtml(username)} |
| Tenant | ${escapeHtml(tenantId)} |
| DB mode | ${escapeHtml(tenantMode)} |
| Schema | ${escapeHtml(tenantSchema || '(default)')} |
| Persisted visits | ${escapeHtml(countResult.rows[0].count)} |
| Last write | ${escapeHtml(lastResult.rows[0]?.visited_at || '')} |
| Keycloak client | ${escapeHtml(clientId)} |