Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

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.

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.

  1. You need MAIL_STATEMENT from Carry a second issuer's statement in UserInfo, with Lab Mail's lab_enrolled mapping still in place. To see the expiry case quickly, set the ID token lifetime of the manager assigned to lab-mail-collage to 60 seconds, then sign in to lab-mail-collage again as in that lab and keep the new ID token as MAIL_STATEMENT. Run Do today step 2 within the minute.

  2. 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

  1. Receive Lab Photos' UserInfo with an aggregated source from Lab Mail, as in the previous lab's planned walkthrough. Confirm UserInfo's sub matches the ID token, and that Lab Photos is listed in accept_from_providers for Lab Mail.

  2. Follow _claim_names to _claim_sources and take the JWT member.

  3. Run the validator from Do today on it: issuer known, key found by kid, signature valid, times current, no aud, 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.

  1. Store only "Lab Mail confirmed enrollment at iat, until exp", never the JWT.

Do today

  1. Save the validator. It reads iss without 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 any aud, and only then reads the named claim. Its log line has the stage, the check, the issuer, the kid and 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.

  1. 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.

  1. Run it on a real Lab Photos ID token: node aggregated-check.mjs "$ID_TOKEN_AVA" lab_enrolled. The answer is rejected unexpected_issuer, before any key is fetched.

Why it matters: the validator never fetches keys from an issuer just because a JWT names it.

  1. Wait until MAIL_STATEMENT expires and run step 2 again: rejected stale_statement.

Why it matters: an expired statement is treated as missing, and the discount waits for a statement that passes every check.

  1. Read your log lines: stage, check, issuer, kid and 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

  1. Restore the ID token lifetime on Lab Mail's manager, then remove Lab Mail's lab_enrolled mapping.

  2. Keep claims-providers.json and aggregated-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.

Back to all labs

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

The Lab