OAUTH 2.0 · LAB
Build a client module configured from metadata, with one place for each job
Build the printer's client module with a transaction store, callback handler, token store, refresh coordinator and API caller, then prove it survives an endpoint move and refuses misuse.
ReadyUses your lab tenant
The lesson
Builds on: Using access tokens.
New to the labs? Start with the lab toolkit and the shared cast and names every lab uses.
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 a separate test client
Recorded as
tenant.oauth.clients.createsucceeded.Connect Ava through the test configuration
Recorded as
oauth.tokensucceeded forlab-tmp-printer-testabout[email protected].Move the token endpoint in Metadata Management
Recorded as
tenant.oauth.metadata.updatesucceeded.Refresh once while two callers race
Recorded as
oauth.tokensucceeded forlab-tmp-printer-testabout[email protected].Rotate the test client's secret
Recorded as
tenant.oauth.credentials.rotatesucceeded.See the module refused with the old secret
Recorded as
oauth.tokenrejected (invalid_client) forlab-tmp-printer-test.
Setup
Choose Lab Photos as the lab tenant and press Start.
In Clients, create
lab-tmp-printer-testfrom the Web application preset: Confidential, PKCE Required, authorization code and refresh token, redirect URIhttp://127.0.0.1:8765/test/callback. Rotate its secret in Access Token Management. Flow policy must allow the refresh token grant.Put each environment's secret in its own variable. They never go into the configuration files:
read -rs PRINTER_SECRET; export PRINTER_SECRET # lab-printer, the "production" client
read -rs PRINTER_TEST_SECRET; export PRINTER_TEST_SECRET # lab-tmp-printer-test
mkdir -p ~/lab-printer-client && cd ~/lab-printer-client
Save two configuration files. They hold only what the printer decides for itself; no endpoint address appears in either.
{ "issuer": "https://tenant-<id>.beyondthelogin.dev", "client_id": "<lab-printer client ID>", "secret_env": "PRINTER_SECRET",
"redirect_uri": "http://127.0.0.1:8765/callback", "scope": "photos.read offline_access", "api_base": "http://127.0.0.1:8766" }
Save that as prod.json, and the same with lab-tmp-printer-test's client ID, "secret_env": "PRINTER_TEST_SECRET" and "redirect_uri": "http://127.0.0.1:8765/test/callback" as test.json.
Save the module as
printer-oauth.mjs. A real client would wrap a maintained OAuth library here; this lab writes the protocol steps out so you can see where each check lives.
// The printer's OAuth client module. Node 18+, no dependencies. Every log line names a stage, never a secret.
import http from 'node:http'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
const rnd = () => crypto.randomBytes(32).toString('base64url');
export async function createClient(config, log = (stage, detail = {}) => console.log(JSON.stringify({ at: new Date().toISOString(), stage, ...detail }))) {
const secret = process.env[config.secret_env];
if (!secret) throw new Error(`${config.secret_env} is not set`);
let meta = null;
async function metadata(force = false) { // endpoints come only from validated metadata
if (meta && !force) return meta;
const fresh = await (await fetch(`${config.issuer}/.well-known/oauth-authorization-server`)).json();
if (fresh.issuer !== config.issuer) throw new Error('metadata names another issuer');
if (!fresh.code_challenge_methods_supported?.includes('S256')) throw new Error('S256 is no longer offered');
return (meta = fresh);
}
const basic = 'Basic ' + Buffer.from(`${encodeURIComponent(config.client_id)}:${encodeURIComponent(secret)}`).toString('base64');
async function tokenRequest(params, corr = null) {
for (const force of [false, true]) { // a 404 means the endpoint moved: read metadata again, once
log('token_request', { corr, grant_type: params.grant_type });
const r = await fetch((await metadata(force)).token_endpoint, { method: 'POST', headers: { Authorization: basic }, body: new URLSearchParams(params) });
log('token_response', { corr, grant_type: params.grant_type, status: r.status, request_id: r.headers.get('x-request-id') });
if (r.status !== 404) return { status: r.status, body: await r.json() };
}
throw new Error('token endpoint not found');
}
// Transaction store: one pending transaction per attempt, claimed once, by the session that started it.
const transactions = new Map();
// Token store: one file per connection, readable only by you. A real deployment encrypts it with a managed key.
const dir = path.join(os.homedir(), 'lab-printer-oauth', config.client_id);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const file = account => path.join(dir, `${account.replace(/[^a-z0-9]/gi, '')}.json`);
const store = {
load: a => (fs.existsSync(file(a)) ? JSON.parse(fs.readFileSync(file(a), 'utf8')) : null),
replace: (a, t) => fs.writeFileSync(file(a), JSON.stringify({ ...t, expires_at: Date.now() + t.expires_in * 1000 }), { mode: 0o600 }),
expire: a => fs.writeFileSync(file(a), JSON.stringify({ ...store.load(a), expires_at: 0 }), { mode: 0o600 }),
};
// Each attempt gets a local correlation ID for the logs. It is not the state, which travels through the browser.
async function begin(account, session = 'browser-1') {
const m = await metadata(), t = { state: rnd(), verifier: rnd(), session, account, corr: rnd().slice(0, 10) };
transactions.set(t.state, t);
log('connect_start', { corr: t.corr, account, session });
return `${m.authorization_endpoint}?` + new URLSearchParams({ response_type: 'code', client_id: config.client_id, redirect_uri: config.redirect_uri,
scope: config.scope, state: t.state, code_challenge: crypto.createHash('sha256').update(t.verifier).digest('base64url'), code_challenge_method: 'S256' });
}
function awaitCallback() { // listens on the redirect URI only until one response arrives
const { port, pathname } = new URL(config.redirect_uri);
return new Promise(resolve => { const server = http.createServer((req, res) => {
const u = new URL(req.url, config.redirect_uri);
if (u.pathname !== pathname) { res.writeHead(404); return res.end(); }
res.end('You can close this window.'); server.close(); resolve(u.searchParams);
}).listen(Number(port), '127.0.0.1'); });
}
async function connect(account, session = 'browser-1') {
console.log(await begin(account, session));
return callback(await awaitCallback(), session);
}
// Callback handler: claim the transaction, compare the issuer, handle an error, and only then exchange the code.
async function callback(params, session) {
const t = transactions.get(params.get('state')); transactions.delete(params.get('state'));
if (!t || t.session !== session) { log('callback_refused', { reason: 'no_transaction' }); throw new Error('no matching attempt'); }
log('callback', { corr: t.corr, result: params.has('code') ? 'code' : 'error', issuer: params.get('iss') === config.issuer ? 'match' : 'mismatch' });
if (params.get('iss') !== config.issuer) throw new Error('wrong issuer');
if (params.get('error')) throw new Error(params.get('error'));
const { status, body } = await tokenRequest({ grant_type: 'authorization_code', code: params.get('code'), redirect_uri: config.redirect_uri,
code_verifier: t.verifier }, t.corr);
if (status !== 200) throw new Error(`exchange refused: ${body.error}`);
store.replace(t.account, body); log('connected', { corr: t.corr, account: t.account, scope: body.scope });
}
// Refresh coordinator: the only code that refreshes, one refresh per connection at a time.
const locks = new Map();
const withLock = (key, fn) => {
if (process.env.NO_LOCK) return fn();
const run = (locks.get(key) ?? Promise.resolve()).then(fn, fn); locks.set(key, run.catch(() => {})); return run;
};
const currentAccessToken = account => withLock(account, async () => {
const stored = store.load(account);
if (!stored) throw new Error('needs reconnection');
if (stored.expires_at > Date.now() + 60_000) return stored.access_token; // good for at least a minute
const { status, body } = await tokenRequest({ grant_type: 'refresh_token', refresh_token: stored.refresh_token });
if (status !== 200) { log('needs_reconnection', { account, error: body.error }); throw new Error('needs reconnection'); }
store.replace(account, { ...stored, ...body }); // save first, then use
return body.access_token;
});
// API caller: the only code that attaches a token, and only to the configured API's origin.
async function callApi(account, target) {
const url = new URL(target, config.api_base);
if (url.origin !== new URL(config.api_base).origin) throw new Error('Refusing to send a photo API token to another host');
return fetch(url, { headers: { Authorization: `Bearer ${await currentAccessToken(account)}` } });
}
return { begin, awaitCallback, connect, callback, currentAccessToken, callApi, store, metadata };
}
Save a small driver as
printer.mjs:
import fs from 'node:fs'; import { createClient } from './printer-oauth.mjs';
const [configFile, command, account = 'ava', target] = process.argv.slice(2);
const client = await createClient(JSON.parse(fs.readFileSync(configFile, 'utf8')));
if (command === 'connect') await client.connect(account);
else if (command === 'call') { const r = await client.callApi(account, target ?? '/photos'); console.log(r.status, await r.text()); }
else if (command === 'race') { // two callers want a token for a connection whose access token is about to expire
client.store.expire(account);
const [a, b] = await Promise.allSettled([client.currentAccessToken(account), client.currentAccessToken(account)]);
console.log(a.status, b.status, a.value && a.value === b.value ? 'same access token' : 'different results');
} else console.log('usage: node printer.mjs <config.json> connect|call|race [account] [path]');
Start the photo API in another terminal:
btl-lab resource --mode jwt.
Walkthrough
Connect Ava through the test configuration:
node printer.mjs test.json connect ava. Open the printed address, sign in as Ava and approve. Then call the API through the module:node printer.mjs test.json call ava /photosreturns200.
Why it matters: the photo picker and the calendar job would both go through callApi, so both get the same refresh behavior and the same rule about where tokens go.
Move the token endpoint. In Metadata Management, change the Token endpoint path to
/oauth/token2and save. The old address no longer answers:
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$ISSUER/oauth/token" # 404
Run node printer.mjs test.json race ava: the module reads the endpoint from metadata, refreshes at the new address, and prints fulfilled fulfilled same access token. A long-running process that cached the old address would get a 404, read metadata again once, and continue. The Token Decoder keeps working too.
Why it matters: a value the server publishes is never copied into a file where it goes stale. The race also shows the refresh coordinator: two callers, one refresh at the tenant, one new access token for both.
Restore: set the Token endpoint path back to /oauth/token and save.
Load the production client ID with the test site's redirect URI. Copy
prod.jsontomixed.json, change onlyredirect_uritohttp://127.0.0.1:8765/test/callback, and runnode printer.mjs mixed.json connect ava. Opening the printed address shows the tenant's page saying the return address is not registered. Press Ctrl+C.
Why it matters: separate registrations turn a configuration mix-up into an error instead of a leak.
Ask the API caller to send a token somewhere else:
node printer.mjs test.json call ava https://example.net/. It throws before any request is sent.
Why it matters: new URL(path, base) lets a full address replace the base, so the origin check in the one function that attaches tokens is what keeps a token from leaving.
Rotate the test secret without deploying it. In Access Token Management, rotate the secret for
lab-tmp-printer-test, but leavePRINTER_TEST_SECRETunchanged. Runnode printer.mjs test.json race ava: the refresh is refused with401 invalid_client, and the module marks the connection as needing reconnection rather than retrying. Thenread -rs PRINTER_TEST_SECRETwith the new value, export it, reconnect, and it works again.
Why it matters: this tenant has no overlap period for client secrets (G10), so a new secret must reach every running copy at once. Each environment has its own secret, so a test secret works nowhere else.
Break it
Run the race without the coordinator's lock: NO_LOCK=1 node printer.mjs test.json race ava. Both callers read the same refresh token and both send it. The second arrives as reuse, the tenant revokes the family (refresh_replayed), and the connection needs reconnecting.
Restore: run without NO_LOCK (unset NO_LOCK), and reconnect Ava with node printer.mjs test.json connect ava.
Check your work
Press Check my progress before Cleanup, because Cleanup deletes the test client. The checks look for, in order: the creation of lab-tmp-printer-test, Ava's connection through it, the tenant.oauth.metadata.update that moved the token endpoint, a refresh through it, the secret rotation, and the refused refresh with the old secret.
Audit also shows a second tenant.oauth.metadata.update for the restore, and refresh_replayed from Break it. Logs show the 404 at the old token path.
Cleanup
Confirm the Token endpoint path is
/oauth/tokenin Metadata Management.Delete stored tokens:
rm -rf ~/lab-printer-oauth. Keep~/lab-printer-client(the module, driver and configuration files): the tracing, testing and operating labs reuse them.Delete the client
lab-tmp-printer-test; the next labs create it again. Rununset PRINTER_TEST_SECRET NO_LOCK.