hello-keycloak-web/server.js

108 lines
4.4 KiB
JavaScript

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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
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(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Signed out</title></head><body style="font-family:system-ui;display:grid;place-items:center;min-height:100vh;margin:0"><main style="text-align:center"><h1>Signed out</h1><p><a href="/login">Sign in with Keycloak</a></p></main></body></html>`);
});
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(`<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Arrowstack + Keycloak</title>
<style>:root{font-family:system-ui,sans-serif;color-scheme:light dark}body{min-height:100vh;margin:0;display:grid;place-items:center}main{max-width:720px;padding:2rem;text-align:center}h1{font-size:clamp(2.5rem,8vw,5rem);margin:0 0 1rem}.card{border:1px solid #8885;border-radius:16px;padding:1.25rem;margin:1.5rem 0;text-align:left}code{word-break:break-all}a{font-weight:600}</style></head>
<body><main><h1>Authenticated ✅</h1><p>Hello, <strong>${escapeHtml(name)}</strong>.</p><div class="card"><div><b>Client:</b> ${escapeHtml(clientId)}</div><div><b>Issuer:</b> <code>${escapeHtml(issuerUrl)}</code></div><div><b>Subject:</b> <code>${escapeHtml(user.sub)}</code></div></div><p>Gitea → Coolify → Keycloak, provisioned through @Arrowstack.</p><p><a href="/logout">Sign out locally</a></p></main></body></html>`);
});
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);
});