Skip to content
academia.sh

Lesson 08 / 23

OpenID Connect

Adding an identity layer on top of the authorization delegation protocol: the ID token issued with the openid scope, its separation from the access token by audience, asymmetric signature verification through the discovery document and key set, and the nonce, issuer, audience, and duration steps.

Contents

None of the previous lesson’s flows answered the question “who is this user?” The resource server read the sub field in the token, but which user that field corresponds to, and whether the token was really issued as the result of authentication, fall outside the scope of the authorization delegation protocol. The access token says “this request can access these resources”; it does not say “this person just authenticated their identity.”

OpenID Connect fills this gap by adding an identity layer on top of the authorization delegation protocol. When the client requests the openid scope, a second token is added to the token response: the ID token. This token belongs not to the resource server but to the client, and it states who the user is and when they were authenticated.

Two Tokens, Two Recipients

The distinction shows up in the aud claim. The access token’s audience is the resource server; the ID token’s audience is the client itself. Two rules follow from this: the client does not send the ID token to the API, and the resource server does not accept the ID token. When the two are confused, a token issued for one service ends up being presented as proof to another.

The ID token is also issued to be verified, not to be carried around. The client verifies it once, writes the information inside it into its own session, and has no need to keep the token.

The Provider

The provider below extends the previous lesson’s authorization code flow with the identity layer: the openid scope and the nonce parameter are mandatory, the signature is asymmetric, and public keys are published from a key set (JSON Web Key Set) endpoint.

// provider.mjs — identity layer: discovery document, key set, ID token, userinfo endpoint
// Usage: node provider.mjs [port]     (default 8561)
import { createServer } from "node:http";
import { generateKeyPairSync, createHash, randomBytes, sign, verify, timingSafeEqual } from "node:crypto";

const PORT = Number(process.argv[2] ?? 8561);
const ISSUER = `http://127.0.0.1:${PORT}`;
const CLIENT = { id: "reading-app", redirectUri: "http://127.0.0.1:8563/callback" };
const USER = { sub: "U-1001", name: "Clara Diaz", email: "[email protected]", branch: "central" };

// Two keys: the current one and the retired one. Both stay in the key set.
const makeKey = (kid) => ({ kid, ...generateKeyPairSync("rsa", { modulusLength: 2048 }) });
const KEYS = [makeKey("2026-a"), makeKey("2025-b")];
const CURRENT = KEYS[0];

