Token Exchange (On-Behalf-Of): Swapping Tokens for Downstream APIs
Why resource binding alone is not enough #
v09 binds each access token to a named API at mint time. The client sends resource on authorize and token; the auth server sets JWT aud (or introspection aud) to that URI. Gated routes on the resource server reject tokens bound to the wrong API.
That fixes which API may accept a given token. It does not fix who may present the token, or how a middle tier gets a downstream token without asking the user to log in again.
In v09, if the user logs in for Resource A:
GET /api/resource-awith the user token returns 200GET /api/resource-bwith the same user token returns 401
That is correct for audience binding. An agent or BFF sitting between the user and multiple APIs often needs a different token: same user (sub), different aud. The user already consented at login, so the middle service should not replay the user’s Resource A token against Resource B.
What the middle tier is (BFF and agent) #
A BFF (Backend for Frontend) is a server built for one specific UI. The browser or mobile app talks only to the BFF; the BFF holds secrets, runs OAuth, and calls downstream APIs. An agent is the same architectural slot for a different caller: an autonomous tool, workflow, or LLM runtime that acts on behalf of the user without a human clicking through every API.
| BFF | Agent | |
|---|---|---|
| Typical caller | React SPA, iOS app, Next.js page | AI assistant, Zapier workflow, background job |
| Who drives the session | Human in a browser or app | Tool chain or scheduled task |
| Why it needs token exchange | UI logged in for one API; BFF must call others server-side | User delegated once; agent must call APIs the user’s token was not minted for |
Real-world BFF examples:
- Shopify storefront: The checkout page does not call inventory, tax, payments, and shipping APIs directly from the browser. A Node or Rails BFF aggregates them, keeps
CLIENT_SECREToff the client, and attaches the right token per downstream service. - Netflix (pattern originator): Separate BFFs per device (TV, phone, web) each shape responses and auth for that client while shared microservices stay generic.
- Next.js API routes: A React app on
app.example.comcalls/api/profileon the same origin. That route is the BFF: it uses the user’s session, exchanges or refreshes tokens server-side, then calls internal HR or billing APIs the SPA never sees.
Real-world agent examples:
- Microsoft 365 Copilot: User signs into Outlook; Copilot needs Microsoft Graph calendar or SharePoint with a token scoped for those resources, not whatever the mail client received at login. Azure AD’s on-behalf-of (OBO) flow is token exchange under another name (Microsoft identity platform OBO).
- Salesforce Einstein / CRM agents: An agent reads a support case (API A) and updates a record (API B). Replaying the case-scoped user token against the write API fails audience or scope checks; exchange mints a token fit for the write.
- Zapier or Make: User connects Google and Slack once; the automation runtime holds delegated access and calls each API with credentials appropriate to that step. That is conceptually the same middle tier as v10’s
:25003agent.
In this lab, the process on :25003 is labeled agent, but you can read it as a minimal BFF. It never serves HTML to end users; it only accepts a subject token and returns a downstream token. Production BFFs usually do more (aggregate JSON, cache, rate-limit), but the OAuth problem is identical: the middle service needs its own client identity and a controlled way to obtain tokens for APIs the user’s original token cannot access.
RFC 8693: OAuth 2.0 Token Exchange adds a token-exchange grant on POST /token. A client (here, the agent) presents a subject_token (the user’s access token) and receives a new access token, typically with a tighter or different audience, scope, or delegation record.
| Concern | v09 | v10 (RFC 8693) |
|---|---|---|
| Token bound to one API? | Yes (aud = resource URI) |
Same; exchanged token also bound via resource |
| Middle service calls downstream API? | Must reuse user token (wrong aud returns 401) |
Agent exchanges user token for downstream token |
| Who acts on behalf of whom? | Implicit (client holds user token) | Optional act claim names the agent |
New grant on /token? |
authorization_code, refresh_token |
+ urn:ietf:params:oauth:grant-type:token-exchange |
| Processes | 3 (:25000 / :25001 / :25002) |
4 (adds agent middle service on :25003) |
What token exchange is #
Token exchange is a grant type, not a redirect flow. The agent authenticates as itself (demo-agent / agent-secret), posts to the auth server’s token endpoint, and supplies:
grant_type=urn:ietf:params:oauth:grant-type:token-exchangesubject_token: the user’s access token from the normal OIDC loginsubject_token_type=urn:ietf:params:oauth:token-type:access_tokenresource: downstream API URI (RFC 8707 still applies)
The auth server validates the subject token, applies policy (lab: allow A to B only), mints a new access token with aud set to the requested resource, and optionally records the agent in an act claim.
The runnable implementation lives at versions/v10-token-exchange/. v09 behavior is intact; v10 adds the agent process and token-exchange grant on top.
Example: agent needs calendar after user signed in for email #
Setup: User completes OIDC login in the client app for api.example.com/email. The client holds an access token with aud = email API.
What breaks without token exchange:
- An agent that summarizes email and updates calendar cannot call
api.example.com/calendarwith the email-scoped token. Resource B rejects it (v09 demo). - Forcing a second browser login for calendar is poor UX and does not model delegation.
- Passing the user’s email token to the agent and letting it call calendar is a confused deputy: the token is not minted for calendar.
What v10 fixes: The agent presents the user’s token to the IdP via token exchange, requests resource=https://api.example.com/calendar, receives a new token with aud = calendar, and calls Resource B. The resource server still checks audience; the exchange is the controlled way to mint the right aud.
Four programs, four roles #
| Program | Port | Keeps from v09 | Changes |
|---|---|---|---|
| Auth server (OpenID Provider) | :25000 |
OAuth + OIDC + resource indicators | Token-exchange grant on POST /token; agent_clients registry |
| Resource server | :25002 |
Gated A/B + ungated /api/me |
Surfaces act in /debug/state last_validation when validating OBO tokens |
| Client app | :25001 |
Resource picker login; binding matrix | Profile OBO section; POST /exchange-for-b |
| Agent (middle service) | :25003 |
(new) | POST /exchange, then auth server token exchange |
OIDC (id_token, UserInfo, JWKS) is unchanged. v10 only adds delegation mechanics on access tokens.
subject_token vs resource vs act
#
subject_token |
resource (requested) |
act (optional JWT claim) |
|
|---|---|---|---|
| Purpose | Proof of user delegation | Which API the exchanged token is for | Which service performed the exchange |
| Example | User’s opaque or JWT access token | http://localhost:25002/api/resource-b |
{"sub": "demo-agent"} |
| Set by | Client login (v09) | Agent on exchange request | Auth server when minting exchanged token |
| Checked by | Auth server validates before mint | Auth server allowlist; resource server on use | Resource server / logs (optional in lab) |
scope on the exchanged token can mirror the subject token’s scope in this lab; production policies often narrow it.
What v10 changes on top of v09 #
v10 does not add a new browser redirect. It adds a fourth process and a new branch on POST /token.
Shared constants #
shared/token_exchange.py holds RFC 8693 URNs used by the auth server and agent:
GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"
TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
What is a URN? #
A URN (Uniform Resource Name) is a kind of identifier string. It is not a URL you fetch, but a stable, globally unique name for a type or concept. The format looks like:
urn:ietf:params:oauth:grant-type:token-exchange
| Part | Meaning |
|---|---|
urn: |
This is a URN, not a web address |
ietf:params:oauth: |
IETF-registered OAuth parameter namespace |
grant-type:token-exchange |
The specific thing being named |
For well-known grants, plain strings suffice: grant_type=authorization_code, grant_type=refresh_token. Token exchange is an extension. If every vendor invented its own short name (obo, token_swap, exchange), servers and clients would not interoperate. RFC 8693 registers official URNs so every implementation uses the same string for the same meaning.
In v10, those registered names appear on POST /token:
grant_type=urn:ietf:params:oauth:grant-type:token-exchange(selects the exchange handler, not authorization code or refresh)subject_token_type=urn:ietf:params:oauth:token-type:access_token(declares that the subject token is an access token)
The auth server matches these strings and routes to _handle_token_exchange() instead of _handle_authorization_code().
URN vs URL vs URI: A URL locates something on the network (http://localhost:25002/api/resource-b); you can request it. A URN names something (urn:ietf:params:oauth:grant-type:token-exchange); you compare it as a label, you do not HTTP GET it. URI is the umbrella term; both URLs and URNs are URIs. In v09, resource=http://localhost:25002/api/resource-a is a URL (which API the token is for). In v10, grant_type=urn:... is a URN (which OAuth operation you are requesting). Different roles, same idea: a standardized string everyone agrees on.
Where the standard defines these strings #
The URNs in shared/token_exchange.py are not lab inventions. They are copied from RFC 8693: OAuth 2.0 Token Exchange, which defines the grant, the request/response parameters, and the registered identifier strings.
| Constant in the lab | Standard | Where |
|---|---|---|
GRANT_TYPE_TOKEN_EXCHANGE |
RFC 8693 | §2: urn:ietf:params:oauth:grant-type:token-exchange |
TOKEN_TYPE_ACCESS_TOKEN |
RFC 8693 | §2.1: subject_token_type / requested_token_type |
TOKEN_TYPE_REFRESH_TOKEN |
RFC 8693 | §2.1 and IANA registry |
TOKEN_TYPE_ID_TOKEN |
RFC 8693 | §2.1: when an ID token is exchanged |
RFC 8693 registers names in the IETF OAuth URN namespaces maintained by IANA:
- OAuth Grant Type URIs:
token-exchangeappears asurn:ietf:params:oauth:grant-type:token-exchange - OAuth Token Type URIs:
access_token,refresh_token,id_token, and others
RFC 6749 (OAuth 2.0) uses short grant names (authorization_code, refresh_token) because they were in the base spec from day one. Extensions like token exchange register new values in IANA so vendors do not collide. That is why token exchange uses the long URN form instead of a new short string like token_exchange.
Related standards used together in v10 but not the source of these URNs:
| Topic | Standard |
|---|---|
Token endpoint, grant_type parameter |
RFC 6749 |
Requested downstream API (resource on exchange) |
RFC 8707 |
Optional act claim on exchanged JWTs |
RFC 8693 §4.1 |
Why agent and auth server share one module #
The agent and auth server are separate processes, but they speak one protocol on the wire. The agent sends URNs in the POST /token body; the auth server compares those strings byte-for-byte to route the request:
Agent -> grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token_type=urn:ietf:params:oauth:token-type:access_token
Auth -> if grant_type == GRANT_TYPE_TOKEN_EXCHANGE:
_handle_token_exchange()
A typo on either side yields unsupported_grant_type; there is no fuzzy matching. The same pattern as v09: shared/resource_indicators.py keeps Resource A/B URIs aligned across three apps; shared/token_exchange.py keeps grant and token-type URNs aligned between agent and auth server (and discovery in oidc.py).
Belongs in shared/token_exchange.py |
Does not belong there |
|---|---|
| Grant/token type URNs (fixed by RFC 8693 + IANA) | demo-agent / agent-secret (runtime credentials in .env) |
| Same on every compliant deployment | subject_token (unique per user session) |
resource URL (from shared/resource_indicators.py) |
If discovery advertises a grant URN from this module but token.py hard-codes a different string, clients trust metadata the server does not implement. One shared source avoids that drift.
Auth server: exchange grant #
Registry. auth-server/storage/memory.py adds agent_clients for demo-agent (separate from web clients and introspection service_clients).
Per route:
| File | What it does |
|---|---|
token.py |
Routes grant_type=urn:ietf:params:oauth:grant-type:token-exchange to _handle_token_exchange() |
introspect.py |
Returns act when present on exchanged tokens (opaque store and JWT) |
oidc.py |
Lists token-exchange grant in grant_types_supported |
_handle_token_exchange() flow:
- Authenticate the agent.
_authenticate_agent_client()checksclient_id/client_secretagainstagent_clients. Failure returnsinvalid_client. - Validate inputs. Require
subject_token,subject_token_type=urn:ietf:params:oauth:token-type:access_token, andresourceoraudience. - Validate subject token. Reuse
_active_from_memory()/_active_from_jwt()from introspection. Invalid or expired returnsinvalid_grant. - Apply lab policy. Requested
resourcemust be inallowed_resources(); subjectaudmust be Resource A; requested target must be Resource B. Anything else returnsinvalid_grant. - Mint downstream token.
_mint_access_token()withsubfrom the subject token,aud= requested resource, andact={"sub": agent_client_id}. Exchanged tokens do not get refresh tokens. - Respond.
_token_exchange_response()returnsaccess_token,token_type,expires_in, andissued_token_typeper RFC 8693 §2.2.1.
Lab policy (enforced in code): Exchange only when subject token aud is Resource A and requested resource is Resource B. Reject unknown subject tokens, expired tokens, same-resource exchange (A to A), and reverse exchange (B to A).
Exchange request (curl): After login for Resource A, copy session.access_token from client debug state, then:
export SUBJECT_TOKEN='PASTE_USER_ACCESS_TOKEN'
curl -s -X POST http://localhost:25000/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d client_id=demo-agent \
-d client_secret=agent-secret \
-d subject_token="$SUBJECT_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d resource=http://localhost:25002/api/resource-b
Expected response:
{
"access_token": "...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 60
}
Agent: middle service #
New folder versions/v10-token-exchange/agent/:
| Route | Purpose |
|---|---|
POST /exchange |
Accept subject_token + target resource; call auth server with agent credentials |
GET /demo |
Standalone OBO matrix UI (paste subject token, exchange, probe APIs) |
POST /demo |
Same page; form submit runs exchange and renders the 2x2 matrix |
GET /debug/state |
Last exchange cache (storage.exchanged_tokens.last) |
The agent is the acting party in RFC 8693 terms. It never exposes agent-secret to the browser; the client calls the agent server-side from POST /exchange-for-b.
Shared helper. _exchange_with_auth_server() posts to the auth server token endpoint with agent credentials and caches successful results in memory.exchanged_tokens["last"]. Both POST /exchange and POST /demo use it.
Agent exchange (curl): After login for Resource A, copy session.access_token from http://localhost:25001/debug/state?format=json (use the access token, not id_token). Then:
export SUBJECT_TOKEN='PASTE_ACCESS_TOKEN_HERE'
curl -s -X POST http://localhost:25003/exchange \
-d subject_token="$SUBJECT_TOKEN" \
-d resource=http://localhost:25002/api/resource-b
On success you get a new access_token with aud = Resource B. Confirm the agent cache at http://localhost:25003/debug/state?format=json (storage.exchanged_tokens.last).
Standalone OBO demo (/demo): Open http://localhost:25003/demo. Paste the user’s access token, click Exchange for Resource B and show matrix. The page probes both gated APIs with each token:
| Token | /api/resource-a |
/api/resource-b |
|---|---|---|
| Subject (user, bound to A) | 200 | 401 |
| Exchanged (agent OBO, bound to B) | 401 | 200 |
This mirrors the client profile matrix but runs entirely on the agent. It is useful when debugging exchange without the client session.
Client: profile OBO demo #
After login for Resource A, /profile shows:
- Resource binding (v09): user token yields A 200, B 401
- On-Behalf-Of (v10): button triggers exchange via agent; exchanged token should yield B 200
POST /exchange-for-b (server-side, no agent secret in the browser):
- Read
session.access_token(refresh silently if needed). POSTto agent/exchangewithsubject_token+resource_b_indicator().- On success, store
session["exchanged_access_token"]; on failure, setsession["exchange_error"]and redirect back to profile. - Logout clears
exchanged_access_tokenandexchange_error.
/profile OBO section. After exchange, the route calls GET /api/resource-b with the exchanged token and passes resource_b_obo to the template. Expected: 200 OK in the OBO section while the user-token row for Resource B still shows 401.
Resource server: act in validation debug
#
Gated routes are unchanged from v09. They still call validate_bearer_token(token, expected_resource=...). v10 adds one optional debug surface:
When introspection or JWT validation returns an act claim (RFC 8693 actor), token_validation.py includes it in the validation result. After an OBO token hits a gated route, check http://localhost:25002/debug/state?format=json:
{
"last_validation": {
"mode": "introspection",
"result": {
"user_id": "alice",
"act": { "sub": "demo-agent" }
},
"error": null
}
}
This confirms the resource server saw the delegation record. The happy path does not require route changes.
End-to-end flow (login A, exchange for B) #
%%{init: {'theme': 'base', 'themeVariables': {
'actorLineColor': '#1e293b',
'actorBorder': '#334155',
'signalColor': '#1e293b',
'lineColor': '#1e293b',
'noteBorderColor': '#b45309',
'noteBkgColor': '#fef9c3'
}}}%%
sequenceDiagram
participant Browser as Browser
box rgba(5,80,174,0.18) Client :25001
participant ClientApp as OAuth Client
end
box rgba(120,53,15,0.18) Agent :25003
participant Agent as Middle Service
end
box rgba(196,30,58,0.18) OpenID Provider :25000
participant AuthServer as Auth Server
end
box rgba(22,101,52,0.18) Resource Server :25002
participant ResourceServer as Resource Server
end
Browser->>ClientApp: GET /login/resource-a
Note over ClientApp,AuthServer: v09 authorize + token (resource=A)
ClientApp-->>Browser: session user access_token aud=A
Browser->>ClientApp: GET /profile
ClientApp->>ResourceServer: GET /api/resource-b Bearer user_token
Note over ResourceServer: aud mismatch
ResourceServer-->>ClientApp: 401
Browser->>ClientApp: POST /exchange-for-b
ClientApp->>Agent: POST /exchange subject_token=user_token resource=B
Agent->>AuthServer: POST /token token-exchange grant
Note over AuthServer: validate subject_token aud=A
Note over AuthServer: mint new token aud=B act=demo-agent
AuthServer-->>Agent: exchanged access_token
Agent-->>ClientApp: exchanged access_token
Note over ClientApp: store in session
ClientApp->>ResourceServer: GET /api/resource-b Bearer exchanged_token
Note over ResourceServer: aud matches RESOURCE_B_INDICATOR
ResourceServer-->>ClientApp: 200
ClientApp->>ResourceServer: GET /api/resource-b Bearer user_token
Note over ResourceServer: still aud=A
ResourceServer-->>ClientApp: 401
Configuration (Optional) #
v10 adds agent settings to .env.example (copy into agent/, auth-server/, and client/):
AGENT_SERVER_URL=http://localhost:25003
AGENT_SERVER_PORT=25003
AGENT_CLIENT_ID=demo-agent
AGENT_CLIENT_SECRET=agent-secret
Resource indicator URIs (RESOURCE_A_INDICATOR, RESOURCE_B_INDICATOR) remain as in v09.
How to run it #
Run four terminals from github.com/sauvikbiswas/oauth-lab:
Terminal 1: auth server (:25000)
cd versions/v10-token-exchange/auth-server
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../../../.env.example .env
python3 app.py
Terminal 2: resource server (:25002)
cd versions/v10-token-exchange/resource-server
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../../../.env.example .env
python3 app.py
Terminal 3: client app (:25001)
cd versions/v10-token-exchange/client
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../../../.env.example .env
python3 app.py
Terminal 4: agent (:25003)
cd versions/v10-token-exchange/agent
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../../../.env.example .env
python3 app.py
Open http://localhost:25001/login/resource-a, complete login, visit /profile. Click Exchange via agent for Resource B; the OBO section should show 200 on Resource B with the exchanged token while the user-token row still shows 401 on B.
Alternative paths without the client button:
- Agent UI: http://localhost:25003/demo: paste subject token, submit form
- Agent curl:
POST /exchangesmoke test above - Auth server curl: direct
POST /tokenwith token-exchange grant
Manual checks #
Should succeed:
| Test | How | Expected |
|---|---|---|
| v09 baseline | Login for A; profile matrix | A 200, B 401 with user token |
| Token exchange | curl or agent /exchange |
New token with aud = Resource B |
| OBO profile | Exchange then reload /profile |
OBO section B 200 |
Agent /demo matrix |
Paste token at :25003/demo |
Subject A 200 / B 401; exchanged A 401 / B 200 |
| User token unchanged | Same user token on B after exchange | Still 401 |
act in resource debug |
Call Resource B with exchanged token; check :25002/debug/state |
last_validation.result.act.sub = demo-agent |
| Discovery | curl /.well-known/openid-configuration |
grant_types_supported includes token-exchange URN |
Should fail:
| Test | How | Expected |
|---|---|---|
| Exchange without agent auth | Omit client_secret |
invalid_client |
| Exchange with invalid subject | Garbage subject_token |
invalid_grant |
| Exchange with expired subject | Wait past 60s TTL, then exchange | invalid_grant |
| Exchange A to A (policy forbids) | Request same resource as subject aud |
invalid_grant |
| Exchanged token on A | Use B token on /api/resource-a |
401 |
Unchanged from v09:
| Test | Expected |
|---|---|
id_token / UserInfo |
OIDC path unchanged |
Refresh with resource |
Re-binds same resource URI |
/api/me ungated |
200 regardless of aud binding |
Cast of characters (v10 additions) #
| Name | Who creates it | Where it travels | What it does |
|---|---|---|---|
grant_type (token-exchange URN) |
Agent | POST /token body |
Selects RFC 8693 exchange handler |
subject_token |
User login (v09) | Agent to auth server | User access token to delegate |
subject_token_type |
Agent | POST /token body |
Declares subject token format |
demo-agent |
Config / agent_clients |
Agent to auth server | Acting party client credentials |
act |
Auth server at exchange | JWT or introspection | Names agent that obtained delegation |
Exchanged access_token |
Auth server | Agent to client to Resource B | Downstream token with new aud |
PKCE, resource on authorize, JWKS, refresh tokens, and UserInfo behave as in v09.
Pitfalls #
- Reusing the user token downstream: Token exchange exists because audience-bound tokens should not be replayed at the wrong API. The agent must call exchange, not forward the user’s token.
- Agent secret in the browser: The client should call the agent server-side; never embed
AGENT_CLIENT_SECRETin front-end code. - Skipping subject validation: The auth server must verify the subject token (signature, expiry,
aud) before minting a new token. Otherwise any stolen token becomes a delegation primitive. - Over-broad exchange policy: Production IdPs restrict which agents may exchange which subject tokens for which audiences. The lab uses a simple A to B rule; real systems use policy engines.
- Confused deputy: An agent with a valid user token for A should not receive tokens for arbitrary resources without policy checks. That is the v10 threat model Intermission 2 expands on.
id_tokenis not a subject token here: Exchange uses access tokens assubject_token. OIDC identity tokens stay on the client for login proof.- Shell one-liner trap when curling
/exchange: WritingSUBJECT_TOKEN=... curl ... -d subject_token="$SUBJECT_TOKEN"does not work. TheVAR=valueprefix only exportsVARto thecurlprocess; the shell expands"$SUBJECT_TOKEN"in the parent shell beforecurlruns, where it is still empty. You getsubject_token is requiredeven though you typed the token on the same line. Useexport SUBJECT_TOKEN=...on its own line first, assign then curl on the next lines, or paste the token inline in-d subject_token=.... /debug/stateon the client:sessionvsstorage: A successful login puts the live token insession.access_token. Keys understorage.access_tokensare a client-side debug mirror from the callback, not proof you are still logged in. If"session": {}, log in again (logout, expired cookie, different browser, or client restart). Do not reuse an old key fromstorage.access_tokenswithout checkingsession.- Subject token TTL: Access tokens in this lab expire in 60 seconds. Copy from
/debug/stateand run exchange immediately. An expired subject token returnsinvalid_grantfrom the auth server.
What next? #
v10 adds On-Behalf-Of delegation on top of v09 resource binding. To see what changed, diff the adjacent snapshots:
diff -ru versions/v09-resource-indicators versions/v10-token-exchange
Next up: Intermission 2 (Agents, consent, and the MCP authorization model), then v11 (MCP-style agent authorization). Intermission 2 maps the v08-v10 artifacts to the agent threat model and the MCP authorization spec. It adds no new code.
Further reading #
- RFC 8693: OAuth 2.0 Token Exchange (defines grant and token-type URNs used in
shared/token_exchange.py) - IANA OAuth Grant Type URIs
- IANA OAuth Token Type URIs
- RFC 8707: Resource Indicators (requested
resourceon exchange) - RFC 7662: Token Introspection (
aud, optionalact)