Last active
April 24, 2026 15:11
-
-
Save PotatoesMaster/4984c4334d95f88bc2eea65f330d1aee to your computer and use it in GitHub Desktop.
Simple OIDC NodeJs confidential client, displaying userInfo (for testing purpose)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """); | |
| } | |
| 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