Skip to content

fix(oauth): the token-exchange grant is advertised and enforced but cannot be provisioned by any supported path #381

Description

@EsTharian

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.

  • DCRapps/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 manifestlibs/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).

  • CIMDapps/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:

  1. 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).
  2. 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).
  3. 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

  • A client provisioned through a supported path (DCR, CIMD, or the seed manifest) can
    complete an RFC 8693 token exchange, or the grant is removed from
    grant_types_supported and the out-of-band requirement is documented.
  • The chosen direction is consistent across discovery, DCR, CIMD and the seed manifest.
  • A test exercises the real provisioning path, not a stubbed grantTypes array.
  • docs/agent-authorization.md is updated — it currently carries a warning note
    describing this gap, added while auditing docs; that note should be removed or
    rewritten once the behaviour changes.

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

  • Status
    Backlog

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions