Integrate
Node.js server (BFF)
A complete Express server with openid-client. Tokens stay on the server, the session is derived from the IdP, access is granted by (iss, sub). The suite runs this exact code against the IdP.
A confidential client in the backend-for-frontend shape: the server does the login, keeps every token in its own session store, and gives the browser only an HttpOnly cookie. One file, site/examples/node-bff/server.ts. The IdP’s test suite runs it against a real IdP on every change: login, refresh, a blocked account, logout, invitations.
Stack: Node.js 24, Express 5, express-session, openid-client v6.
0. Register and configure
Section titled “0. Register and configure”Register a development client (Registering an app):
{ "clientId": "my-app-dev", "name": "My App (local development)", "profile": "confidential", "redirectUris": ["http://localhost:3000/callback"], "webOrigins": [], "postLogoutRedirectUris": ["http://localhost:3000/"], "secretSsmPath": "/idp/prod/clients/my-app-dev", "notes": "One client per environment: localhost never shares a descriptor with production."}Install and configure:
{ "name": "urupema-idp-node-bff", "private": true, "type": "module", "engines": { "node": ">=24" }, "scripts": { "start": "node --env-file=.env server.ts", "check": "tsc -p ." }, "dependencies": { "express": "5.2.1", "express-session": "1.19.0", "openid-client": "6.8.8" }, "devDependencies": { "@types/express": "5.0.6", "@types/express-session": "1.19.0", "@types/node": "26.6.4", "typescript": "7.0.2" }}IDP_ISSUER=https://id.institutourupema.com.br/realms/urupemaIDP_CLIENT_ID=my-app-dev# Delivered over a secure channel; the same value the IdP keeps in SSM. Never commit it.IDP_CLIENT_SECRET=APP_URL=http://localhost:3000# openssl rand -hex 32SESSION_SECRET=# Demo only: access granted before first login, "email=permission,..."APP_INVITES=Run node --env-file=.env server.ts and open http://localhost:3000/login.
1. Discovery at boot
Section titled “1. Discovery at boot”The app knows one address: the issuer. discovery fetches the endpoints and the signing keys, and fails if the document’s issuer differs from the configured one. enableNonRepudiationChecks makes openid-client verify the ID token signature against the published keys (off by default).
import express from "express";import session from "express-session";import * as client from "openid-client";
const { IDP_ISSUER, IDP_CLIENT_ID, IDP_CLIENT_SECRET, APP_URL, SESSION_SECRET } = process.env as Record<string, string>;
// Discovery at boot: endpoints and keys come from the issuer, never written by hand.// openid-client checks that the discovered `issuer` equals IDP_ISSUER.const config = await client.discovery( new URL(IDP_ISSUER), IDP_CLIENT_ID, IDP_CLIENT_SECRET, undefined, // Also verify the ID token signature against the discovered JWKS. { execute: [client.enableNonRepudiationChecks] },);2. A server-side session
Section titled “2. A server-side session”Tokens live in the session store on the server. The cookie carries only the session id. Its maxAge equals the IdP’s 10-hour session ceiling and is renewed whenever the session changes (each refresh); the ceiling is the IdP refusing the refresh once the central session reaches 10 hours, which ends the local session at the next request. Production: a persistent store (Redis, Postgres); MemoryStore loses every session on restart.
type Person = { iss: string; sub: string; name?: string; email?: string };
declare module "express-session" { interface SessionData { login?: { state: string; nonce: string; verifier: string }; tokens?: { access: string; refresh: string; idToken: string; expiresAt: number }; person?: Person; }}
const app = express();app.set("trust proxy", 1); // behind a TLS proxy, so the `secure` cookie is setapp.use( session({ secret: SESSION_SECRET, resave: false, saveUninitialized: false, // MemoryStore is for development only: in production use a store that survives // restarts (Redis, Postgres). The tokens live there, never in the cookie. cookie: { httpOnly: true, sameSite: "lax", secure: APP_URL.startsWith("https://"), maxAge: 36_000_000, // ceiling: 10 h, the IdP's ssoSessionMaxLifespan }, }),);3. Login: PKCE S256, state, nonce
Section titled “3. Login: PKCE S256, state, nonce”Each login gets a fresh PKCE verifier, state and nonce, kept in the session. Scope exactly openid profile email; any other scope is answered invalid_scope.
app.get("/login", async (req, res) => { const verifier = client.randomPKCECodeVerifier(); const state = client.randomState(); const nonce = client.randomNonce(); req.session.login = { state, nonce, verifier };
const url = client.buildAuthorizationUrl(config, { redirect_uri: `${APP_URL}/callback`, scope: "openid profile email", // exactly this; any other scope gets invalid_scope code_challenge: await client.calculatePKCECodeChallenge(verifier), code_challenge_method: "S256", state, nonce, }); res.redirect(url.href);});4. Callback: validate, then sign in
Section titled “4. Callback: validate, then sign in”authorizationCodeGrant checks state, PKCE, the iss response parameter (RFC 9207) and the ID token: signature, iss, aud, exp and nonce. Any failure throws and nobody is signed in. After success the session id is regenerated (no session fixation).
app.get("/callback", async (req, res, next) => { const login = req.session.login; if (!login) return res.status(400).send("Login expired. Try again."); delete req.session.login; // single-use state
let tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers; try { // Checks state, PKCE, the `iss` response parameter and the ID token (signature, iss, // aud, exp, nonce). Any failure throws: never sign the person in. tokens = await client.authorizationCodeGrant(config, new URL(req.originalUrl, APP_URL), { pkceCodeVerifier: login.verifier, expectedState: login.state, expectedNonce: login.nonce, }); } catch (err) { return next(err); } const person = signIn(tokens.claims()!);
// A new session id after login (prevents session fixation). req.session.regenerate((err) => { if (err) return next(err); req.session.person = person; req.session.tokens = { access: tokens.access_token, refresh: tokens.refresh_token!, idToken: tokens.id_token!, expiresAt: Date.now() + (tokens.expiresIn() ?? 0) * 1000, }; res.redirect("/"); });});5. Access: your app decides
Section titled “5. Access: your app decides”Signing in creates or updates the person’s record, keyed by (iss, sub), and grants nothing. Zero permissions and invitations by verified email: Identity and access.
// Your app's own records. In a real app these are tables (see "Identity and access");// Maps keep the example self-contained.const people = new Map<string, Person & { permissions: string[] }>(); // key: (iss, sub)// Access granted before a person's first login, addressed to an email. The seed comes from// APP_INVITES ("email=permission,…") only to make the example runnable.const invitations = new Map<string, string[]>( (process.env.APP_INVITES ?? "").split(",").filter(Boolean).map((pair) => { const [email, permission] = pair.split("="); return [email.toLowerCase(), [permission]]; }),);
/** Called after every login. Authentication is not authorization: nothing is granted here. */function signIn(claims: client.IDToken): Person { const key = JSON.stringify([claims.iss, claims.sub]); // the person is (iss, sub), never the email const person: Person = { iss: claims.iss, sub: claims.sub, name: claims.name as string | undefined, email: claims.email as string | undefined, }; // A new person starts with zero permissions; a known one keeps theirs, with fresh attributes. const record = { ...person, permissions: people.get(key)?.permissions ?? [] }; // An invitation binds to (iss, sub) once, and only to a verified email. After that the // email is just an attribute: changing it later moves no access. const email = person.email?.toLowerCase(); if (email && claims.email_verified === true && invitations.has(email)) { record.permissions = [...new Set([...record.permissions, ...invitations.get(email)!])]; invitations.delete(email); } people.set(key, record); return person;}6. The derived session
Section titled “6. The derived session”The IdP decides whether a session is still valid. While the access token (300 s) is fresh, requests pass without calling the IdP. On expiry the server refreshes. A refused refresh (account blocked, session ended, idle or maximum lifetime reached) or an unreachable IdP destroys the local session and the request gets 401. A block takes effect within 5 minutes.
// The derived session: the IdP decides whether it is still valid.async function requireSession(req: express.Request, res: express.Response, next: express.NextFunction) { const t = req.session.tokens; if (!t) return res.status(401).json({ error: "login_required" }); if (Date.now() < t.expiresAt) return next();
try { const fresh = await client.refreshTokenGrant(config, t.refresh); req.session.tokens = { access: fresh.access_token, refresh: fresh.refresh_token ?? t.refresh, idToken: fresh.id_token ?? t.idToken, expiresAt: Date.now() + (fresh.expiresIn() ?? 0) * 1000, }; next(); } catch { // Refused (account blocked, session ended, idle or max lifetime) or the IdP is // unreachable: fail closed. The person signs in again. req.session.destroy(() => res.status(401).json({ error: "session_ended" })); }}
app.get("/api/me", requireSession, (req, res) => { const { iss, sub } = req.session.person!; const record = people.get(JSON.stringify([iss, sub])); // A session that outlived the app's records (e.g. an in-memory store after a restart): sign in again. if (!record) return req.session.destroy(() => res.status(401).json({ error: "login_required" })); res.json({ iss, sub, name: record.name, email: record.email, permissions: record.permissions });});7. Logout: both sessions
Section titled “7. Logout: both sessions”Destroy the local session, then redirect the browser to the IdP’s end_session_endpoint with id_token_hint, which ends the central session. See Session and logout.
app.get("/logout", (req, res) => { const idToken = req.session.tokens?.idToken; req.session.destroy(() => { if (!idToken) return res.redirect("/"); // Ends the central (SSO) session too; the URI must be in postLogoutRedirectUris. const url = client.buildEndSessionUrl(config, { id_token_hint: idToken, post_logout_redirect_uri: `${APP_URL}/`, }); res.redirect(url.href); });});Next: with an API, validate the access token there; then Before production.