Harden OIDC token endpoints from security review

Fixes from review of the device flow / introspection / DCR work:

Introspection over-disclosure (RFC 7662):
- Restrict introspection to authorized callers — the client a token was issued
  to, or a resource server registered (resource_identifiers) to serve the token's
  bound RFC 8707 audience. Unauthorized callers get {active:false}, disclosing
  nothing, instead of any confidential client reading any token.
- Scope-gate disclosed claims: username only with the email scope, groups only
  with the groups scope (mirrors userinfo). ADR 0005.

Tokens minted for revoked users:
- Re-check application.user_allowed?(user) at mint time in the device, refresh,
  and authorization-code grants (covers app-active, user-active, group
  membership). A user deactivated or removed from the allowed group between
  approval and the token request is refused with access_denied. The refresh
  check runs before rotation so a denied refresh has no side effects.

Device authorization hardening:
- Require confidential clients to authenticate, and require PKCE (code_challenge)
  up front for clients that require it, so an intercepted device_code plus a
  known public client_id cannot redeem tokens.
- Merge (not overwrite) the shared consent record on device approval.

Tests: new oidc_introspection_test and oidc_mint_authorization_test; existing
PKCE/claims tests updated to grant app access (they created ungrouped apps and
relied on the token endpoint not checking authorization). Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
This commit is contained in:
Dan Milne
2026-07-19 13:19:41 +10:00
co-authored by Claude Opus 4.8
parent 2defa26a87
commit 017dfdff0e
14 changed files with 533 additions and 67 deletions
@@ -0,0 +1,46 @@
# 0005 — Introspection authorization & claim scope-gating
**Status:** Accepted · **Date:** 2026-07-19
## Decision
The RFC 7662 introspection endpoint restricts *which* tokens a caller may see and
*which* claims it returns:
1. **Authorization to introspect.** A caller may introspect a token only if it was
issued to that caller, or the token is bound (RFC 8707 `resource`) to a resource
the caller is registered to serve (`Application#serves_resource?`, backed by a new
`resource_identifiers` column). Unauthorized callers get the same `{active:false}`
as an unknown token — disclosing nothing.
2. **Claim scope-gating.** `username` (email) is returned only when the token carries
the `email` scope; `groups` only with the `groups` scope — mirroring the userinfo
endpoint. `sub` is a pairwise pseudonym and is always safe to return.
## Context
The first cut authenticated the caller (any confidential client) but then returned
`active:true` plus the user's email and **all** group names for **any** token — even
tokens issued to a different client and regardless of the token's scopes. A
low-privilege second client could therefore harvest every user's email and group
memberships by replaying tokens it observed. RFC 7662 §4 explicitly calls for the AS
to verify the resource server is authorized to introspect the particular token,
typically via audience restriction.
## How it fits together
This is the enforcement half of the RFC 8707 resource indicators
([0004](0004-resource-indicators.md)): the CLI/agent requests a token with
`resource=<resource server>`, the resource server is registered with that same
identifier, and only it can introspect (and thereby read the user's groups to
authorize). A token minted for one resource server cannot be introspected by another.
## Consequences
- **c2a2 setup:** the `c2a2-introspection` client must declare its
`resource_identifiers` (seeded from `C2A2_RESOURCE`), and the CLI must request its
token with `resource=<that URL>`. A token with no bound resource can only be
introspected by the client it was issued to.
- Resource servers see only the identity claims the token was actually granted, so an
over-broad token (or a misconfigured scope) can't leak email/groups.
- Unauthorized introspection is indistinguishable from an unknown token, preventing
token-scanning and cross-client identity harvesting.
+1
View File
@@ -11,3 +11,4 @@ Each file is one decision. Newest decisions get the next number.
| [0002](0002-device-authorization-grant.md) | CLI/agent auth uses the OAuth 2.0 Device Authorization Grant (RFC 8628) |
| [0003](0003-dynamic-client-registration.md) | Dynamic Client Registration (RFC 7591), runtime-gated + default-deny |
| [0004](0004-resource-indicators.md) | Resource Indicators (RFC 8707) bind token audience; pass-through validation |
| [0005](0005-introspection-authorization.md) | Introspection restricted to authorized callers + claim scope-gating |