Skip to main content

Path D: token exchange

RFC 8693's grant runs against a tenant of its own, exdoc, so the clients this exercises — one registered for the grant, one registered for it but not permitted to impersonate, and one not registered for it at all — do not crowd demo's. Three clients and two users:

odudu seed \
--tenant exdoc --client exdoc-spa \
--redirect-uri http://localhost:8080/callback \
--user ada --password correct-horse-battery --email ada@example.com
odudu seed user --tenant exdoc --username bot --password correct-horse-battery-2

odudu seed client \
--tenant exdoc --client-id exdoc-exchange --client-secret exdoc-exchange-secret \
--redirect-uri http://localhost:8080/callback \
--grant-type authorization_code --grant-type refresh_token \
--grant-type urn:ietf:params:oauth:grant-type:token-exchange
odudu seed client \
--tenant exdoc --client-id exdoc-exchange-restricted --client-secret exdoc-exchange-restricted-secret \
--redirect-uri http://localhost:8080/callback \
--grant-type authorization_code --grant-type refresh_token \
--grant-type urn:ietf:params:oauth:grant-type:token-exchange
odudu seed client \
--tenant exdoc --client-id exdoc-no-exchange --client-secret exdoc-no-exchange-secret \
--redirect-uri http://localhost:8080/callback \
--grant-type authorization_code --grant-type refresh_token

seed client has no --audience or impersonation flag (see the roadmap), so exdoc-exchange and exdoc-exchange-restricted both get an audience set directly, and only the first gets token_exchange_impersonation_allowed flipped on:

client_id is unique only within a tenant, so both updates below are scoped to exdoc by name, not by client_id alone:

docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE client_oidc_config
SET audiences = ARRAY['https://api.exdoc.example'], token_exchange_impersonation_allowed = true
FROM clients
WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'exdoc-exchange'
AND client_oidc_config.tenant_id = (SELECT id FROM tenants WHERE name = 'exdoc');

UPDATE client_oidc_config
SET audiences = ARRAY['https://api.exdoc.example']
FROM clients
WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'exdoc-exchange-restricted'
AND client_oidc_config.tenant_id = (SELECT id FROM tenants WHERE name = 'exdoc');
"

Signing in as ada and, separately, as bot — the same PKCE flow as Path A, against exdoc-spa in exdoc rather than demo-spa in demo, once per user — leaves $SUBJECT_TOKEN (ada's access token) and $ACTOR_TOKEN (bot's).

A delegation, with act naming the actor​

An actor_token alongside the subject_token delegates: the issued token keeps the subject's own identity and adds who is acting for them.

curl -sS -u exdoc-exchange:exdoc-exchange-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$SUBJECT_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode "actor_token=$ACTOR_TOKEN" \
--data-urlencode 'actor_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'resource=https://api.exdoc.example' \
'http://localhost:3000/tenants/exdoc/protocol/openid-connect/token'
{
"access_token": "eyJhbGciOiJSUzI1NiIs…",
"token_type": "Bearer",
"expires_in": 292,
"scope": "openid",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token"
}

Decoded, never verified (payload only):

{
"iss": "http://localhost:3000/tenants/exdoc",
"sub": "01a0cee5-c812-…",
"aud": ["https://api.exdoc.example", "http://localhost:3000/tenants/exdoc"],
"client_id": "exdoc-exchange",
"scope": "openid",
"iat": 1790177628,
"exp": 1790177920,
"jti": "01a0cee6-d09f-7cce-9e66-e7458d2c9110",
"sid": "01a0cee6-b3d6-…",
"grant_id": "01a0cee6-d09f-7cce-9e66-e7448248e095",
"act": { "sub": "01a0cee6-8ab8-…" }
}

