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

Find an API's metadata starting from nothing but its address

Call an API without a token, follow the resource_metadata challenge to its RFC 9728 document, and read which authorization server it trusts. Today, build the discovery client against a labeled local fixture.

PlannedIncludes a simulationUses your lab tenant

The lesson

Builds on: Validating metadata and issuer identity.

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.

Request console

Requests in this lab can be sent from this page to your tenant: open one and choose Send. Fill in the values below first. They stay in this page's memory and are gone when you leave; secrets are never stored or sent anywhere except the request you send.

Setup

These steps are real today. You need ISSUER in the shell and Node 20 or later. Work in a folder outside any repository, such as ~/btl-issuer from the issuer labs.

Planned walkthrough

This walkthrough runs once Lab Photos hosts a sample photo API at $ISSUER/resource that publishes RFC 9728 metadata and sends resource_metadata in its challenges (G3), configured in the tenant's resource registry (G55). Its identifier is exactly your tenant's default access token audience, and because the identifier has the path /resource, its document lives at $ISSUER/.well-known/oauth-protected-resource/resource.

Planned setup

  1. In OAuth > Resources > Sample photo API (planned), turn it on and set the name "Lab photo API", the scope photos.read and the documentation address https://photos.lab.example/developers. Audit records tenant.oauth.resources.update (planned event name).

Steps

  1. Call the address without a token:

GET$ISSUER/resource Open in console
GET $ISSUER/resource

The response is 401 with WWW-Authenticate: Bearer resource_metadata="<your ISSUER>/.well-known/oauth-protected-resource/resource".

Why it matters: a request without a token gets a challenge with no error code, and the parameter points the client to the API's own description.

  1. Fetch the document it names:

GET$ISSUER/.well-known/oauth-protected-resource/resource Open in console
GET $ISSUER/.well-known/oauth-protected-resource/resource
{"resource": "<your ISSUER>/resource", "authorization_servers": ["<your ISSUER>"], "scopes_supported": ["photos.read"],
 "bearer_methods_supported": ["header"], "resource_name": "Lab photo API", "resource_documentation": "https://photos.lab.example/developers"}

Why it matters: the document begins with the identifier it describes, which is also the aud of tokens issued for this API, and names the authorization server whose tokens it accepts.

  1. Build the address yourself from the identifier with the insertion rule: the well-known name goes at the root and the path /resource follows it. The appended form, $ISSUER/resource/.well-known/oauth-protected-resource, returns 404.

Why it matters: a document's address is fixed by the identifier it describes, so a document can speak only for the resource whose address it sits under.

  1. Change the resource name in the portal and fetch the document again, allowing for caching. It follows the tenant's configuration.

Do today

  1. Confirm what is missing today:

curl -s -o /dev/null -w '%{http_code}\n' "$ISSUER/.well-known/oauth-protected-resource/resource"
curl -s -i "$ISSUER/oidc/userinfo" | grep -i www-authenticate
curl -s "$ISSUER/.well-known/oauth-authorization-server" | jq .protected_resources

The first prints 404. UserInfo, a real protected resource on your tenant, answers a request without a token with a plain WWW-Authenticate: Bearer and no resource_metadata. The last prints null.

  1. Stand up a stand-in API.

Simulation. your tenant has no protected resource metadata yet (G3), so this local server plays an API at http://127.0.0.1:8790/api. It names your real Lab Photos issuer.

Save as fixture.mjs and run node fixture.mjs in its own terminal:

import http from 'node:http';
const origin = 'http://127.0.0.1:8790', as = process.env.AS ?? process.env.ISSUER;
const doc = {resource: `${origin}/api`, authorization_servers: [as], scopes_supported: ['photos.read'],
  bearer_methods_supported: ['header'], resource_name: 'Lab photo API (fixture)'};
http.createServer((req, res) => {
  if (req.url === '/.well-known/oauth-protected-resource/api') return res.writeHead(200, {'Content-Type': 'application/json'}).end(JSON.stringify(doc));
  // /other is a different address whose challenge points at /api's document.
  if (req.url === '/api' || req.url === '/other')
    return res.writeHead(401, {'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/api"`}).end();
  res.writeHead(404).end();
}).listen(8790, '127.0.0.1');
  1. Write the client side for real. Save as find-api.mjs; the next two labs extend and test it:

// node find-api.mjs URL: protected resource discovery with the lesson's checks
import { lookup } from 'node:dns/promises';
import net from 'node:net';
const trusted = (process.env.TRUSTED_ISSUERS ?? '').split(' ').filter(Boolean);   // the client's own policy
const fixture = process.env.FIXTURE_ORIGIN;                                         // Simulation only: the local fixture
const stop = (reason, fields = {}) => { console.error(JSON.stringify({message: 'Discovery stopped', stage: 'discovery', reason, ...fields})); process.exit(1); };
const internal = ip => net.isIPv4(ip) ? /^(0|10|127)\.|^169\.254\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\./.test(ip) : /^(::1?$|f[cd]|fe[89ab])/i.test(ip);
async function get(address) {
  const url = new URL(address);
  if (!(fixture && url.origin === fixture)) {
    if (url.protocol !== 'https:') stop('not_https', {address});
    if (internal((await lookup(url.hostname)).address)) stop('internal_address', {address});
  }
  return fetch(address, {redirect: 'manual', signal: AbortSignal.timeout(5000)});
}
const wellKnown = id => { const u = new URL(id); return `${u.origin}/.well-known/oauth-protected-resource${u.pathname.replace(/\/$/, '')}`; };
const called = process.argv[2];                                                     // exactly as entered and stored
const pointer = /resource_metadata="([^"]+)"/.exec((await get(called)).headers.get('www-authenticate') ?? '')?.[1];
const response = await get(pointer ?? wellKnown(called));
if (response.status !== 200) stop('metadata_unavailable');
const doc = await response.json();
if (doc.resource !== called) stop('resource_mismatch', {called, resource: doc.resource});
const issuer = (doc.authorization_servers ?? []).find(candidate => trusted.includes(candidate));
if (!issuer) stop('no_trusted_authorization_server', {listed: doc.authorization_servers ?? []});
console.log(JSON.stringify({message: 'Resource discovered', resource: doc.resource, issuer, scopes: doc.scopes_supported ?? [], via: pointer ? 'challenge' : 'well-known'}));
  1. Run it:

FIXTURE_ORIGIN=http://127.0.0.1:8790 TRUSTED_ISSUERS="$ISSUER" node find-api.mjs http://127.0.0.1:8790/api

It prints "issuer": "<your ISSUER>" and "via": "challenge". The authorization server it found is real; only the API is the fixture.

  1. See the insertion rule in the client: node -e 'const u = new URL(process.argv[1]); console.log(${u.origin}/.well-known/oauth-protected-resource${u.pathname})' "$ISSUER/resource" prints the address the planned step 2 will fetch.

Check your work

There are no automated checks while this lab is planned. For Do today, your terminal shows 404, a plain Bearer challenge from UserInfo, null for protected_resources, and one discovered resource from the fixture.

Cleanup

Keep fixture.mjs and find-api.mjs for the next two labs. Stop the fixture when you finish the track.

Missing infrastructure

  • G3 Sample protected resource API: the sample photo API at $ISSUER/resource, its RFC 9728 document, resource_metadata in its challenges, and protected_resources in the authorization server metadata.

  • G55 Tenant resource (API) registry, where the sample API's name, scopes and documentation are configured, and from which further APIs could later publish documents.

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