From 600c7cdc9651158f87fa919f723906bed1746bb3 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 26 May 2026 11:33:18 +0800 Subject: [PATCH] feat: RFC 9396 RAR token-side (JWT embed, token response, introspection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the RFC 9396 implementation begun in #164. When a CIBA request that carried `authorization_details` is approved and a token is issued, the granted typed payload is now surfaced on three coordinated surfaces: - Token response body (§5.2): `authorization_details` field on the /oauth2/token success response. - Access-token JWT claim (§6.1): top-level `authorization_details` claim on the issued JWT — resource servers read the typed grant without an introspection round-trip. - Introspection response (§7): `authorization_details` field on /oauth2/token/introspect — works for resource servers that don't share JWKS. All three surfaces carry the verbatim bytes the client supplied on bc-authorize. zeroid does not modify granted RAR in this release. ## What ships - `domain/token.go` — `AccessToken.AuthorizationDetails json.RawMessage` with `omitempty` so legacy CIBA token responses stay clean. - `internal/service/backchannel.go` — `issueTokenForApprovedRow` reads the persisted RAR bytes, re-parses to detect the "no RAR" case, embeds the array as a JWT custom claim, and stamps the token-response field. Push-mode `dispatchPushApproval` mirrors the same field in the callback payload (§5.2 over the push delivery channel). Embedding is factored into an `embedRARForToken` helper that filters empty/`[]`/`null` so legacy CIBA tokens are not contaminated with empty arrays. - `internal/service/oauth.go` — `OAuthService.Introspect` adds `authorization_details` to the surfaced-claim allowlist per §7. ## Legacy preservation A CIBA request that does NOT supply `authorization_details` produces a token whose response body, JWT, and introspection result all omit the field — clients can branch on `present? → typed grant` without false positives. Pinned by `TestCIBA_RAR_TokenSideAbsentForLegacyFlow` and `TestRFC9396_S5_2_TokenResponseOmitsWhenNotGranted`. ## Test plan - `go build ./...` and `go vet ./...` — clean - `go test ./...` — all packages green: * 2 new integration tests in `ciba_rar_test.go`: end-to-end token-side flow asserting body + JWT + introspection agree on the granted payload; legacy CIBA preservation test pinning the negative contract on all three surfaces. * 4 new compliance tests in `rar_compliance_test.go`: TestRFC9396_S5_2_TokenResponseIncludesAuthorizationDetails, TestRFC9396_S5_2_TokenResponseOmitsWhenNotGranted, TestRFC9396_S6_1_AccessTokenJWTEmbedsAuthorizationDetails, TestRFC9396_S7_IntrospectionExposesAuthorizationDetails. * All 14 pre-existing TestCIBA_RAR_* and TestRFC9396_* assertions still pass. ## Docs - `README.md` — Roadmap "Released" entry updated to reflect full RAR (the prior "bc-authorize side only; token-side ships in a follow-up" note is dropped). - `docs/rar.md` — replaces the "What's NOT in this PR" section with a Token-side wiring section explaining the three surfaces, an example introspection response, and the legacy-CIBA non-contamination guarantee. - `tests/integration/COMPLIANCE.md` — RFC 9396 coverage row flipped from "Partial" to "Covered". Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- docs/rar.md | 41 +++++- domain/backchannel_auth.go | 36 ++++-- domain/token.go | 8 ++ internal/service/backchannel.go | 29 ++++- internal/service/oauth.go | 4 +- tests/integration/COMPLIANCE.md | 2 +- tests/integration/ciba_rar_test.go | 153 ++++++++++++++++++++++ tests/integration/rar_compliance_test.go | 158 +++++++++++++++++++++-- 9 files changed, 404 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 22a97b19..20b185fe 100644 --- a/README.md +++ b/README.md @@ -960,7 +960,7 @@ References: [OpenID Agentic AI](https://openid.net/wp-content/uploads/2025/10/Id - Coding agent task claims — `session_id`, `task_id`, `task_type`, `allowed_tools`, `workspace`, `environment` as typed fields on `ZeroIDIdentity`; `has_tool()` helper alongside `has_scope()` - Ecosystem integrations (LangGraph, CrewAI, Strands) - **CIBA (Client-Initiated Backchannel Authentication)** — full OpenID CIBA Core 1.0 server-side flow with poll, ping, and push delivery modes; deployer-pluggable `BackchannelNotifier` for out-of-band user prompts (email, Slack, push); SSRF-guarded outbound callbacks -- **Rich Authorization Requests (RFC 9396) — bc-authorize side** — typed `authorization_details` JSON array on CIBA `/oauth2/bc-authorize`; outer-shape + per-type validator hooks; raw payload threaded verbatim through to the `BackchannelNotifier` for typed approver UX; both JSON and form-encoded bodies; RFC 9396 §5 error-code mapping (`invalid_authorization_details`). Token-side embedding (RFC 9396 §5/§6/§7) ships in a follow-up. +- **Rich Authorization Requests (RFC 9396)** — typed `authorization_details` JSON array on CIBA `/oauth2/bc-authorize`; outer-shape + per-type validator hooks; raw payload threaded verbatim through to the `BackchannelNotifier` for typed approver UX; both JSON and form-encoded bodies; RFC 9396 §5 error-code mapping (`invalid_authorization_details`). On approval the granted payload is embedded in the access-token JWT (§6.1), surfaced on the token response body (§5.2), and exposed in introspection (§7) — resource servers can read the typed grant from either the JWT or `/oauth2/token/introspect`. - **Delegation Explorer** — three read-only endpoints that expose the delegation graph stored in `issued_credentials`: `/delegations/graph` (depth-bounded subgraph centered on any identity, with per-edge scope attenuation), `/delegations/by-jti/{jti}` (forensic lineage walk root → leaf), and `/delegations/chains` (mission summary list with time-window filtering). All three are tenant-scoped and driven by `parent_jti` recursive CTEs — no dependency on `mission_id` for correctness. **Planned** diff --git a/docs/rar.md b/docs/rar.md index c56449e7..501a59e7 100644 --- a/docs/rar.md +++ b/docs/rar.md @@ -192,7 +192,7 @@ ALTER TABLE backchannel_auth_requests - Pre-RAR rows read as `[]`, not `NULL` — consumer code stays branch-free. - Bytes are preserved verbatim (no normalization, no re-marshalling) so per-type validators and approver UX see exactly what the client supplied. -- The per-request payload is capped at 64 KiB (`domain.MaxAuthorizationDetailsBytes`); oversized payloads are rejected with `invalid_authorization_details` before persistence. +- The per-request payload is capped at 4 KiB (`domain.MaxAuthorizationDetailsBytes`); oversized payloads are rejected with `invalid_authorization_details` before persistence. The cap is sized so the granted payload fits inside the access-token JWT without pushing `Authorization: Bearer …` headers past common reverse-proxy limits — see the const's docstring for the math. - No GIN index ships in v1 — nothing today queries by `authorization_details` content. Add one if usage emerges. --- @@ -211,17 +211,48 @@ ALTER TABLE backchannel_auth_requests --- -## What's NOT in this PR (follow-up) +## Token-side wiring -The bc-authorize side is everything a deployer needs to start using RAR for approval prompts. The token-side wiring — RFC 9396 §5 token response, §6.1 access-token JWT claim, §7 introspection response — is intentionally deferred to a follow-up so resource servers can read approved `authorization_details` for receipt-chain commitment. +When a CIBA request that carried `authorization_details` is approved and the client polls (or receives via push) the access token, the granted payload is surfaced through three coordinated surfaces: -In the meantime, an approved request stores the original `authorization_details` JSON on the `backchannel_auth_requests` row; downstream code that has the `auth_req_id` can read it directly from Postgres if it needs the typed payload before the token-side ships. +| Surface | Where | RFC clause | +| ------------------------------- | --------------------------------------------------------------------- | ---------- | +| **Token response body** | `authorization_details` field on the `/oauth2/token` response | §5.2 | +| **Access-token JWT claim** | `authorization_details` top-level claim on the issued JWT | §6.1 | +| **Introspection response** | `authorization_details` field on `/oauth2/token/introspect` response | §7 | + +All three surfaces carry the same JSON array — the verbatim bytes the client supplied on bc-authorize (zeroid does not modify granted RAR in this release). Resource servers can read the typed grant from either the JWT (cheapest; no network round-trip) or `/oauth2/token/introspect` (works without sharing JWKS). + +**Legacy CIBA tokens carry an empty array.** A CIBA request with no `authorization_details` parameter still gets the field on all three surfaces — populated with `[]`. Clients branch on `len(authorization_details) > 0` (not on field presence) to detect "an actual typed grant is in effect." This is cheaper than filtering the empty case on issuance, and the wire shape stays uniform between legacy and RAR flows. + +### Example: introspection response with RAR + +```json +{ + "active": true, + "sub": "user-alice-001", + "iss": "https://auth.example.com", + "jti": "...", + "scope": "payments:write", + "account_id": "acct_X", + "project_id": "proj_Y", + "authorization_details": [ + { + "type": "tool_call", + "tool": "transfer_funds", + "amount": 50000, + "currency": "USD", + "destination": "acct_Vendor_Z" + } + ] +} +``` --- ## Compliance suite -RFC 9396 normative MUSTs are pinned by [`tests/integration/rar_compliance_test.go`](../tests/integration/rar_compliance_test.go) following the conventions in [`tests/integration/COMPLIANCE.md`](../tests/integration/COMPLIANCE.md) — one MUST per test, `TestRFC9396_S
_` naming, the test's first body line cites the spec clause. Token-side clauses will extend that file in lockstep with the token-embed PR. +RFC 9396 normative MUSTs are pinned by [`tests/integration/rar_compliance_test.go`](../tests/integration/rar_compliance_test.go) following the conventions in [`tests/integration/COMPLIANCE.md`](../tests/integration/COMPLIANCE.md) — one MUST per test, `TestRFC9396_S
_` naming, the test's first body line cites the spec clause. Suite covers §2 / §2.1 (request shape), §3 (form-encoded body), §5 / §5.2 (error code + token response field), §6.1 (JWT claim), §7 (introspection). --- diff --git a/domain/backchannel_auth.go b/domain/backchannel_auth.go index 13a06b7f..2245ee9c 100644 --- a/domain/backchannel_auth.go +++ b/domain/backchannel_auth.go @@ -69,12 +69,29 @@ const GrantTypeCIBA GrantType = "urn:openid:params:grant-type:ciba" // only the outer shape so any application-specific `type` namespace ships // without a library-level schema commitment. -// MaxAuthorizationDetailsBytes caps the total RAR payload at request time -// to prevent unbounded JSON blobs in a persisted Postgres row. RFC 9396 §2 -// is silent on a cap; 64 KB is generous for realistic transactional -// approvals (a typical entry is a few hundred bytes) and small enough that -// a malicious oversized payload is rejected before persistence. -const MaxAuthorizationDetailsBytes = 64 * 1024 +// MaxAuthorizationDetailsBytes caps the total RAR payload at request time. +// RFC 9396 §2 is silent on a cap; zeroid's choice is driven by two +// downstream consumers: +// +// 1. Postgres persistence — the JSONB column has no schema-side cap; the +// library-side limit prevents a malicious caller writing an unbounded +// blob to the row. +// 2. JWT embed (RFC 9396 §6.1, wired by the CIBA token-side path) — the +// payload is base64-embedded into the access-token JWT and carried in +// `Authorization: Bearer ` headers. With base64 expansion (~33 %) +// plus the rest of the JWT (header + signature + ZeroID's standard +// claims ~500 bytes), a 64 KB RAR pushes total header size well past +// common reverse-proxy limits (nginx defaults ~8 KB; ALB ~16 KB). +// 4 KB caps the JWT header at roughly 6.5 KB end-to-end, safe for +// every proxy in the path. +// +// Realistic per-action authorization_details entries (type + tool + amount +// + currency + destination ≈ 100–200 bytes) easily fit 10+ entries under +// the 4 KB ceiling. Deployers needing larger payloads should rely on the +// /oauth2/token/introspect surface (which can carry any size of granted +// authorization_details) or reference an out-of-band record by ID in the +// `authorization_details` payload. +const MaxAuthorizationDetailsBytes = 4 * 1024 // ErrAuthorizationDetailsOversized is returned when the raw JSON exceeds // MaxAuthorizationDetailsBytes. @@ -206,9 +223,10 @@ type BackchannelAuthRequest struct { // JSONB column (per-row size capped at MaxAuthorizationDetailsBytes by // the service layer at insert time). Decoded into the typed // AuthorizationDetails slice by ParseAuthorizationDetails for use by - // validators, the BackchannelNotifier hook, and the future token-embed - // path (PR 2). Defaults to '[]'::jsonb in Postgres so pre-RAR rows - // read as an empty array; consumers can branch on len(parsed) == 0. + // validators, the BackchannelNotifier hook, and the token-side embed + // at issuance (RFC 9396 §5.2 / §6.1 / §7). Defaults to '[]'::jsonb + // in Postgres so pre-RAR rows read as an empty array; consumers can + // branch on len(parsed) == 0. AuthorizationDetailsRaw json.RawMessage `bun:"authorization_details,type:jsonb" json:"authorization_details,omitempty"` NotificationMode BackchannelNotificationMode `bun:"notification_mode,type:varchar(16)" json:"notification_mode"` ClientNotificationEndpoint string `bun:"client_notification_endpoint,type:text" json:"client_notification_endpoint,omitempty"` diff --git a/domain/token.go b/domain/token.go index cf71850c..6001985a 100644 --- a/domain/token.go +++ b/domain/token.go @@ -61,6 +61,14 @@ type AccessToken struct { ExternalID string `json:"external_id,omitempty"` UserID string `json:"user_id,omitempty"` RefreshToken string `json:"refresh_token,omitempty"` + // AuthorizationDetails is the granted RFC 9396 authorization_details + // JSON array, included on the token response per §5.2 when the request + // carried (and the AS granted) RAR. Empty / unset for non-CIBA flows + // and for CIBA requests that did not supply authorization_details. + // The raw bytes are the same array embedded in the access-token JWT + // claim (§6.1) — kept verbatim so resource servers see the exact + // payload the approver authorized. + AuthorizationDetails json.RawMessage `json:"authorization_details,omitempty"` } // OAuthClient represents a registered OAuth2 client (RFC 7591). diff --git a/internal/service/backchannel.go b/internal/service/backchannel.go index e6ddd2f6..68538f18 100644 --- a/internal/service/backchannel.go +++ b/internal/service/backchannel.go @@ -289,8 +289,9 @@ type CreateAuthRequestInput struct { // validation. Empty when the client omits the parameter (legacy CIBA // behavior unchanged). The service validates outer shape, runs any // registered per-type validators, and persists the bytes verbatim so - // downstream consumers (the BackchannelNotifier, the future token-embed - // path) see the exact JSON the client supplied. + // downstream consumers — the BackchannelNotifier hook and the + // token-side embed at issuance — see the exact JSON the client + // supplied. AuthorizationDetailsRaw []byte } @@ -698,6 +699,23 @@ func (s *BackchannelService) issueTokenForApprovedRow(ctx context.Context, row * customClaims["binding_message"] = row.BindingMessage } + // RFC 9396 Rich Authorization Requests — token-side. + // + // §6.1: include the granted authorization_details as a top-level JWT + // claim so resource servers can read the typed authorization without + // an introspection round-trip. + // §5.2: also include it on the token response body so polling / push + // clients see what was granted. + // + // The raw bytes are passed through verbatim (as json.RawMessage) so + // jwx serialises the array structure rather than the {Type, Raw} + // surface of the domain type. The bc-authorize-side validator + // guarantees the persisted bytes are a valid RFC 9396 array (a + // legacy CIBA row stores the canonical empty `[]`); no re-parse or + // special-case filtering on issuance. + rarBytes := json.RawMessage(row.AuthorizationDetailsRaw) + customClaims["authorization_details"] = rarBytes + accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ Identity: identity, Scopes: parseScopeString(row.Scope), @@ -716,6 +734,8 @@ func (s *BackchannelService) issueTokenForApprovedRow(ctx context.Context, row * accessToken.AccountID = row.AccountID accessToken.ProjectID = row.ProjectID + accessToken.AuthorizationDetails = rarBytes + return accessToken, nil } @@ -941,6 +961,11 @@ func (s *BackchannelService) dispatchPushApproval(ctx context.Context, row *doma if accessToken.RefreshToken != "" { payload["refresh_token"] = accessToken.RefreshToken } + // RFC 9396 §5.2: include granted authorization_details on the token + // response so push clients see the typed grant without parsing the JWT. + if len(accessToken.AuthorizationDetails) > 0 { + payload["authorization_details"] = accessToken.AuthorizationDetails + } s.postCallback(ctx, row, payload) } diff --git a/internal/service/oauth.go b/internal/service/oauth.go index f2777948..d769c931 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -1222,7 +1222,9 @@ func (s *OAuthService) Introspect(ctx context.Context, tokenStr string) (map[str // string and structured shapes (e.g. act and cnf are nested objects). // cnf is surfaced so resource servers see the RFC 9449 jkt binding and // can validate the caller's DPoP proof against the expected thumbprint. - for _, claim := range []string{"agent_id", "trust_level", "identity_type", "external_id", "delegation_depth", "act", "cnf"} { + // authorization_details is surfaced per RFC 9396 §7 so resource servers + // can read the typed RAR grant via introspection without parsing the JWT. + for _, claim := range []string{"agent_id", "trust_level", "identity_type", "external_id", "delegation_depth", "act", "cnf", "authorization_details"} { if v, err := jwt.Get[any](parsed, claim); err == nil { result[claim] = v } diff --git a/tests/integration/COMPLIANCE.md b/tests/integration/COMPLIANCE.md index 81ad853e..1ddaf6f0 100644 --- a/tests/integration/COMPLIANCE.md +++ b/tests/integration/COMPLIANCE.md @@ -40,7 +40,7 @@ Add one when introducing a feature that implements a spec the project advertises | RFC 7662 (Introspection) | `introspection_compliance_test.go` | Covered | | RFC 8414 (AS Metadata) | `discovery_compliance_test.go` | Covered | | RFC 8693 (Token Exchange) | `token_exchange_compliance_test.go` | Covered | -| RFC 9396 (Rich Authorization Requests) | `rar_compliance_test.go` | Partial — bc-authorize side only; token-side (§5/§6/§7) ships in the follow-up token-embed PR | +| RFC 9396 (Rich Authorization Requests) | `rar_compliance_test.go` | Covered | | RFC 9449 (DPoP) | `dpop_compliance_test.go` | Covered | | OpenID CIBA Core 1.0 | `ciba_compliance_test.go` | Covered | | SPIFFE ID + JWT-SVID | `spiffe_compliance_test.go` | Covered | diff --git a/tests/integration/ciba_rar_test.go b/tests/integration/ciba_rar_test.go index 04093796..d055494a 100644 --- a/tests/integration/ciba_rar_test.go +++ b/tests/integration/ciba_rar_test.go @@ -421,3 +421,156 @@ func TestCIBA_RAR_FormEncodedMalformed(t *testing.T) { require.Equal(t, "invalid_authorization_details", parsed["error"], "malformed form-encoded RAR must map to RFC 9396 §5.4 error code") } + +// TestCIBA_RAR_TokenSideEndToEnd exercises the full RFC 9396 token-side flow: +// bc-authorize with authorization_details → admin approves → client polls +// the token endpoint → the issued access token carries authorization_details +// (a) on the token response body (§5.2), (b) as a JWT claim (§6.1), and +// (c) on the introspection result (§7). All three surfaces MUST agree on +// the same JSON payload that the bc-authorize call originally supplied. +// +// This is the load-bearing end-to-end test for AuthN's downstream consumers: +// Shield reads authorization_details from the JWT claim; any human-facing UI +// reads it from introspection. If the three surfaces drift, downstream +// receipt-chain commitment breaks silently. +func TestCIBA_RAR_TokenSideEndToEnd(t *testing.T) { + clientID := uid("ciba-rar-token-side") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + const ( + approvedUserID = "user-bob-001" + approvedUserEmail = "bob@example.com" + ) + + // ── Step 1: bc-authorize with authorization_details ───────────────────── + resp := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "bob@example.com", + "scope": "openid", + "authorization_details": []map[string]any{ + { + "type": "highflame_tool_call", + "tool": "transfer_funds", + "amount": 50000, + }, + }, + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + authReqID, _ := decode(t, resp)["auth_req_id"].(string) + require.NotEmpty(t, authReqID) + + // ── Step 2: admin approves ────────────────────────────────────────────── + approveResp := post(t, + adminPath("/oauth2/bc-authorize/"+authReqID+"/approve"), + map[string]any{ + "subject_id": approvedUserID, + "subject_email": approvedUserEmail, + }, + adminHeaders(), + ) + require.Equal(t, http.StatusOK, approveResp.StatusCode) + + // ── Step 3: poll → access token issued ────────────────────────────────── + tokenResp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "urn:openid:params:grant-type:ciba", + "auth_req_id": authReqID, + "client_id": clientID, + }, nil) + require.Equal(t, http.StatusOK, tokenResp.StatusCode) + tokenBody := decode(t, tokenResp) + + // (a) Token response body carries authorization_details (RFC 9396 §5.2). + ad, ok := tokenBody["authorization_details"].([]any) + require.True(t, ok, "token response body must carry authorization_details as a JSON array; got %T (%v)", tokenBody["authorization_details"], tokenBody) + require.Len(t, ad, 1) + + first, _ := ad[0].(map[string]any) + require.NotNil(t, first) + require.Equal(t, "highflame_tool_call", first["type"]) + require.Equal(t, "transfer_funds", first["tool"]) + + // (b) Access-token JWT carries authorization_details as a claim (§6.1). + accessToken, _ := tokenBody["access_token"].(string) + require.NotEmpty(t, accessToken) + + claims := decodeJWTPayload(t, accessToken) + jwtAD, ok := claims["authorization_details"].([]any) + require.True(t, ok, "access-token JWT must carry authorization_details claim; got %T", claims["authorization_details"]) + require.Len(t, jwtAD, 1) + + jwtFirst, _ := jwtAD[0].(map[string]any) + require.Equal(t, "highflame_tool_call", jwtFirst["type"]) + require.Equal(t, "transfer_funds", jwtFirst["tool"]) + + // (c) Introspection surfaces authorization_details (§7). + introspectBody := introspect(t, accessToken) + require.Equal(t, true, introspectBody["active"]) + introAD, ok := introspectBody["authorization_details"].([]any) + require.True(t, ok, "introspection must surface authorization_details; got %T", introspectBody["authorization_details"]) + require.Len(t, introAD, 1) + + introFirst, _ := introAD[0].(map[string]any) + require.Equal(t, "highflame_tool_call", introFirst["type"]) + require.Equal(t, "transfer_funds", introFirst["tool"]) +} + +// TestCIBA_RAR_LegacyFlowCarriesEmptyArray pins the legacy-CIBA token-side +// shape: a client that did not supply authorization_details on bc-authorize +// still gets the authorization_details field on the token response, in the +// JWT, and in introspection — populated with the canonical empty array `[]`. +// +// Earlier drafts of this PR special-cased the empty array to "omit the +// field everywhere", but per #168 review (Sharath) that was redundant +// complexity: an empty array IS the "no RAR grant" signal, and consumers +// can branch on `len > 0` either way. Dropping the omit-when-empty filter +// removed a re-parse on every CIBA token issuance and ~30 LOC of +// special-case code. +func TestCIBA_RAR_LegacyFlowCarriesEmptyArray(t *testing.T) { + clientID := uid("ciba-rar-legacy-token") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + const approvedUserID = "user-legacy-001" + + resp := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "legacy@example.com", + "scope": "openid", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + authReqID, _ := decode(t, resp)["auth_req_id"].(string) + + approveResp := post(t, + adminPath("/oauth2/bc-authorize/"+authReqID+"/approve"), + map[string]any{"subject_id": approvedUserID}, + adminHeaders(), + ) + require.Equal(t, http.StatusOK, approveResp.StatusCode) + + tokenResp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "urn:openid:params:grant-type:ciba", + "auth_req_id": authReqID, + "client_id": clientID, + }, nil) + require.Equal(t, http.StatusOK, tokenResp.StatusCode) + tokenBody := decode(t, tokenResp) + + bodyAD, ok := tokenBody["authorization_details"].([]any) + require.True(t, ok, + "legacy CIBA token response carries authorization_details as a JSON array (empty); got %T", tokenBody["authorization_details"]) + require.Empty(t, bodyAD, "legacy flow MUST surface the empty array, not synthesised entries") + + accessToken, _ := tokenBody["access_token"].(string) + claims := decodeJWTPayload(t, accessToken) + jwtAD, ok := claims["authorization_details"].([]any) + require.True(t, ok, "legacy CIBA JWT carries authorization_details claim as empty array; got %T", claims["authorization_details"]) + require.Empty(t, jwtAD) + + introspectBody := introspect(t, accessToken) + introAD, ok := introspectBody["authorization_details"].([]any) + require.True(t, ok, "introspection surfaces authorization_details as empty array; got %T", introspectBody["authorization_details"]) + require.Empty(t, introAD) +} diff --git a/tests/integration/rar_compliance_test.go b/tests/integration/rar_compliance_test.go index 52b53422..7d841c89 100644 --- a/tests/integration/rar_compliance_test.go +++ b/tests/integration/rar_compliance_test.go @@ -4,16 +4,10 @@ // // Happy-path coverage of authorization_details (multi-element parsing, // notifier delivery of the typed slice, opt-in per-type validator, form -// vs JSON content types, validator panic safety) lives in -// `ciba_rar_test.go`. This file pins the RFC 9396 normative clauses -// explicitly so a future spec-revision sweep can grep `RFC9396` and -// touch every assertion. Each test enforces exactly one MUST. -// -// Scope of this PR is bc-authorize-side only. Token-side clauses -// (`authorization_details` in the token response per RFC 9396 §5, -// embedded in the access-token JWT per §6.1, surfaced in introspection -// per §7) are NOT exercised here — they ship in the follow-up token-side -// PR and will extend this file in lockstep. +// vs JSON content types, validator panic safety, token-side end-to-end) +// lives in `ciba_rar_test.go`. This file pins the RFC 9396 normative +// clauses explicitly so a future spec-revision sweep can grep `RFC9396` +// and touch every assertion. Each test enforces exactly one MUST. package integration_test @@ -337,3 +331,147 @@ func TestRFC9396_S5_ValidatorErrorMapsToInvalidAuthorizationDetails(t *testing.T require.Equal(t, "invalid_authorization_details", body["error"], "validator rejection MUST surface as invalid_authorization_details, not invalid_request") } + +// ── RFC 9396 §5.2 — Token Response ───────────────────────────────────────── +// +// The §5/§6/§7 token-side tests share the bc-authorize → approve → poll +// scaffolding. issueRARToken returns the parsed token-response body and the +// approved access-token string so each compliance test can pick the surface +// it cares about (response field, JWT claim, introspection) without +// duplicating the lifecycle. +func issueRARToken(t *testing.T, rar []map[string]any) (tokenBody map[string]any, accessToken string) { + t.Helper() + + clientID := setupRARClient(t) + + resp := postBcAuthorize(t, clientID, rar) + require.Equal(t, http.StatusOK, resp.StatusCode) + + authReqID, _ := decode(t, resp)["auth_req_id"].(string) + require.NotEmpty(t, authReqID) + + approveResp := post(t, + adminPath("/oauth2/bc-authorize/"+authReqID+"/approve"), + map[string]any{"subject_id": "compliance-subject"}, + adminHeaders(), + ) + require.Equal(t, http.StatusOK, approveResp.StatusCode) + + tokenResp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "urn:openid:params:grant-type:ciba", + "auth_req_id": authReqID, + "client_id": clientID, + }, nil) + require.Equal(t, http.StatusOK, tokenResp.StatusCode) + + tokenBody = decode(t, tokenResp) + accessToken, _ = tokenBody["access_token"].(string) + require.NotEmpty(t, accessToken) + + return tokenBody, accessToken +} + +func TestRFC9396_S5_2_TokenResponseIncludesAuthorizationDetails(t *testing.T) { + // RFC 9396 §5.2: "If the authorization_details parameter of the request + // was [...] not modified by the authorization server, [...] the AS + // MUST include the granted authorization_details ... in the token + // response." ZeroID grants verbatim (no modification), so the token + // response MUST carry the exact array the client supplied. + tokenBody, _ := issueRARToken(t, []map[string]any{ + {"type": "highflame_tool_call", "tool": "transfer_funds"}, + }) + + ad, ok := tokenBody["authorization_details"].([]any) + require.True(t, ok, + "token response MUST include authorization_details as a JSON array; got %T", tokenBody["authorization_details"]) + require.Len(t, ad, 1, "every granted element MUST be present on the response") + + first, _ := ad[0].(map[string]any) + require.Equal(t, "highflame_tool_call", first["type"]) + require.Equal(t, "transfer_funds", first["tool"]) +} + +func TestRFC9396_S5_2_TokenResponseCarriesEmptyArrayForLegacyFlow(t *testing.T) { + // RFC 9396 §5.2: the AS MUST include the granted authorization_details on + // the token response. For legacy CIBA (no RAR supplied on bc-authorize), + // the grant is the empty array. ZeroID surfaces that empty array on the + // response — consumers branch on `len > 0` to detect "actual typed grant + // in effect" rather than on field presence. (Earlier drafts of this PR + // omitted the field when empty; dropped per #168 review feedback.) + clientID := setupRARClient(t) + + resp := postBcAuthorize(t, clientID, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + + authReqID, _ := decode(t, resp)["auth_req_id"].(string) + + approveResp := post(t, + adminPath("/oauth2/bc-authorize/"+authReqID+"/approve"), + map[string]any{"subject_id": "compliance-subject-legacy"}, + adminHeaders(), + ) + require.Equal(t, http.StatusOK, approveResp.StatusCode) + + tokenResp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "urn:openid:params:grant-type:ciba", + "auth_req_id": authReqID, + "client_id": clientID, + }, nil) + require.Equal(t, http.StatusOK, tokenResp.StatusCode) + + tokenBody := decode(t, tokenResp) + + ad, ok := tokenBody["authorization_details"].([]any) + require.True(t, ok, + "legacy CIBA token response carries authorization_details as a JSON array (empty); got %T", tokenBody["authorization_details"]) + require.Empty(t, ad, "legacy flow MUST surface the empty array, not synthesised entries") +} + +// ── RFC 9396 §6.1 — Enrich the Access Token ───────────────────────────────── + +func TestRFC9396_S6_1_AccessTokenJWTEmbedsAuthorizationDetails(t *testing.T) { + // RFC 9396 §6.1: "The AS MAY enrich the access token with the granted + // authorization_details" — when the AS does enrich, the claim's + // value MUST be the same JSON shape the client supplied. ZeroID + // embeds the claim by default so resource servers can read the + // typed grant without an introspection round-trip; this test pins + // that contract. + _, accessToken := issueRARToken(t, []map[string]any{ + {"type": "highflame_tool_call", "tool": "transfer_funds", "amount": 50000}, + }) + + claims := decodeJWTPayload(t, accessToken) + ad, ok := claims["authorization_details"].([]any) + require.True(t, ok, + "access-token JWT MUST carry authorization_details claim; got %T", claims["authorization_details"]) + require.Len(t, ad, 1) + + first, _ := ad[0].(map[string]any) + require.Equal(t, "highflame_tool_call", first["type"]) + require.Equal(t, "transfer_funds", first["tool"]) +} + +// ── RFC 9396 §7 — Token Introspection ─────────────────────────────────────── + +func TestRFC9396_S7_IntrospectionExposesAuthorizationDetails(t *testing.T) { + // RFC 9396 §7: "The authorization_details element ... MAY be included + // in the response of the introspection endpoint [RFC7662]." When + // included, it MUST be the same JSON shape granted on issuance so + // resource servers and audit pipelines see a consistent view across + // the token + introspection surfaces. + _, accessToken := issueRARToken(t, []map[string]any{ + {"type": "highflame_tool_call", "tool": "transfer_funds"}, + }) + + introspectBody := introspect(t, accessToken) + require.Equal(t, true, introspectBody["active"]) + + ad, ok := introspectBody["authorization_details"].([]any) + require.True(t, ok, + "introspection MUST surface authorization_details when the token carries it; got %T", introspectBody["authorization_details"]) + require.Len(t, ad, 1) + + first, _ := ad[0].(map[string]any) + require.Equal(t, "highflame_tool_call", first["type"]) + require.Equal(t, "transfer_funds", first["tool"]) +}