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.
- 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).
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.
Sign in to start this lab and check your progress. Log in or create an account.
Register the test client
Recorded as
tenant.oauth.clients.createsucceeded.Connect a test account for the suite
Recorded as
oauth.tokensucceeded forlab-tmp-printer-testabout[email protected].Pass the concurrency test with one refresh
Recorded as
oauth.tokensucceeded forlab-tmp-printer-testabout[email protected].See the concurrency test catch a missing lock
Recorded as
oauth.tokenrejected (refresh_replayed) forlab-tmp-printer-test.
Setup
Choose Lab Photos as the lab tenant and press Start.
Create
lab-tmp-printer-testagain as in Implementing a client Setup,read -rs PRINTER_TEST_SECRET; export PRINTER_TEST_SECRET, and updateclient_idin~/lab-printer-client/test.json. Work in~/lab-printer-client.Tell the tests where the toolkit is:
export BTL_LAB=~/btl-lab.mjs.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-printerand the helpers from Present an access token correctly, extended to keepID_TOKEN:authorize "openid photos.read", sign in as Ava,exchange. KeepVALID=$TOKENandID=$ID_TOKEN.In Lab Mail with
lab-mail-collage, get any access token and keep it asOTHER.For an expired token: in Access Token Management, create
lab-tmp-expiring(Signed JWT, Maximum lifetime60), assign it tolab-printer, get a token and keep it asEXPIRED, then assign Default access tokens back tolab-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
Connect a test account for the client tests:
node printer.mjs test.json connect ava, signing in as Ava.
Walkthrough
Save the suite as
oauth.test.mjs. The validator tests drivebtl-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);
});
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.
Read what the validator tests did not cover. The lesson's table also lists an unsigned token,
HS256with the public key as a secret, and a different key under a knownkid. 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.
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.tokenrefresh event in Audit for two callers.
Separate test identities from real ones. Check that
test.jsonnameslab-tmp-printer-test, whose only redirect URI is the test callback, and that its secret comes from its own variable. Copytest.jsontowrong.jsonwithprod.json'sclient_idand 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
Delete the fixtures and stored tokens:
rm -rf fixtures ~/lab-printer-oauth; rm -f wrong.json.Confirm
lab-printeruses Default access tokens, then deletelab-tmp-expiring.Delete the client
lab-tmp-printer-test. Rununset 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.