OPENID CONNECT · LAB
Validate an embedded claims-provider JWT
Plan validating a real aggregated statement end to end, and today build the lesson's validator in the lesson's order and watch it reject real JWTs from two issuers for the right reasons.
PlannedUses both lab tenants
The lesson
Builds on: Combining claims from multiple sources.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
Planned. The core of this lab waits on platform features that are not built yet. The planned walkthrough shows exactly how it will run; Do today is a real exercise you can do now.
- G63 Aggregated and distributed claims (trusted claims-provider registry)
- G66 Second lab tenant for every learner: additional tenants need a paid subscription or a BTL grant, so labs that use Lab Mail cannot be completed by an ordinary learner yet
Needs a second tenant. This lab also uses Lab Mail, a second tenant. Additional tenants currently need a paid subscription or a BTL grant, so you may not be able to do the Lab Mail steps yet (gap G66).
Setup
No tenant emits aggregated claims yet (G63), so the accept path cannot complete. The relying party's validator can be built and exercised today against real JWTs from Lab Photos and Lab Mail.
You need
MAIL_STATEMENTfrom Carry a second issuer's statement in UserInfo, with Lab Mail'slab_enrolledmapping still in place. To see the expiry case quickly, set the ID token lifetime of the manager assigned tolab-mail-collageto 60 seconds, then sign in tolab-mail-collageagain as in that lab and keep the new ID token asMAIL_STATEMENT. Run Do today step 2 within the minute.Write the relying party's own claims provider configuration. It is a trust decision separate from accepting Lab Photos' sign-ins:
jq -n --arg mail "$ISSUER2" --arg photos "$ISSUER" '{($mail): {jwks: ($mail + "/oauth/jwks"), algorithms: ["RS256"],
claim_names: ["lab_enrolled"], accept_from_providers: [$photos]}}' > claims-providers.json
Planned walkthrough
Receive Lab Photos' UserInfo with an aggregated source from Lab Mail, as in the previous lab's planned walkthrough. Confirm UserInfo's
submatches the ID token, and that Lab Photos is listed inaccept_from_providersfor Lab Mail.Follow
_claim_namesto_claim_sourcesand take theJWTmember.Run the validator from Do today on it: issuer known, key found by
kid, signature valid, times current, noaud, claim present.
Why it matters: Lab Photos vouches for whose statement it is. Lab Mail's signature vouches for what it says. Each signer is trusted for one decision.
Store only "Lab Mail confirmed enrollment at
iat, untilexp", never the JWT.
Do today
Save the validator. It reads
isswithout trusting it, looks the issuer up in your configuration before fetching any key, verifies with that issuer's keys and configured algorithm, checks times, rejects anyaud, and only then reads the named claim. Its log line has the stage, the check, the issuer, thekidand a correlation ID, never the JWT.
import { createPublicKey, randomUUID, verify } from 'node:crypto';
import { readFileSync } from 'node:fs';
const [jwt, claim] = process.argv.slice(2);
const providers = JSON.parse(readFileSync('claims-providers.json', 'utf8'));
const read = part => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
const [h, p, s] = jwt.split('.');
const header = read(h), body = read(p);
const done = (result, check) => {
console.log(JSON.stringify({stage: 'aggregated_claim', result, check, issuer: body.iss ?? null, kid: header.kid ?? null, correlation_id: randomUUID()}));
process.exit(result === 'accepted' ? 0 : 1);
};
const provider = providers[body.iss];
if (!provider || !provider.claim_names.includes(claim)) done('rejected', 'unexpected_issuer');
if (!provider.algorithms.includes(header.alg)) done('rejected', 'unknown_key_or_algorithm');
const keys = (await (await fetch(provider.jwks)).json()).keys;
const jwk = keys.find(key => key.kid === header.kid && key.kty === 'RSA');
if (!jwk) done('rejected', 'unknown_key_or_algorithm');
if (!verify('sha256', Buffer.from(h + '.' + p), createPublicKey({key: jwk, format: 'jwk'}), Buffer.from(s, 'base64url'))) done('rejected', 'bad_signature');
const now = Date.now() / 1000;
if (!(body.exp > now - 30) || body.iat > now + 30) done('rejected', 'stale_statement');
if ('aud' in body) done('rejected', 'unexpected_audience');
if (!(claim in body)) done('not_available', 'claim_missing');
done('accepted', 'none');
Save it as aggregated-check.mjs.
Run it on Lab Mail's real ID token while it is still valid:
node aggregated-check.mjs "$MAIL_STATEMENT" lab_enrolled
The issuer is known, the key is found, the signature verifies and the times are fine, then: rejected unexpected_audience.
Why it matters: an ID token Lab Mail issued to some application is signed with Lab Mail's key and even carries the claim, but it is not a statement for you. The audience rule tells them apart.
Run it on a real Lab Photos ID token:
node aggregated-check.mjs "$ID_TOKEN_AVA" lab_enrolled. The answer isrejectedunexpected_issuer, before any key is fetched.
Why it matters: the validator never fetches keys from an issuer just because a JWT names it.
Wait until
MAIL_STATEMENTexpires and run step 2 again:rejectedstale_statement.
Why it matters: an expired statement is treated as missing, and the discount waits for a statement that passes every check.
Read your log lines: stage, check, issuer,
kidand a correlation ID only.
Break it
Planned, once G63 exists: Lab Mail rotates its signing key, and a statement arrives with an unknown kid. The validator refetches the key set once, within a limit, and verifies again. A validator that never refetches rejects every statement on rotation day.
Check your work
Today: three log lines, unexpected_audience for Lab Mail's ID token, unexpected_issuer for Lab Photos' ID token with no key fetched, and stale_statement after expiry. None contains a JWT.
Cleanup
Restore the ID token lifetime on Lab Mail's manager, then remove Lab Mail's
lab_enrolledmapping.Keep
claims-providers.jsonandaggregated-check.mjs: the distributed claims labs extend them.
Missing infrastructure
G63 (aggregated and distributed claims). As in the previous lab, and in particular a statement format with no
aud, issued by a claims provider tenant, so the validator's accept path can complete against a real aggregated source.G66 Second lab tenant: this lab uses Lab Mail, a second tenant. Additional tenants currently need a paid subscription or a BTL grant, so an ordinary learner can do only the Lab Photos steps until every learner can have a second lab tenant.