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.
- G3 Sample protected resource API; no RFC 9728 protected resource metadata
- G55 Tenant resource (API) registry
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
In OAuth > Resources > Sample photo API (planned), turn it on and set the name "Lab photo API", the scope
photos.readand the documentation addresshttps://photos.lab.example/developers. Audit recordstenant.oauth.resources.update(planned event name).
Steps
Call the address without a token:
GET$ISSUER/resource
Open in console
GET $ISSUER/resourceThe 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.
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.
Build the address yourself from the identifier with the insertion rule: the well-known name goes at the root and the path
/resourcefollows it. The appended form,$ISSUER/resource/.well-known/oauth-protected-resource, returns404.
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.
Change the resource name in the portal and fetch the document again, allowing for caching. It follows the tenant's configuration.
Do today
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.
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');
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'}));
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.
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_metadatain its challenges, andprotected_resourcesin 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.