sub is still ada's own subject id — SUBJECT_TOKEN's — and act.sub is bot's: the requesting client, exdoc-exchange, delegated to bot, and the issued token says so rather than becoming a token for bot outright. aud carries both the named resource and the tenant's own issuer, the same narrowing resource applies at every other grant (see above's own resource bullet). jti and grant_id are shown in full rather than truncated, the same coincidence step 4 already names.

Impersonation, gated by a column no flag sets​

Omitting actor_token asks to impersonate the subject outright — the issued token names no actor at all. That is the branch token_exchange_impersonation_allowed gates, and the column is the only thing distinguishing the client below that gets refused from the one that does not:

docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
SELECT clients.client_id, client_oidc_config.token_exchange_impersonation_allowed
FROM client_oidc_config JOIN clients ON clients.id = client_oidc_config.client_id
WHERE clients.client_id IN ('exdoc-exchange', 'exdoc-exchange-restricted')
ORDER BY clients.client_id;
"
client_id | token_exchange_impersonation_allowed
---------------------------+--------------------------------------
exdoc-exchange | t
exdoc-exchange-restricted | f
(2 rows)

exdoc-exchange-restricted is registered for the grant — the allowlist below is a separate check, already satisfied — but is refused before its subject_token is ever read, so a garbage subject_token refuses exactly the same way a live one would (held by the test ODUDU-TOKEN-EXCHANGE-ORACLE-01). x-request-id, Date and the keep-alive headers are omitted below, as elsewhere in this document:

curl -sS -D - -u exdoc-exchange-restricted:exdoc-exchange-restricted-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$SUBJECT_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
'http://localhost:3000/tenants/exdoc/protocol/openid-connect/token'
HTTP/1.1 400 Bad Request
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"error":"unauthorized_client","error_description":"This client may not use this grant type."}

The identical request against exdoc-exchange — the client the column above shows permitted — succeeds, and the issued token carries no act at all:

curl -sS -D - -u exdoc-exchange:exdoc-exchange-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$SUBJECT_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
'http://localhost:3000/tenants/exdoc/protocol/openid-connect/token'
HTTP/1.1 200 OK
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"access_token":"eyJhbGciOiJSUzI1NiIs…","token_type":"Bearer","expires_in":291,"scope":"openid","issued_token_type":"urn:ietf:params:oauth:token-type:access_token"}

Requesting a refresh token back​

requested_token_type picks the issued shape. Asking for a refresh token is the one that surprises a reader used to the other three grants: the opaque token still travels in the response's access_token member, per §2.2.1, with token_type reading N_A rather than Bearer because the issued credential is not one:

curl -sS -u exdoc-exchange:exdoc-exchange-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$SUBJECT_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:refresh_token' \
'http://localhost:3000/tenants/exdoc/protocol/openid-connect/token'
{
"access_token": "XeEBhyNc7bBbT3mpNxgf9…",
"token_type": "N_A",
"expires_in": 268,
"scope": "openid",
"issued_token_type": "urn:ietf:params:oauth:token-type:refresh_token"
}

The allowlist refusing an unregistered grant​

exdoc-no-exchange never named the grant at seed time:

docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
SELECT clients.client_id, client_oidc_config.grant_types
FROM client_oidc_config JOIN clients ON clients.id = client_oidc_config.client_id
WHERE clients.client_id = 'exdoc-no-exchange';
"
client_id | grant_types
-------------------+------------------------------------
exdoc-no-exchange | {authorization_code,refresh_token}
(1 row)
curl -sS -D - -u exdoc-no-exchange:exdoc-no-exchange-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$SUBJECT_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
'http://localhost:3000/tenants/exdoc/protocol/openid-connect/token'
HTTP/1.1 400 Bad Request
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"error":"unauthorized_client","error_description":"This client may not use this grant type."}

Byte-identical to the impersonation refusal above — both are unauthorized_client with nothing else to tell them apart, which is why each transcript above shows the column its own refusal turns on rather than asserting which check fired.

A client disabled after a token was issued to it​

Disabling a client is an admin API operation (the admin paths), not something this document's odudu seed walks through, but its effect on a token that is already live is a fact about /userinfo, /introspect and this grant, so it belongs here rather than there. A tenant of its own, disableddoc, with a public client that can both sign a user in and be disabled, and a confidential disableddoc-reader registered for the exchange grant — the resource server that introspects and exchanges the public client's tokens, kept enabled throughout so a refusal below can only be the disabled client's own doing:

odudu seed \
--tenant disableddoc --client disableddoc-spa \
--redirect-uri http://localhost:8080/callback \
--user ada --password correct-horse-battery --email ada@example.com

odudu seed client \
--tenant disableddoc --client-id disableddoc-reader --client-secret disableddoc-reader-secret \
--redirect-uri http://localhost:8080/callback \
--grant-type authorization_code --grant-type urn:ietf:params:oauth:grant-type:token-exchange

The top-level odudu seed has no --grant-type, --audience or impersonation flag at all (see the roadmap), so disableddoc-spa — created through it, not through seed client — gets the exchange grant and https://api.disableddoc.example named in its own audiences directly, the same way an id_token self-exchange needs token_exchange_impersonation_allowed on directly, too, since with no actor_token it is impersonating itself. disableddoc-reader gets the identical audiences entry, so it is entitled to introspect and exchange what disableddoc-spa mints, and impersonation on for the same reason, the way the section above turns it on for exdoc-exchange:

docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE client_oidc_config
SET audiences = ARRAY['https://api.disableddoc.example'],
grant_types = array_append(grant_types, 'urn:ietf:params:oauth:grant-type:token-exchange'),
token_exchange_impersonation_allowed = true
FROM clients
WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'disableddoc-spa'
AND client_oidc_config.tenant_id = (SELECT id FROM tenants WHERE name = 'disableddoc');

UPDATE client_oidc_config
SET audiences = ARRAY['https://api.disableddoc.example'],
token_exchange_impersonation_allowed = true
FROM clients
WHERE clients.id = client_oidc_config.client_id AND clients.client_id = 'disableddoc-reader'
AND client_oidc_config.tenant_id = (SELECT id FROM tenants WHERE name = 'disableddoc');
"

Signing in as ada — Path A's own flow, against disableddoc-spa in disableddoc, scope=openid offline_access and resource=https://api.disableddoc.example so the minted aud includes what disableddoc-reader is registered under — gives $ACCESS_TOKEN, $REFRESH_TOKEN and $ID_TOKEN from one grant and one session. While the client is still enabled, all five calls this section is about succeed. /userinfo:

curl -sS -D - -H "Authorization: Bearer $ACCESS_TOKEN" \
"http://localhost:3000/tenants/disableddoc/protocol/openid-connect/userinfo"
HTTP/1.1 200 OK
vary: Origin
content-type: application/json; charset=utf-8

{"sub":"01a0d45f-9986-77b6-835a-3913b7fc63b1"}

/introspect, called by disableddoc-reader rather than by disableddoc-spa itself — a caller distinct from the client under test, the same way the access-token and refresh-token exchanges below need one:

curl -sS -u disableddoc-reader:disableddoc-reader-secret \
-X POST "http://localhost:3000/tenants/disableddoc/protocol/openid-connect/token/introspect" \
--data-urlencode "token=$ACCESS_TOKEN"
{"active":true,"scope":"openid offline_access","client_id":"disableddoc-spa","sub":"01a0d45f-9986-77b6-835a-3913b7fc63b1","aud":["https://api.disableddoc.example","http://localhost:3000/tenants/disableddoc"],"token_type":"Bearer","exp":1790269746,"iat":1790269446}

The access token exchanged by disableddoc-reader:

curl -sS -D - -u disableddoc-reader:disableddoc-reader-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$ACCESS_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'resource=https://api.disableddoc.example' \
'http://localhost:3000/tenants/disableddoc/protocol/openid-connect/token'
HTTP/1.1 200 OK
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"access_token":"eyJhbGciOiJSUzI1NiIs…","token_type":"Bearer","expires_in":290,"scope":"openid offline_access","issued_token_type":"urn:ietf:params:oauth:token-type:access_token"}

The refresh token exchanged the same way:

curl -sS -D - -u disableddoc-reader:disableddoc-reader-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$REFRESH_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:refresh_token' \
--data-urlencode 'resource=https://api.disableddoc.example' \
'http://localhost:3000/tenants/disableddoc/protocol/openid-connect/token'
HTTP/1.1 200 OK
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"access_token":"eyJhbGciOiJSUzI1NiIs…","token_type":"Bearer","expires_in":300,"scope":"openid offline_access","issued_token_type":"urn:ietf:params:oauth:token-type:access_token"}

And, since an id_token's audience must name the requesting client (OIDC Core §3.1.2.2), a self-exchange of $ID_TOKEN — disableddoc-spa is public, so this reaches /token with no credential at all, naming only client_id in the body, the way Path A redeems a code:

curl -sS -D - \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'client_id=disableddoc-spa' \
--data-urlencode "subject_token=$ID_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:id_token' \
'http://localhost:3000/tenants/disableddoc/protocol/openid-connect/token'
HTTP/1.1 200 OK
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"access_token":"eyJhbGciOiJSUzI1NiIs…","token_type":"Bearer","expires_in":290,"scope":"","issued_token_type":"urn:ietf:params:oauth:token-type:access_token"}

Disabling disableddoc-spa — the precondition every refusal below depends on, shown rather than asserted, since an unrelated refusal (a dead session, a revoked grant) would read no differently:

docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE clients SET enabled = false WHERE client_id = 'disableddoc-spa'
AND tenant_id = (SELECT id FROM tenants WHERE name = 'disableddoc');
SELECT client_id, enabled FROM clients WHERE client_id = 'disableddoc-spa';
"
client_id | enabled
-----------------+---------
disableddoc-spa | f
(1 row)

None of $ACCESS_TOKEN, $REFRESH_TOKEN, $ID_TOKEN or the session behind them changed — every grant is still live and the session is still live. The identical five requests, unchanged, now all refuse. /userinfo:

HTTP/1.1 401 Unauthorized
vary: Origin
www-authenticate: Bearer realm="userinfo", error="invalid_token"
content-length: 0

/introspect:

{"active":false}

The access token exchange:

HTTP/1.1 400 Bad Request
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"error":"invalid_request","error_description":"The request is missing a parameter or has a wrong one."}

the refresh token exchange, identically:

curl -sS -D - -u disableddoc-reader:disableddoc-reader-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$REFRESH_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:refresh_token' \
--data-urlencode 'resource=https://api.disableddoc.example' \
'http://localhost:3000/tenants/disableddoc/protocol/openid-connect/token'
HTTP/1.1 400 Bad Request
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"error":"invalid_request","error_description":"The request is missing a parameter or has a wrong one."}

and the id_token self-exchange:

curl -sS -D - \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'client_id=disableddoc-spa' \
--data-urlencode "subject_token=$ID_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:id_token' \
'http://localhost:3000/tenants/disableddoc/protocol/openid-connect/token'
HTTP/1.1 401 Unauthorized
vary: Origin
www-authenticate: Basic realm="token"
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8

{"error":"invalid_client","error_description":"Client authentication failed."}

An id_token names no grant to revoke — resolveIdToken returns grantId: null — so exchanging one reads clients.enabled directly rather than through a grant, the one branch a revoked-grant check could never have reached. This transcript's last refusal cannot prove that read fired, though: an id_token's audience must name the requesting client, which makes this necessarily a self-exchange, and a disabled client can no longer authenticate at /token for any grant — verifyClientSecret already refuses a disabled client's own client_secret, private_key_jwt or tls_client_auth attempt, the same way The branches below documents for every other grant, and invalid_client above is that refusal, not resolveIdToken's. An integration test covers the id_token branch's own read directly, the way the impersonation refusal above covers unauthorized_client's own oracle: by calling resolveExchangeToken itself rather than through the client-authentication gate in front of it.

A subject disabled after a token was issued to it​

The same five calls again, for the other half of the same question: disabling a subject ends nothing it holds either — its grants, its session and its client are all untouched — so each of /userinfo, /introspect and the exchange grant reads the token's subject beside its client (subjectIsEnabled, the same predicate the refresh grant and the admin API ask). On a stack of its own, on port 3080, brought up from an empty volume. Its tenant subjdoc is set up exactly as disableddoc is above, under its own names — subjdoc-spa, subjdoc-reader with secret subjdoc-reader-secret, the audience https://api.subjdoc.example, and ada — and $ACCESS_TOKEN, $REFRESH_TOKEN and $ID_TOKEN come from one sign-in the same way. The Date, Connection and Keep-Alive lines are left out of each header block, and each issued access_token is cut after its first bytes.

While ada is enabled, all five succeed. /userinfo, /introspect, the access-token exchange, the refresh-token exchange and the id_token self-exchange, in that order:

curl -sS -D - -H "Authorization: Bearer $ACCESS_TOKEN" \
"http://localhost:3080/tenants/subjdoc/protocol/openid-connect/userinfo"
HTTP/1.1 200 OK
x-request-id: 01a0ed77-6862-70c5-a810-a69d1250e153
vary: Origin
content-type: application/json; charset=utf-8
content-length: 46

{"sub":"01a0ed76-cc3a-7f71-a4ac-2c62b3c966fc"}
curl -sS -u subjdoc-reader:subjdoc-reader-secret \
-X POST "http://localhost:3080/tenants/subjdoc/protocol/openid-connect/token/introspect" \
--data-urlencode "token=$ACCESS_TOKEN"
{"active":true,"scope":"openid offline_access","client_id":"subjdoc-spa","sub":"01a0ed76-cc3a-7f71-a4ac-2c62b3c966fc","aud":["https://api.subjdoc.example","http://localhost:3080/tenants/subjdoc"],"token_type":"Bearer","exp":1790690705,"iat":1790690405}
curl -sS -D - -u subjdoc-reader:subjdoc-reader-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$ACCESS_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'resource=https://api.subjdoc.example' \
'http://localhost:3080/tenants/subjdoc/protocol/openid-connect/token'
HTTP/1.1 200 OK
x-request-id: 01a0ed77-68f2-7692-b732-1bdd74b33fda
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 1082

{"access_token":"eyJhbGciOiJSUzI1NiI…","token_type":"Bearer","expires_in":284,"scope":"openid offline_access","issued_token_type":"urn:ietf:params:oauth:token-type:access_token"}
curl -sS -D - -u subjdoc-reader:subjdoc-reader-secret \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$REFRESH_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:refresh_token' \
--data-urlencode 'resource=https://api.subjdoc.example' \
'http://localhost:3080/tenants/subjdoc/protocol/openid-connect/token'
HTTP/1.1 200 OK
x-request-id: 01a0ed77-6943-7205-a3ad-1c3345ce296f
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 1082

{"access_token":"eyJhbGciOiJSUzI1NiI…","token_type":"Bearer","expires_in":300,"scope":"openid offline_access","issued_token_type":"urn:ietf:params:oauth:token-type:access_token"}
curl -sS -D - \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'client_id=subjdoc-spa' \
--data-urlencode "subject_token=$ID_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:id_token' \
'http://localhost:3080/tenants/subjdoc/protocol/openid-connect/token'
HTTP/1.1 200 OK
x-request-id: 01a0ed77-6980-7459-9d28-6a9447d257e4
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 1029

{"access_token":"eyJhbGciOiJSUzI1NiI…","token_type":"Bearer","expires_in":284,"scope":"","issued_token_type":"urn:ietf:params:oauth:token-type:access_token"}

Disabling ada — the precondition every refusal below depends on, shown rather than asserted; PATCH /admin/tenants/subjdoc/subjects/{id} with {"enabled":false} writes the same column:

docker compose -f infra/docker/compose.yaml exec -T postgres \
psql -U odudu -d odudu -c "
UPDATE subjects SET disabled_at = now() WHERE id = '01a0ed76-cc3a-7f71-a4ac-2c62b3c966fc';
SELECT s.id, u.username, s.disabled_at IS NOT NULL AS disabled FROM subjects s JOIN users u ON u.subject_id = s.id WHERE s.id = '01a0ed76-cc3a-7f71-a4ac-2c62b3c966fc';
"
id | username | disabled
--------------------------------------+----------+----------
01a0ed76-cc3a-7f71-a4ac-2c62b3c966fc | ada | t
(1 row)

The client is still enabled, the grant still live and the session still open. The identical five requests now refuse, each the way that door already refuses a revoked grant: /userinfo with RFC 6750 §3.1's invalid_token,

HTTP/1.1 401 Unauthorized
x-request-id: 01a0ed77-6a0e-7eaa-b525-1ca619886b84
vary: Origin
www-authenticate: Bearer realm="userinfo", error="invalid_token"
content-length: 0

/introspect with RFC 7662 §2.2's bare {"active":false},

{"active":false}

and each exchange with the invalid_request RFC 8693 §2.2.2 gives an unacceptable subject_token — the access token,

HTTP/1.1 400 Bad Request
x-request-id: 01a0ed77-6a54-7a80-bcf5-e0c47d10c6bb
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 104

{"error":"invalid_request","error_description":"The request is missing a parameter or has a wrong one."}

the refresh token,

HTTP/1.1 400 Bad Request
x-request-id: 01a0ed77-6a88-770c-90a3-bf9abb37c569
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 104

{"error":"invalid_request","error_description":"The request is missing a parameter or has a wrong one."}

and the id_token, which, unlike the disabled-client case above, reaches the resolver: subjdoc-spa still authenticates, so this refusal is the subject check's own.

HTTP/1.1 400 Bad Request
x-request-id: 01a0ed77-6ab7-7f76-85b8-66552b24f99d
vary: Origin
cache-control: no-store
pragma: no-cache
content-type: application/json; charset=utf-8
content-length: 104

{"error":"invalid_request","error_description":"The request is missing a parameter or has a wrong one."}