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

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.

  1. Register a separate test client

    Recorded as tenant.oauth.clients.create succeeded.

  2. Connect Ava through the test configuration

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

  3. Move the token endpoint in Metadata Management

    Recorded as tenant.oauth.metadata.update succeeded.

  4. Refresh once while two callers race

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

  5. Rotate the test client's secret

    Recorded as tenant.oauth.credentials.rotate succeeded.

  6. See the module refused with the old secret

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

Setup

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

  2. In Clients, create lab-tmp-printer-test from the Web application preset: Confidential, PKCE Required, authorization code and refresh token, redirect URI http://127.0.0.1:8765/test/callback. Rotate its secret in Access Token Management. Flow policy must allow the refresh token grant.

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

  1. 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 };
}
  1. 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]');
  1. Start the photo API in another terminal: btl-lab resource --mode jwt.

Walkthrough

  1. 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 /photos returns 200.

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.

  1. Move the token endpoint. In Metadata Management, change the Token endpoint path to /oauth/token2 and 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.

  1. Load the production client ID with the test site's redirect URI. Copy prod.json to mixed.json, change only redirect_uri to http://127.0.0.1:8765/test/callback, and run node 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.

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

  1. Rotate the test secret without deploying it. In Access Token Management, rotate the secret for lab-tmp-printer-test, but leave PRINTER_TEST_SECRET unchanged. Run node printer.mjs test.json race ava: the refresh is refused with 401 invalid_client, and the module marks the connection as needing reconnection rather than retrying. Then read -rs PRINTER_TEST_SECRET with 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

  1. Confirm the Token endpoint path is /oauth/token in Metadata Management.

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

  3. Delete the client lab-tmp-printer-test; the next labs create it again. Run unset PRINTER_TEST_SECRET NO_LOCK.

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