Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
41 changes: 36 additions & 5 deletions docs/rar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand All @@ -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<section>_<descriptor>` 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<section>_<descriptor>` 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).

---

Expand Down
36 changes: 27 additions & 9 deletions domain/backchannel_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jwt>` 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.
Expand Down Expand Up @@ -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"`
Expand Down
8 changes: 8 additions & 0 deletions domain/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
29 changes: 27 additions & 2 deletions internal/service/backchannel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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),
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion internal/service/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/COMPLIANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading