diff --git a/server.js b/server.js new file mode 100644 index 0000000..469d6e1 --- /dev/null +++ b/server.js @@ -0,0 +1,107 @@ +const crypto = require('crypto'); +const express = require('express'); +const session = require('express-session'); +const { Issuer, generators } = require('openid-client'); + +const port = Number(process.env.PORT || 3000); +const appUrl = (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; + +for (const [name, value] of Object.entries({ APP_URL: appUrl, KEYCLOAK_ISSUER: issuerUrl, KEYCLOAK_CLIENT_ID: clientId, KEYCLOAK_CLIENT_SECRET: clientSecret })) { + if (!value) throw new Error(`${name} is required`); +} + +const escapeHtml = (value = '') => String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +async function main() { + const issuer = await Issuer.discover(issuerUrl); + const client = new issuer.Client({ + client_id: clientId, + client_secret: clientSecret, + redirect_uris: [`${appUrl}/callback`], + response_types: ['code'], + }); + + const app = express(); + 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' }, + })); + + app.get('/health', (_req, res) => res.status(200).send('ok')); + + app.get('/login', (req, res) => { + const codeVerifier = generators.codeVerifier(); + const state = generators.state(); + req.session.oidc = { codeVerifier, state }; + + const authorizationUrl = client.authorizationUrl({ + scope: 'openid profile email', + state, + code_challenge: generators.codeChallenge(codeVerifier), + code_challenge_method: 'S256', + }); + res.redirect(authorizationUrl); + }); + + app.get('/callback', async (req, res, next) => { + try { + const pending = req.session.oidc; + if (!pending) return res.status(400).send('Missing login session'); + + const tokenSet = await client.callback( + `${appUrl}/callback`, + client.callbackParams(req), + { state: pending.state, code_verifier: pending.codeVerifier }, + ); + + req.session.oidc = undefined; + req.session.user = tokenSet.claims(); + req.session.idToken = tokenSet.id_token; + res.redirect('/'); + } catch (error) { + next(error); + } + }); + + app.get('/logout', (req, res) => { + req.session.destroy(() => res.redirect('/logged-out')); + }); + + app.get('/logged-out', (_req, res) => { + res.type('html').send(`
Hello, ${escapeHtml(name)}.
${escapeHtml(issuerUrl)}${escapeHtml(user.sub)}Gitea → Coolify → Keycloak, provisioned through @Arrowstack.