const CODES = new Map();
const b64 = (v) => Buffer.from(v).toString("base64url");
const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const isEqual = (a, b) => {
  const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

const issueJws = (key, claims) => {
  const data = `${b64(JSON.stringify({ alg: "RS256", typ: "JWT", kid: key.kid }))}.${b64(JSON.stringify(claims))}`;
  return `${data}.${sign("sha256", Buffer.from(data), key.privateKey).toString("base64url")}`;
};

const verifyJws = (token) => {
  const [h, b, s] = (token ?? "").split(".");
  if (!h || !b || !s) return null;
  const header = JSON.parse(Buffer.from(h, "base64url").toString("utf8"));
  const key = KEYS.find((k) => k.kid === header.kid);
  if (!key || header.alg !== "RS256") return null;
  if (!verify("sha256", Buffer.from(`${h}.${b}`), key.publicKey, Buffer.from(s, "base64url"))) return null;
  const claims = JSON.parse(Buffer.from(b, "base64url").toString("utf8"));
  return Math.floor(Date.now() / 1000) < claims.exp ? claims : null;
};

const readForm = (request) => new Promise((resolve) => {
  let v = ""; request.on("data", (p) => (v += p));
  request.on("end", () => resolve(Object.fromEntries(new URLSearchParams(v))));
});

createServer(async (request, response) => {
  response.sendDate = false;
  const url = new URL(request.url, ISSUER), s = url.searchParams;
  const json = (code, body) => {
    response.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
    response.end(JSON.stringify(body));
  };

  if (url.pathname === "/.well-known/openid-configuration") {
    return json(200, {
      issuer: ISSUER, authorization_endpoint: `${ISSUER}/authorize`,
      token_endpoint: `${ISSUER}/token`, userinfo_endpoint: `${ISSUER}/userinfo`,
      jwks_uri: `${ISSUER}/keys`, id_token_signing_alg_values_supported: ["RS256"],
      scopes_supported: ["openid", "profile", "loan:read"], response_types_supported: ["code"],
    });
  }

  if (url.pathname === "/keys") {
    return json(200, {
      keys: KEYS.map((k) => ({
        ...k.publicKey.export({ format: "jwk" }), kid: k.kid, alg: "RS256", use: "sig",
      })),
    });
  }

  if (request.method === "GET" && url.pathname === "/authorize") {
    if (s.get("client_id") !== CLIENT.id || s.get("redirect_uri") !== CLIENT.redirectUri) {
      return json(400, { error: "invalid_request" });
    }
    const scopes = (s.get("scope") ?? "").split(" ");
    // The identity layer only kicks in when the "openid" scope is requested.
    if (!scopes.includes("openid")) return json(400, { error: "invalid_scope", detail: "no openid scope" });
    if (!s.get("state") || !s.get("nonce") || !s.get("code_challenge")) return json(400, { error: "invalid_request" });

    const code = randomBytes(24).toString("base64url");
    CODES.set(code, { challenge: s.get("code_challenge"), nonce: s.get("nonce"), scopes, used: false });
    const u = new URL(CLIENT.redirectUri);
    u.searchParams.set("code", code); u.searchParams.set("state", s.get("state"));
    response.writeHead(302, { location: u.toString() });
    return response.end();
  }

  if (request.method === "POST" && url.pathname === "/token") {
    const form = await readForm(request);
    const record = CODES.get(form.code ?? "");
    if (!record || record.used) return json(400, { error: "invalid_grant" });
    if (!form.code_verifier || !isEqual(s256(form.code_verifier), record.challenge)) return json(400, { error: "invalid_grant" });
    record.used = true;

    const now = Math.floor(Date.now() / 1000);
    // The access token is issued for the resource server; the ID token for the client.
    const access = issueJws(CURRENT, { iss: ISSUER, sub: USER.sub, aud: "loan-service",
      scope: record.scopes.join(" "), exp: now + 600, iat: now });
    const idToken = issueJws(CURRENT, { iss: ISSUER, sub: USER.sub, aud: CLIENT.id,
      nonce: record.nonce, auth_time: now, exp: now + 600, iat: now,
      name: USER.name, email: USER.email });
    return json(200, { access_token: access, id_token: idToken, token_type: "Bearer", expires_in: 600 });
  }

  if (url.pathname === "/userinfo") {
    // The userinfo endpoint requires an access token; the ID token is not valid here.
    const header = request.headers.authorization ?? "";
    if (!header.startsWith("Bearer ")) return json(401, { error: "invalid_token" });
    const claims = verifyJws(header.slice(7));
    if (!claims) return json(401, { error: "invalid_token" });
    if (claims.aud !== "loan-service") return json(401, { error: "invalid_token", detail: "audience is not this endpoint" });
    return json(200, USER);
  }
  json(404, { error: "not_found" });
}).listen(PORT, "127.0.0.1", () => console.log(`provider ${ISSUER}`));

The key set publishes two keys: the current one and the retired one. Key rotation can therefore happen without interruption — a new key is added to the set, signing moves to it, the old key stays in the set until the tokens signed with it run out their lifetime, and only then is it removed. The header’s kid field tells the verifier which key to select.

node provider.mjs 8561 > /dev/null 2>&1 & S=$!
sleep 1
curl -s http://127.0.0.1:8561/keys | sed 's/"n":"[^"]\{20\}[^"]*"/"n":"…"/g'
echo
kill $S
{"keys":[{"kty":"RSA","n":"…","e":"AQAB","kid":"2026-a","alg":"RS256","use":"sig"},{"kty":"RSA","n":"…","e":"AQAB","kid":"2025-b","alg":"RS256","use":"sig"}]}

The set holds only public keys (n is truncated). The verifying party never sees the private key; the power to issue tokens stays with the provider. This is how the scaling problem that shared-secret signing created in the JSON Web Tokens lesson gets solved.

The Client and the Verification Steps

The client hardcodes no address; it reads them from the discovery document. After receiving the token, verification consists of questions answered in sequence.

// client.mjs — reads the discovery document, runs the flow, verifies the ID token step by step
// Usage: while node provider.mjs 8561 &  is running,  node client.mjs
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";

const PROVIDER = "http://127.0.0.1:8561";
const CLIENT_ID = "reading-app";
const REDIRECT_URI = "http://127.0.0.1:8563/callback";
const s256 = (v) => createHash("sha256").update(v).digest("base64url");
const decode = (p) => JSON.parse(Buffer.from(p, "base64url").toString("utf8"));

// 1) Discovery document: endpoint addresses and supported algorithms are read from here.
const discovery = await (await fetch(`${PROVIDER}/.well-known/openid-configuration`)).json();
const keySet = await (await fetch(discovery.jwks_uri)).json();
console.log("issuer            :", discovery.issuer);
console.log("signing algorithms:", discovery.id_token_signing_alg_values_supported.join(", "));
console.log("key set           :", keySet.keys.map((k) => `${k.kid} (${k.kty})`).join(", "));

// 2) Flow: the client generates and keeps state, nonce, and the PKCE verifier itself.
const state = randomBytes(9).toString("base64url");
const nonce = randomBytes(9).toString("base64url");
const verifier = randomBytes(32).toString("base64url");
const u = new URL(discovery.authorization_endpoint);
for (const [name, value] of Object.entries({ response_type: "code", client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI, scope: "openid profile loan:read", state, nonce,
  code_challenge: s256(verifier), code_challenge_method: "S256" })) u.searchParams.set(name, value);

const authResponse = await fetch(u, { redirect: "manual" });
const callback = new URL(authResponse.headers.get("location"));
if (callback.searchParams.get("state") !== state) throw new Error("state did not match");

const tokens = await (await fetch(discovery.token_endpoint, {
  method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({ grant_type: "authorization_code", code: callback.searchParams.get("code"),
    redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier }),
})).json();

// 3) Verifying the ID token. Each step corresponds to a separate question.
const verifyIdToken = (token, { expectedNonce } = {}) => {
  const [h, b, s] = (token ?? "").split(".");
  if (!h || !b || !s) return "format";
  const header = decode(h);
  if (!discovery.id_token_signing_alg_values_supported.includes(header.alg)) return "algorithm";
  const jwk = keySet.keys.find((k) => k.kid === header.kid && k.alg === header.alg);
  if (!jwk) return "key not recognized";
  const publicKey = createPublicKey({ key: jwk, format: "jwk" });
  if (!verify("sha256", Buffer.from(`${h}.${b}`), publicKey, Buffer.from(s, "base64url"))) return "signature";

  const claims = decode(b), now = Math.floor(Date.now() / 1000);
  if (claims.iss !== discovery.issuer) return "issuer";
  const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
  if (!aud.includes(CLIENT_ID)) return `audience (${claims.aud})`;
  if (now >= claims.exp) return "expired";
  if (expectedNonce !== undefined && claims.nonce !== expectedNonce) return "nonce";
  return { sub: claims.sub, name: claims.name, authenticationTime: claims.auth_time };
};

const print = (name, result) => console.log(name.padEnd(30), typeof result === "string" ? `reject (${result})` : `accept → ${JSON.stringify(result)}`);
print("ID token", verifyIdToken(tokens.id_token, { expectedNonce: nonce }));
print("different expected nonce", verifyIdToken(tokens.id_token, { expectedNonce: "another-value" }));
print("access token when presented", verifyIdToken(tokens.access_token, { expectedNonce: nonce }));

const [h, b, s] = tokens.id_token.split(".");
const bodyAltered = Buffer.from(JSON.stringify({ ...decode(b), sub: "Y-0002" })).toString("base64url");
print("body altered", verifyIdToken(`${h}.${bodyAltered}.${s}`, { expectedNonce: nonce }));
const otherKid = Buffer.from(JSON.stringify({ ...decode(h), kid: "1999-z" })).toString("base64url");
print("unrecognized key id", verifyIdToken(`${otherKid}.${b}.${s}`, { expectedNonce: nonce }));

// 4) The userinfo endpoint requires an access token.
const askUserinfo = async (token) => {
  const response = await fetch(discovery.userinfo_endpoint, { headers: { authorization: `Bearer ${token}` } });
  return `${response.status} ${JSON.stringify(await response.json())}`;
};
console.log("userinfo endpoint / access token:", await askUserinfo(tokens.access_token));
console.log("userinfo endpoint / ID token    :", await askUserinfo(tokens.id_token));
node provider.mjs 8561 > /dev/null 2>&1 & S=$!
sleep 1.2
node client.mjs
kill $S
issuer            : http://127.0.0.1:8561
signing algorithms: RS256
key set           : 2026-a (RSA), 2025-b (RSA)
ID token                       accept → {"sub":"U-1001","name":"Clara Diaz","authenticationTime":1787079690}
different expected nonce       reject (nonce)
access token when presented    reject (audience (loan-service))
body altered                   reject (signature)
unrecognized key id            reject (key not recognized)
userinfo endpoint / access token: 200 {"sub":"U-1001","name":"Clara Diaz","email":"[email protected]","branch":"central"}
userinfo endpoint / ID token    : 401 {"error":"invalid_token","detail":"audience is not this endpoint"}

The timestamp changes on every run. The five lines confirm five separate steps.

Algorithm is bounded by the list the discovery document states, and is not taken from the token’s header. The rule established in the JSON Web Tokens lesson applies here too: the algorithms to accept come from the verifier’s configuration.

Key selection happens between kid and the keys in the set. A kid with no counterpart in the set stops verification before it starts. It is normal for the verifier to refetch the set for a kid it does not recognize; but this refetching must be rate-limited, otherwise every unrecognized value triggers an outbound call.

Audience is the step that separates the ID token from the access token. In the third line, the access token is presented as though it were an ID token and is rejected because its aud field is loan-service. The last line shows the same distinction in reverse: the ID token gets a 401 when presented to the userinfo endpoint. Even though both tokens come from the same issuer and are signed with the same key, neither substitutes for the other; the only field that makes the distinction is the aud claim.

Nonce binds the ID token to this session of the client. The client generates the value, sends it along with the flow, and looks for the same one in the returned token. A token that does not match is not recognized if it belongs to another flow. state verifies that the request belongs to the client; nonce verifies that the token belongs to this request; the two are separate questions, and both are required.

The userinfo endpoint returns additional information not present in the ID token, using the access token. Branch information comes back at this endpoint but is not in the ID token; leaving the information to a separate call instead of growing the token keeps the header carried on every request small.

The ID Token’s Limit

The ID token is a statement about the past: at the moment auth_time, this user’s identity was authenticated. It is not an ongoing authorization. After verifying the token, the client opens its own session and manages that session by its own rules — every decision from the Session-Based Authentication lesson applies here too, including renewing the session identifier at login.

The rule that follows is this: the ID token is not attached to every request, is not sent to the resource server, and is not kept for long. It is verified once, its counterpart is written to the local session, and its job is done.

Summary

  • The authorization delegation protocol delegates access authorization; the identity layer adds the information of who the user is on top of that, and kicks in with the openid scope.
  • The ID token’s audience is the client, the access token’s audience is the resource server; even signed with the same key, neither substitutes for the other.
  • The discovery document states the endpoint addresses and the accepted signing algorithms; the key set publishes public keys with kid and makes key rotation uninterrupted.
  • Verification passes in order through the algorithm, key selection, signature, issuer, audience, duration, and nonce steps; each step answers a separate question.
  • state verifies that the request belongs to the client, nonce verifies that the token belongs to that request; neither can substitute for the other.
  • The ID token is a statement of a past authentication; it is verified once, its counterpart is written to the local session, and it is not attached to every request.

Next Step

In this setup, the identity provider and the loan service were part of the same organization: the provider knew the member registry, and the sub field corresponded directly to the member code. This assumption breaks down once district libraries connect to a shared loan system — each organization has its own user directory, its own authentication rules, and its own user identities. The next lesson builds this arrangement: carrying an identity one organization authenticated to another organization with a signed assertion, checking that assertion by its validity window and audience, preventing the same assertion from being used a second time, and translating externally supplied attributes into a local role.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close