Add Keycloak OIDC login flow
This commit is contained in:
parent
3acdad5e6a
commit
0f1f3ee337
107
server.js
Normal file
107
server.js
Normal file
@ -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(`<!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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user