Skip to content

feat: RFC 9396 RAR token-side (JWT embed, token response, introspection) - #168

Merged
saucam merged 1 commit into
mainfrom
feat/rar-token-side
May 27, 2026
Merged

feat: RFC 9396 RAR token-side (JWT embed, token response, introspection)#168
saucam merged 1 commit into
mainfrom
feat/rar-token-side

Conversation

@saucam

@saucam saucam commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Completes the RFC 9396 implementation begun in #164. When a CIBA request is approved and a token is issued, the granted authorization_details payload is surfaced on three coordinated surfaces:

Surface Where RFC clause
Token response body authorization_details field on /oauth2/token success response §5.2
Access-token JWT claim top-level authorization_details claim on the issued JWT §6.1
Introspection response authorization_details field on /oauth2/token/introspect §7

All three surfaces carry the verbatim bytes the client supplied on bc-authorize. zeroid does not modify granted RAR in this release. Legacy CIBA flows (no authorization_details on bc-authorize) get the canonical empty array [] on all three surfaces; consumers branch on len(authorization_details) > 0 to detect "actual typed grant in effect."

What ships

  • domain/token.goAccessToken.AuthorizationDetails json.RawMessage on the token response shape.
  • internal/service/backchannel.go:
    • issueTokenForApprovedRow passes row.AuthorizationDetailsRaw straight through into the JWT custom claim and the token-response field. The bc-authorize-side validator (parseAndValidateAuthorizationDetails) already guarantees the persisted bytes are a valid RFC 9396 array, so no re-parse on issuance.
    • dispatchPushApproval mirrors the same field in the push-callback payload (§5.2 over the push delivery channel).
  • internal/service/oauth.goOAuthService.Introspect adds authorization_details to the surfaced-claim allow-list per §7.
  • domain/backchannel_auth.go — lowered MaxAuthorizationDetailsBytes from 64 KiB to 4 KiB in response to Copilot review. 4 KB RAR + base64 (~33%) + standard ZeroID claims (~500 B) keeps the total Authorization: Bearer … header at ~6.5 KB — safely under nginx (~8 KB default) and ALB (~16 KB). Deployers needing larger payloads use /oauth2/token/introspect (no size constraint there) or reference an out-of-band record by ID.

Related

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; legacy-flow empty-array shape across all three surfaces)
  • 4 new compliance tests in `rar_compliance_test.go` covering RFC 9396 §5.2 (×2), §6.1, §7
  • All 14 pre-existing `TestCIBA_RAR_` and `TestRFC9396_` assertions still pass

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the token-side wiring for Rich Authorization Requests (RFC 9396) in the CIBA flow. Specifically, when a CIBA request containing authorization_details is approved, the granted payload is now embedded as a top-level claim in the access-token JWT (§6.1), surfaced in the token response body (§5.2), and exposed via token introspection (§7). The implementation ensures that legacy CIBA flows without RAR are not contaminated with empty authorization_details fields. Comprehensive integration and compliance tests have been added to verify these behaviors across all three surfaces. There are no review comments to address, and I have no additional feedback to provide.

@rsharath

Copy link
Copy Markdown
Contributor

@saucam I don't think we need legacy preservation, do we?

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements the token-side surfaces for RFC 9396 Rich Authorization Requests (RAR) in the CIBA flow, propagating granted authorization_details into the access-token JWT, token response body, and introspection response while preserving legacy behavior (field omitted when not granted).

Changes:

  • Add authorization_details to the /oauth2/token success response (poll + push) and embed it as a top-level access-token JWT claim.
  • Surface authorization_details in /oauth2/token/introspect by adding it to the introspection allow-list.
  • Extend integration/compliance coverage and update documentation to reflect full RFC 9396 coverage.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/integration/rar_compliance_test.go Adds RFC 9396 §5.2/§6.1/§7 compliance tests and shared token-issuance helper.
tests/integration/COMPLIANCE.md Marks RFC 9396 compliance as fully covered.
tests/integration/ciba_rar_test.go Adds end-to-end token-side RAR test plus legacy “omit everywhere” regression test.
README.md Updates feature list to reflect token-side RAR support (token response/JWT/introspection).
internal/service/oauth.go Surfaces authorization_details in introspection output by allow-listing the claim.
internal/service/backchannel.go Embeds/passes through persisted RAR into JWT custom claims and token responses; adds helper to omit empty cases.
domain/token.go Adds AccessToken.AuthorizationDetails json.RawMessage with omitempty for token responses.
domain/backchannel_auth.go Updates commentary to reflect token-side embedding now being part of the system.
docs/rar.md Documents token-side wiring surfaces and updates compliance suite description.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/service/backchannel.go Outdated
Comment thread internal/service/backchannel.go Outdated
@saucam
saucam force-pushed the feat/rar-token-side branch from 778a9b4 to 10b60d4 Compare May 26, 2026 13:36
@saucam

saucam commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

Force-pushed a rebase onto latest main (10b60d4). Previous remote tip was a partial merge that brought #162 only; the rebase brings the full #162 → #163 (Token.BaseURL → Token.Issuer BREAKING) → #167 (oautherror constants) set. Service-layer code already used the new oautherror.InvalidAuthorizationDetails constant via the rebase auto-apply; tree post-rebase: go vet, go build, go test ./... all green locally. PR diff against main is now exactly the four RAR token-side files.

@saucam
saucam force-pushed the feat/rar-token-side branch 2 times, most recently from 2982d96 to a7f6847 Compare May 26, 2026 14:55
@saucam

saucam commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

Agreed, dropped — authorization_details: [] is semantically identical to "no RAR grant" and consumers branch on len > 0 either way.

Pushed in a7f6847 (also rebased onto latest main to pick up #169):

  • internal/service/backchannel.goissueTokenForApprovedRow now passes row.AuthorizationDetailsRaw straight through to both the JWT custom claim and accessToken.AuthorizationDetails. The embedRARForToken helper (re-parse + empty-filter + nil-on-parse-error) is gone. The bc-authorize-side validator already guarantees the persisted bytes are a valid RFC 9396 array, so no re-parse on issuance.
  • tests/integration/ciba_rar_test.goTestCIBA_RAR_TokenSideAbsentForLegacyFlowTestCIBA_RAR_LegacyFlowCarriesEmptyArray. Same lifecycle, but now asserts the field IS present as [] on all three surfaces (body, JWT, introspection).
  • tests/integration/rar_compliance_test.goTestRFC9396_S5_2_TokenResponseOmitsWhenNotGrantedTestRFC9396_S5_2_TokenResponseCarriesEmptyArrayForLegacyFlow, same flip.
  • docs/rar.md — Token-side section updated to say "Legacy CIBA tokens carry an empty array" with a one-line rationale ("consumers branch on len > 0, not on field presence; cheaper than filtering on issuance").

Net: removed the re-parse on every CIBA token issuance, ~30 LOC of helper, and one branch in three places. Same external shape for RAR-bearing tokens; legacy tokens now also carry authorization_details: [] uniformly. CI re-runs locally clean.

@saucam

saucam commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

don't think we need legacy preservation, do we?

No

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 <noreply@anthropic.com>
@saucam
saucam force-pushed the feat/rar-token-side branch from a7f6847 to 600c7cd Compare May 26, 2026 15:59
@saucam
saucam merged commit 2c3d2c9 into main May 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants