Summary
/.well-known/oauth-authorization-server advertises
urn:ietf:params:oauth:grant-type:token-exchange in grant_types_supported, and
POST /oauth/token gates on-behalf-of delegation on
client.grantTypes.includes(TOKEN_EXCHANGE_GRANT_TYPE) — but no shipped provisioning
path can put that value on a client. DCR, the seed manifest and CIMD each validate
grant_types against a three-value allowlist that excludes the URN.
The net effect: the entire agent-delegation surface (ADR-007 §2, #183) is unreachable for
any client an operator can actually register. It only works if the URN is written directly
into the oauth_clients.grant_types JSONB column, out of band.
This is a real escape rather than a deliberate deferral. #183's acceptance criteria included
"Advertise support in AS metadata (grant_types_supported)" — which shipped — while
#182's "Surface it on registration paths: CIMD metadata, DCR (POST /oauth/register),
and the seed/manifest tooling" covered is_agent only. Nothing closed the loop for the
grant itself.
Verified on main @ 11879a03.
Evidence
1. Advertised. apps/auth-server/src/app/helpers/discovery.ts:90-95:
grant_types_supported: [
'authorization_code',
'client_credentials',
'refresh_token',
'urn:ietf:params:oauth:grant-type:token-exchange',
],
2. Enforced at the token endpoint.
apps/auth-server/src/app/routes/oauth/token.ts:1145 — a client without the grant gets
unauthorized_client:
if (!client.grantTypes.includes(TOKEN_EXCHANGE_GRANT_TYPE)) {
await auditFailure('unauthorized_client: client not allowed the token-exchange grant');
throw new UnauthorizedClientError();
}
3. Rejected by every provisioning path.
-
DCR — apps/auth-server/src/app/schemas/oauth.ts:312-315:
grant_types: z
.array(z.enum(['authorization_code', 'refresh_token', 'client_credentials']))
.max(8)
.optional(),
Note this 400s rather than silently dropping the value. RFC 7591 §3.2's
ignore-unrecognized-fields behaviour (which this schema implements via Zod's default
strip, per its own JSDoc at :305-307) applies to unknown keys; grant_types is a
known key with a constrained enum, so an out-of-enum member is a validation error.
-
Seed manifest — libs/infra/db/src/scripts/seed-oauth-clients.ts:60,70 validates
against grantTypeEnum.enumValues, and that pg enum
(libs/infra/db/src/lib/schema/enums.ts:51-57) is
['authorization_code', 'refresh_token', 'client_credentials']. No later migration
widens it (ALTER TYPE appears nowhere in libs/infra/db/drizzle/*.sql).
-
CIMD — apps/auth-server/src/app/helpers/cimd.ts:100-104,
SUPPORTED_CIMD_GRANT_TYPES, same three values.
4. Storage would accept it. libs/infra/db/src/lib/schema/core.ts:151 declares
grantTypes as jsonb, not the grant_type pg enum — so the column can already hold
the URN. The enum constrains only the seed validator, which is why a direct DB write is
the sole working path today.
5. No test covers a real provisioning path. token.test.ts's setupExchangeStub
takes grantTypes?: string[] and stubs the client object directly, so the delegation
tests never exercise registration. That is why the gap survived.
Suggested fix
The narrow, self-consistent change is to admit the URN on the paths that already gate it:
- Add
urn:ietf:params:oauth:grant-type:token-exchange to the DCR enum
(schemas/oauth.ts:312) and to SUPPORTED_CIMD_GRANT_TYPES (cimd.ts:100).
- For the seed manifest, either widen the
grant_type pg enum via migration, or decouple
the manifest validator from it (the column is jsonb, so no migration is strictly
required for storage — only for the enum the validator borrows).
- Add a regression test that registers a client through DCR and completes a token
exchange, rather than stubbing grantTypes.
Worth deciding explicitly as part of this: whether a self-asserted DCR client should be
able to claim the delegation grant at all, given is_agent is already documented as
self-asserted and untrusted, and max_agent_mode is the operator-set ceiling. If the
answer is no, then the correct fix is the inverse — stop advertising the grant in
grant_types_supported and document the out-of-band provisioning requirement — and this
issue should say so rather than widening the enums. Flagging it rather than deciding it.
Acceptance criteria
References
Summary
/.well-known/oauth-authorization-serveradvertisesurn:ietf:params:oauth:grant-type:token-exchangeingrant_types_supported, andPOST /oauth/tokengates on-behalf-of delegation onclient.grantTypes.includes(TOKEN_EXCHANGE_GRANT_TYPE)— but no shipped provisioningpath can put that value on a client. DCR, the seed manifest and CIMD each validate
grant_typesagainst a three-value allowlist that excludes the URN.The net effect: the entire agent-delegation surface (ADR-007 §2, #183) is unreachable for
any client an operator can actually register. It only works if the URN is written directly
into the
oauth_clients.grant_typesJSONB column, out of band.This is a real escape rather than a deliberate deferral. #183's acceptance criteria included
"Advertise support in AS metadata (
grant_types_supported)" — which shipped — while#182's "Surface it on registration paths: CIMD metadata, DCR (
POST /oauth/register),and the seed/manifest tooling" covered
is_agentonly. Nothing closed the loop for thegrant itself.
Verified on
main@11879a03.Evidence
1. Advertised.
apps/auth-server/src/app/helpers/discovery.ts:90-95:2. Enforced at the token endpoint.
apps/auth-server/src/app/routes/oauth/token.ts:1145— a client without the grant getsunauthorized_client:3. Rejected by every provisioning path.
DCR —
apps/auth-server/src/app/schemas/oauth.ts:312-315:Note this 400s rather than silently dropping the value. RFC 7591 §3.2's
ignore-unrecognized-fields behaviour (which this schema implements via Zod's default
strip, per its own JSDoc at
:305-307) applies to unknown keys;grant_typesis aknown key with a constrained enum, so an out-of-enum member is a validation error.
Seed manifest —
libs/infra/db/src/scripts/seed-oauth-clients.ts:60,70validatesagainst
grantTypeEnum.enumValues, and that pg enum(
libs/infra/db/src/lib/schema/enums.ts:51-57) is['authorization_code', 'refresh_token', 'client_credentials']. No later migrationwidens it (
ALTER TYPEappears nowhere inlibs/infra/db/drizzle/*.sql).CIMD —
apps/auth-server/src/app/helpers/cimd.ts:100-104,SUPPORTED_CIMD_GRANT_TYPES, same three values.4. Storage would accept it.
libs/infra/db/src/lib/schema/core.ts:151declaresgrantTypesasjsonb, not thegrant_typepg enum — so the column can already holdthe URN. The enum constrains only the seed validator, which is why a direct DB write is
the sole working path today.
5. No test covers a real provisioning path.
token.test.ts'ssetupExchangeStubtakes
grantTypes?: string[]and stubs the client object directly, so the delegationtests never exercise registration. That is why the gap survived.
Suggested fix
The narrow, self-consistent change is to admit the URN on the paths that already gate it:
urn:ietf:params:oauth:grant-type:token-exchangeto the DCR enum(
schemas/oauth.ts:312) and toSUPPORTED_CIMD_GRANT_TYPES(cimd.ts:100).grant_typepg enum via migration, or decouplethe manifest validator from it (the column is
jsonb, so no migration is strictlyrequired for storage — only for the enum the validator borrows).
exchange, rather than stubbing
grantTypes.Worth deciding explicitly as part of this: whether a self-asserted DCR client should be
able to claim the delegation grant at all, given
is_agentis already documented asself-asserted and untrusted, and
max_agent_modeis the operator-set ceiling. If theanswer is no, then the correct fix is the inverse — stop advertising the grant in
grant_types_supportedand document the out-of-band provisioning requirement — and thisissue should say so rather than widening the enums. Flagging it rather than deciding it.
Acceptance criteria
complete an RFC 8693 token exchange, or the grant is removed from
grant_types_supportedand the out-of-band requirement is documented.grantTypesarray.docs/agent-authorization.mdis updated — it currently carries a warning notedescribing this gap, added while auditing docs; that note should be removed or
rewritten once the behaviour changes.
References
is_agenton registration paths, not the grant)