Skip to content

Instantly share code, notes, and snippets.

@PotatoesMaster
Last active April 24, 2026 15:11
Show Gist options
  • Select an option

  • Save PotatoesMaster/4984c4334d95f88bc2eea65f330d1aee to your computer and use it in GitHub Desktop.

Select an option

Save PotatoesMaster/4984c4334d95f88bc2eea65f330d1aee to your computer and use it in GitHub Desktop.
Simple OIDC NodeJs confidential client, displaying userInfo (for testing purpose)
/**
* OIDC-protected Node.js POC server β€” zero dependencies
*
* Required environment variables:
* OIDC_WELL_KNOWN e.g. https://accounts.google.com/.well-known/openid-configuration
* OIDC_CLIENT_ID your client ID
* OIDC_CLIENT_SECRET your client secret
*
* Optional:
* PORT (default: 3000)
* BASE_URL public base URL, e.g. http://localhost:3000 (default: http://localhost:{PORT})
*
* To run it:
* - create a `.env` file
* ```
* OIDC_WELL_KNOWN=xxx/.well-known/openid-configuration
* IDC_CLIENT_ID=your client ID
* OIDC_CLIENT_SECRET=your client secret
* ```
* - launch with the command `node --env-file .env main.js`
*/
"use strict";
const http = require("http");
const https = require("https");
const crypto = require("crypto");
const url = require("url");
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const PORT = process.env.PORT || 3000;
const BASE_URL = (process.env.BASE_URL || `http://localhost:${PORT}`).replace(/\/$/, "");
const WELL_KNOWN = process.env.OIDC_WELL_KNOWN;
const CLIENT_ID = process.env.OIDC_CLIENT_ID;
const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
const REDIRECT_URI = `${BASE_URL}/callback`;
if (!WELL_KNOWN || !CLIENT_ID || !CLIENT_SECRET) {
console.error("Missing required env vars: OIDC_WELL_KNOWN, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET");
process.exit(1);
}
// ---------------------------------------------------------------------------
// Tiny in-memory session store
// { sessionId -> { state?, nonce?, codeVerifier?, userInfo?, idToken? } }
// ---------------------------------------------------------------------------
const sessions = new Map();
function createSession() {
const id = crypto.randomBytes(24).toString("hex");
sessions.set(id, {});
return id;
}
function getSession(req) {
const cookieHeader = req.headers.cookie || "";
const match = cookieHeader.match(/(?:^|;\s*)sid=([^;]+)/);
if (!match) return null;
return sessions.get(match[1]) || null;
}
function sessionCookie(id) {
return `sid=${id}; HttpOnly; Path=/; SameSite=Lax`;
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
function fetchJson(targetUrl, options = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(targetUrl);
const lib = parsed.protocol === "https:" ? https : http;
const reqOpts = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
path: parsed.pathname + parsed.search,
method: options.method || "GET",
headers: options.headers || {},
};
const req = lib.request(reqOpts, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
try { resolve(JSON.parse(body)); }
catch (e) { reject(new Error(`JSON parse error: ${body}`)); }
});
});
req.on("error", reject);
if (options.body) req.write(options.body);
req.end();
});
}
function postForm(targetUrl, params) {
const body = new URLSearchParams(params).toString();
return fetchJson(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
},
body,
});
}
// ---------------------------------------------------------------------------
// PKCE helpers
// ---------------------------------------------------------------------------
function generatePKCE() {
const verifier = crypto.randomBytes(48).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
// ---------------------------------------------------------------------------
// OIDC discovery (cached after first call)
// ---------------------------------------------------------------------------
let oidcConfig = null;
async function getOidcConfig() {
if (!oidcConfig) oidcConfig = await fetchJson(WELL_KNOWN);
return oidcConfig;
}
// ---------------------------------------------------------------------------
// HTML helpers
// ---------------------------------------------------------------------------
function esc(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function send(res, status, body, headers = {}) {
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...headers });
res.end(body);
}
function renderUserInfo(userInfo) {
const rows = Object.entries(userInfo)
.map(([k, v]) => `<tr><th>${esc(k)}</th><td>${esc(typeof v === "object" ? JSON.stringify(v) : String(v))}</td></tr>`)
.join("\n");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>OIDC UserInfo</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f4f6f9; color: #1a1a2e; padding: 2rem; }
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; color: #2d5be3; }
table { border-collapse: collapse; width: 100%; max-width: 720px; background: #fff;
border-radius: 10px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
th, td { padding: .75rem 1rem; text-align: left; border-bottom: 1px solid #e8ecf0; font-size: .9rem; }
th { width: 35%; background: #f0f4ff; font-weight: 600; color: #2d5be3; word-break: break-all; }
td { word-break: break-all; }
tr:last-child th, tr:last-child td { border-bottom: none; }
a { display: inline-block; margin-top: 1.5rem; padding: .5rem 1.2rem;
background: #e8ecf0; border-radius: 6px; text-decoration: none; color: #444; font-size: .85rem; }
a:hover { background: #dde2ea; }
</style>
</head>
<body>
<h1>πŸ” OIDC UserInfo</h1>
<table>
<thead><tr><th>Claim</th><th>Value</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<a href="/logout">Sign out</a>
</body>
</html>`;
}
function renderLoggedOut() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Signed out</title>
<style>
body { font-family: system-ui, sans-serif; background: #f4f6f9; color: #1a1a2e;
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100vh; margin: 0; }
h1 { font-size: 1.3rem; margin-bottom: 1rem; }
a { padding: .5rem 1.2rem; background: #2d5be3; color: #fff; border-radius: 6px;
text-decoration: none; font-size: .9rem; }
a:hover { background: #1e44c8; }
</style>
</head>
<body>
<h1>βœ… You have been signed out.</h1>
<a href="/">Sign in again</a>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// Route handlers
// ---------------------------------------------------------------------------
/** GET / β€” protected resource */
async function handleRoot(req, res) {
const sess = getSession(req);
if (sess?.userInfo) {
return send(res, 200, renderUserInfo(sess.userInfo));
}
// Start OIDC flow
const cfg = await getOidcConfig();
const { verifier, challenge } = generatePKCE();
const state = crypto.randomBytes(16).toString("hex");
const nonce = crypto.randomBytes(16).toString("hex");
const sessId = createSession();
sessions.set(sessId, { state, nonce, codeVerifier: verifier });
const authUrl = new URL(cfg.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("scope", "openid profile email");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("nonce", nonce);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
send(res, 302, "", { Location: authUrl.toString(), "Set-Cookie": sessionCookie(sessId) });
}
/** GET /callback */
async function handleCallback(req, res) {
const parsedUrl = url.parse(req.url, true);
const { code, state, error } = parsedUrl.query;
if (error) return send(res, 400, `<pre>OIDC error: ${esc(error)}</pre>`);
// Recover session from cookie
const cookieHeader = req.headers.cookie || "";
const match = cookieHeader.match(/(?:^|;\s*)sid=([^;]+)/);
if (!match) return send(res, 400, "Session not found");
const sessId = match[1];
const sess = sessions.get(sessId);
if (!sess || sess.state !== state) return send(res, 400, "State mismatch β€” possible CSRF");
const cfg = await getOidcConfig();
// Exchange code for tokens
const tokenRes = await postForm(cfg.token_endpoint, {
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: sess.codeVerifier,
});
if (tokenRes.error) {
return send(res, 400, `<pre>Token error: ${esc(tokenRes.error_description || tokenRes.error)}</pre>`);
}
// Fetch UserInfo
const userInfo = await fetchJson(cfg.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tokenRes.access_token}` },
});
// Persist userInfo + id_token (needed later for end_session_endpoint hint)
sessions.set(sessId, { userInfo, idToken: tokenRes.id_token });
send(res, 302, "", { Location: "/" });
}
/** GET /logout
*
* 1. Destroy the local session and clear the cookie.
* 2. If the IdP advertises an end_session_endpoint, redirect there so the
* IdP also kills its SSO session. Without this step the IdP silently
* re-authenticates the user the moment they visit / again.
* 3. Ask the IdP to come back to /logged-out when it's done (post_logout_redirect_uri).
* ⚠ Register BASE_URL/logged-out as an allowed post-logout redirect in your IdP.
* 4. If no end_session_endpoint exists, fall back to showing the logged-out
* page directly (best-effort local logout only).
*/
async function handleLogout(req, res) {
const cookieHeader = req.headers.cookie || "";
const match = cookieHeader.match(/(?:^|;\s*)sid=([^;]+)/);
let idToken = null;
if (match) {
const sess = sessions.get(match[1]);
idToken = sess?.idToken || null;
sessions.delete(match[1]);
}
const clearCookie = "sid=; HttpOnly; Path=/; Max-Age=0";
const cfg = await getOidcConfig();
if (cfg.end_session_endpoint) {
const endUrl = new URL(cfg.end_session_endpoint);
endUrl.searchParams.set("post_logout_redirect_uri", `${BASE_URL}/logged-out`);
// id_token_hint lets the IdP know which session to kill and skips a
// "do you want to log out?" confirmation page on some IdPs.
if (idToken) endUrl.searchParams.set("id_token_hint", idToken);
return send(res, 302, "", { Location: endUrl.toString(), "Set-Cookie": clearCookie });
}
// No end_session_endpoint β€” local logout only
console.warn("IdP has no end_session_endpoint; only local session was cleared.");
send(res, 302, "", { Location: `${BASE_URL}/logged-out`, "Set-Cookie": clearCookie });
}
/** GET /logged-out β€” landing page after IdP redirects back */
function handleLoggedOut(req, res) {
send(res, 200, renderLoggedOut());
}
// ---------------------------------------------------------------------------
// Server
// ---------------------------------------------------------------------------
const server = http.createServer(async (req, res) => {
const pathname = url.parse(req.url).pathname;
try {
if (pathname === "/") return await handleRoot(req, res);
if (pathname === "/callback") return await handleCallback(req, res);
if (pathname === "/logout") return await handleLogout(req, res);
if (pathname === "/logged-out") return handleLoggedOut(req, res);
send(res, 404, "<h1>404 Not Found</h1>");
} catch (err) {
console.error(err);
send(res, 500, `<pre>Internal error: ${esc(err.message)}</pre>`);
}
});
server.listen(PORT, () => {
console.log(`Server running β†’ ${BASE_URL}`);
console.log(`Redirect URI β†’ ${REDIRECT_URI}`);
console.log(`Post-logout return URI β†’ ${BASE_URL}/logged-out`);
console.log(`Well-known β†’ ${WELL_KNOWN}`);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment