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(`Signed out

Signed out

Sign in with Keycloak

`); + }); + + app.get('/', (req, res) => { + if (!req.session.user) return res.redirect('/login'); + const user = req.session.user; + const name = user.name || user.preferred_username || user.sub; + res.type('html').send(` + +Arrowstack + Keycloak + +

Authenticated ✅

Hello, ${escapeHtml(name)}.

Client: ${escapeHtml(clientId)}
Issuer: ${escapeHtml(issuerUrl)}
Subject: ${escapeHtml(user.sub)}

Gitea → Coolify → Keycloak, provisioned through @Arrowstack.

Sign out locally

`); + }); + + app.use((error, _req, res, _next) => { + console.error(error); + res.status(500).send('Authentication error'); + }); + + app.listen(port, '0.0.0.0', () => console.log(`hello-keycloak-web listening on ${port}`)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});