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

OAUTH 2.0 · LAB

Write tests for your validator, your callback checks and your refresh lock

Build a validator suite from real fixture tokens, test that the client's callback refuses bad responses before sending anything, prove the refresh lock under concurrency, and check one tenant refusal.

Partly readyUses both lab tenants

The lesson

Builds on: Implementing a client.

New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.

Partly ready. Most of this lab runs today. Steps that wait on platform features are marked, and Missing infrastructure says what they need.

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

Your progress

Press Start before you begin. Only events your tenant records after that count, in the order below. Checking reads your tenant's Audit, so you need Audit read access in it.

  1. Register the test client

    Recorded as tenant.oauth.clients.create succeeded.

  2. Connect a test account for the suite

    Recorded as oauth.token succeeded for lab-tmp-printer-test about [email protected].

  3. Pass the concurrency test with one refresh

    Recorded as oauth.token succeeded for lab-tmp-printer-test about [email protected].

  4. See the concurrency test catch a missing lock

    Recorded as oauth.token rejected (refresh_replayed) for lab-tmp-printer-test.

Setup

  1. Choose Lab Photos as the lab tenant and press Start.

  2. Create lab-tmp-printer-test again as in Implementing a client Setup, read -rs PRINTER_TEST_SECRET; export PRINTER_TEST_SECRET, and update client_id in ~/lab-printer-client/test.json. Work in ~/lab-printer-client.

  3. Tell the tests where the toolkit is: export BTL_LAB=~/btl-lab.mjs.

  4. Collect fixture tokens into a test-only folder. They are real tokens from your own tenants, they expire within the hour, and they are deleted in Cleanup.

    • With lab-printer and the helpers from Present an access token correctly, extended to keep ID_TOKEN: authorize "openid photos.read", sign in as Ava, exchange. Keep VALID=$TOKEN and ID=$ID_TOKEN.

    • In Lab Mail with lab-mail-collage, get any access token and keep it as OTHER.

    • For an expired token: in Access Token Management, create lab-tmp-expiring (Signed JWT, Maximum lifetime 60), assign it to lab-printer, get a token and keep it as EXPIRED, then assign Default access tokens back to lab-printer. Wait two minutes before running the suite.

mkdir -p -m 700 fixtures
jq -n --arg valid "$VALID" --arg id "$ID" --arg other "$OTHER" --arg expired "$EXPIRED" '{valid: $valid, id: $id, other_issuer: $other, expired: $expired}' > fixtures/tokens.json
chmod 600 fixtures/tokens.json; unset VALID ID OTHER EXPIRED
  1. Connect a test account for the client tests: node printer.mjs test.json connect ava, signing in as Ava.

Walkthrough

  1. Save the suite as oauth.test.mjs. The validator tests drive btl-lab verify, which exits with an error when any check fails. The client tests pass a log collector to the module, so a test can prove a refused callback sent nothing to the token endpoint.

import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { execFileSync } from 'node:child_process';
import { createClient } from './printer-oauth.mjs';
const config = JSON.parse(fs.readFileSync('test.json', 'utf8'));
const fixtures = JSON.parse(fs.readFileSync('fixtures/tokens.json', 'utf8'));
const audience = `${config.issuer}/resource`;
const verifies = (token, aud = audience) => {
  try { execFileSync('node', [process.env.BTL_LAB, 'verify', token, '--issuer', config.issuer, '--audience', aud, '--type', 'at+jwt'], { stdio: 'pipe' }); return true; }
  catch { return false; }
};
const collector = () => { const lines = []; return { lines, log: (stage, detail = {}) => lines.push({ stage, ...detail }) }; };
const tokenRequests = (lines, grant) => lines.filter(l => l.stage === 'token_request' && (!grant || l.grant_type === grant)).length;

test('accepts a valid access token', () => assert.equal(verifies(fixtures.valid), true));
test('refuses an ID token as an access token', () => assert.equal(verifies(fixtures.id), false));
test('refuses a genuine token from another issuer', () => assert.equal(verifies(fixtures.other_issuer), false));
test('refuses a valid token for another API', () => assert.equal(verifies(fixtures.valid, 'https://share.lab.test'), false));
test('refuses an expired token', () => assert.equal(verifies(fixtures.expired), false));

test('refuses a state that matches no attempt, and sends nothing', async () => {
  const { lines, log } = collector(), client = await createClient(config, log);
  await assert.rejects(client.callback(new URLSearchParams({ code: 'unused', state: 'no-such-attempt', iss: config.issuer }), 'browser-1'));
  assert.equal(tokenRequests(lines), 0);
});
test('refuses an attempt started in another session', async () => {
  const { lines, log } = collector(), client = await createClient(config, log);
  const state = new URL(await client.begin('ava', 'session-a')).searchParams.get('state');
  await assert.rejects(client.callback(new URLSearchParams({ code: 'unused', state, iss: config.issuer }), 'session-b'));
  assert.equal(tokenRequests(lines), 0);
});
test('refuses a response naming another issuer before using the code', async () => {
  const { lines, log } = collector(), client = await createClient(config, log);
  const state = new URL(await client.begin('ava')).searchParams.get('state');
  await assert.rejects(client.callback(new URLSearchParams({ code: 'unused', state, iss: 'https://issuer.other.example' }), 'browser-1'));
  assert.equal(tokenRequests(lines), 0);
});
test('every attempt gets a fresh S256 challenge', async () => {
  const client = await createClient(config, () => {});
  const [a, b] = [new URL(await client.begin('ava')), new URL(await client.begin('ava'))];
  assert.equal(a.searchParams.get('code_challenge_method'), 'S256');
  assert.notEqual(a.searchParams.get('code_challenge'), b.searchParams.get('code_challenge'));
});
test('two callers share one refresh', async () => {
  const { lines, log } = collector(), client = await createClient(config, log);
  client.store.expire('ava');
  const [first, second] = await Promise.all([client.currentAccessToken('ava'), client.currentAccessToken('ava')]);
  assert.equal(tokenRequests(lines, 'refresh_token'), 1);
  assert.equal(first, second);
});
test('the tenant refuses a redirect URI with a trailing slash without redirecting', async () => {
  const client = await createClient(config, () => {}), url = new URL((await client.metadata()).authorization_endpoint);
  url.search = new URLSearchParams({ response_type: 'code', client_id: config.client_id, redirect_uri: `${config.redirect_uri}/`, scope: 'photos.read',
    state: 'test', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', code_challenge_method: 'S256' });
  const response = await fetch(url, { redirect: 'manual' });
  assert.equal(response.status, 400); assert.equal(response.headers.get('location'), null);
});
  1. Run it: node --test oauth.test.mjs. Every test passes.

Why it matters: the valid-token test proves the validator is not simply rejecting everything, so the refusals beside it mean something.

  1. Read what the validator tests did not cover. The lesson's table also lists an unsigned token, HS256 with the public key as a secret, and a different key under a known kid. Those need specially constructed tokens, which belong in your JWT library's own test setup with throwaway keys created only for the test run, never in a lab against a shared issuer. Write down which of them the library you use already tests, and which you would add there.

Why it matters: a signature test that edits one character of a real token can pass by accident, because the last Base64url character can carry unused bits. A token signed by a different key is certainly wrong.

  1. Look at what the client tests proved. Three refused callbacks each sent zero token requests, which a screen message alone could never show. The refresh test produced exactly one oauth.token refresh event in Audit for two callers.

  1. Separate test identities from real ones. Check that test.json names lab-tmp-printer-test, whose only redirect URI is the test callback, and that its secret comes from its own variable. Copy test.json to wrong.json with prod.json's client_id and run the suite against it: the redirect test still passes, but connecting with it fails at the tenant.

Why it matters: a test that reached for production by mistake fails, because nothing in the test configuration works there.

Break it

Run the suite without the refresh coordinator's lock: NO_LOCK=1 node --test oauth.test.mjs. The concurrency test fails with two refresh requests, and Audit shows the second refresh rejected as refresh_replayed: the failure the test exists to catch.

Restore: run unset NO_LOCK, reconnect Ava with node printer.mjs test.json connect ava, and run the suite once more: all tests pass.

Check your work

Press Check my progress before Cleanup, because Cleanup deletes the test client. The checks look for, in order: the test client's creation, the connected test account, the single refresh from the concurrency test, and refresh_replayed when the lock was removed.

All tests pass with every check in place, and the concurrency test fails when the lock is removed.

Cleanup

  1. Delete the fixtures and stored tokens: rm -rf fixtures ~/lab-printer-oauth; rm -f wrong.json.

  2. Confirm lab-printer uses Default access tokens, then delete lab-tmp-expiring.

  3. Delete the client lab-tmp-printer-test. Run unset PRINTER_TEST_SECRET NO_LOCK.

Missing infrastructure

  • 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