From 806f6d559fc711b8cc0c508c158a522033e910c8 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Tue, 26 May 2026 09:29:45 +0800 Subject: [PATCH] feat: RFC 9396 Rich Authorization Requests for CIBA (bc-authorize side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RFC 9396 `authorization_details` support to the CIBA bc-authorize endpoint. Clients can now pass a typed JSON array describing exactly what is being authorized at finer granularity than `scope` — e.g. a specific tool call with bound parameters — and the BackchannelNotifier hook receives the parsed typed slice so deployer-supplied approver UX can render typed approval prompts (vs the legacy "approve this scope" shape). Scope of this PR is bc-authorize-side only. Token-side wiring (RFC 9396 §7: include approved `authorization_details` in the access-token JWT claims and in introspection responses) is a follow-up so resource servers can read approved details for receipt-chain commitment. This PR is everything AuthN needs to begin its BackchannelNotifier implementation. ## What ships - `migrations/027_rar_authorization_details.{up,down}.sql` — adds a JSONB `authorization_details` column on `backchannel_auth_requests` with `NOT NULL DEFAULT '[]'::jsonb` so pre-RAR rows surface as an empty array and consumer code stays branch-free. - `domain/backchannel_auth.go` — `AuthorizationDetail` / `AuthorizationDetails` types preserving each element's raw JSON verbatim; `ParseAuthorizationDetails` enforces RFC 9396 outer shape (array of objects, each with a non-empty string `type` field); `MaxAuthorizationDetailsBytes = 64 KB` per-request cap; `ErrAuthorizationDetailsMalformed` + `ErrAuthorizationDetailsOversized` sentinels for errors.Is. - `internal/service/backchannel.go` — request-input field, size cap, parse + validate path, per-type validator registry guarded by RWMutex, `dispatchNotifierWithRAR` that threads the typed slice through to the notifier payload. Validator invocation wrapped in `runRARValidator` so a buggy deployer-registered validator (panic or nil-deref) maps to invalid_authorization_details instead of escaping as HTTP 500 via chi's Recoverer. - `hooks.go` — `BackchannelNotification.AuthorizationDetails` field; `AuthorizationDetailValidator` type — the public surface deployers register against. - `server.go` — `Server.RegisterAuthorizationDetailValidator(typ, fn)` exposed at the top-level package; the existing `SetBackchannelNotifier` wrapping now threads the typed slice through. `oauthFormCompatMiddleware` special-cases JSON-shaped form fields (currently `authorization_details`) so form-encoded clients per RFC 9396 §2.1 see their JSON arrays bind correctly, not get string-flattened by the default form→JSON bridge. - `internal/handler/oauth.go` — `BcAuthorizeInput.AuthorizationDetails` field; pass-through to the service. ## Validation depth Permissive by default. zeroid validates only: - Outer shape: JSON array of objects. - Per-element shape: each object has a `type` field that is a non-empty string. Per-type schema validation is opt-in via the `Server.RegisterAuthorizationDetailValidator(typ, fn)` hook. This matches how Auth0 and Okta handle CIBA RAR — the library accepts the shape, the deployer (here, AuthN in Highflame's stack) layers strict type-aware validation on top. Strict deployers can register a validator for every accepted type; an unregistered type passes outer-shape validation and proceeds to the notifier. Rejections at any layer return the OAuth error code `invalid_authorization_details` per RFC 9396 §5.4 — distinct from `invalid_request` so clients can branch on the error code rather than the description string. This includes deployer-validator panics, which are caught and surfaced as invalid_authorization_details with "validator panicked: ..." in the description. ## Content-type support Both `application/json` and `application/x-www-form-urlencoded` request bodies are supported per RFC 9396 §2.1. The form middleware detects JSON-shaped fields (authorization_details today; extensible via `jsonShapedFormFields`) and passes them through as raw JSON so the downstream binder sees the original array shape instead of the default string-flatten. ## What does NOT ship (follow-up PR 2) - Approved `authorization_details` propagated into the access-token JWT claims (RFC 9396 §7 / token-side). - Approved `authorization_details` included in `/oauth2/token/introspect` responses. - Resource-server reflection helpers (zeroid SDK side, for clients reading the approved details out of an introspected token). ## Out of scope (separate efforts) - A GIN index on the JSONB column — nothing queries by authorization_details content today. Add when usage emerges. - Strict type registry inside zeroid — the permissive + opt-in validator pattern was chosen explicitly in highflame-architecture/adrs/0002 (CIBA + RAR for AARM STEP_UP). ## Verification - `go build ./...` and `go vet ./...` — clean - `go test ./...` — all packages green: * 24 domain sub-tests in `ParseAuthorizationDetails` covering backward-compatible inputs (nil, empty, null, [], whitespace), outer-shape failures, per-element failures, and round-trip preservation of unknown fields in `Raw`. * 8 integration test functions in `ciba_rar_test.go`: notifier receives parsed details, backward-compatible omission, explicit empty array, malformed cases mapped to invalid_authorization_details, per-type validator end-to-end, validator panic surfacing as the OAuth error code, form-encoded RAR happy path, form-encoded malformed surfacing the OAuth error code. * Pre-existing `TestCIBA_PollingLifecycle`, ping mode, push mode, and compliance suites all still pass. ## Related - ADR 0002 (AARM STEP_UP uses CIBA + RAR): https://github.com/highflame-ai/highflame-architecture/blob/main/adrs/0002-aarm-stepup-defer-protocol.md - Wave B v1 (Shield-side annotation validation): https://github.com/highflame-ai/highflame-shield/pull/193 (merged) Co-Authored-By: Claude Opus 4.7 --- README.md | 3 + docs/rar.md | 232 ++++++++++ domain/backchannel_auth.go | 152 ++++++- domain/backchannel_auth_test.go | 207 +++++++++ hooks.go | 35 ++ internal/handler/oauth.go | 11 + internal/service/backchannel.go | 251 +++++++++-- .../027_rar_authorization_details.down.sql | 4 + .../027_rar_authorization_details.up.sql | 29 ++ server.go | 76 +++- tests/integration/COMPLIANCE.md | 1 + tests/integration/ciba_rar_test.go | 423 ++++++++++++++++++ tests/integration/rar_compliance_test.go | 339 ++++++++++++++ 13 files changed, 1708 insertions(+), 55 deletions(-) create mode 100644 docs/rar.md create mode 100644 domain/backchannel_auth_test.go create mode 100644 migrations/027_rar_authorization_details.down.sql create mode 100644 migrations/027_rar_authorization_details.up.sql create mode 100644 tests/integration/ciba_rar_test.go create mode 100644 tests/integration/rar_compliance_test.go diff --git a/README.md b/README.md index f9fe60f1..0a082455 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ OAuth/OIDC authenticates a human to a service. **ZeroID implements true delegate - **DPoP Sender-Constrained Tokens** — RFC 9449. Clients may attach a `DPoP` proof JWT to any `/oauth2/token` call; the issued token then carries `cnf.jkt` and `token_type: "DPoP"`. Proof replay is blocked by an atomic `dpop_jti` upsert (DB primary key — no pre-check race). Resource servers retrieve `cnf` via introspection and validate the per-request proof themselves. Full reference: [`docs/dpop-and-dcr.md`](docs/dpop-and-dcr.md). - **Dynamic Client Registration** — RFC 7591 (`POST /oauth2/register`) gated by an initial access token with the `client:register` scope, plus RFC 7592 management (`GET`/`PUT`/`DELETE /oauth2/register/{client_id}`) authenticated by a one-shot `registration_access_token` (bcrypt-hashed at rest, constant-time lookup). Internal admin-registered clients remain isolated from DCR — the delete path refuses to touch `registration_source = 'internal'`. Full reference: [`docs/dpop-and-dcr.md`](docs/dpop-and-dcr.md). - **CIBA Backchannel Approval** — OpenID Client-Initiated Backchannel Authentication (CIBA Core 1.0). Agent posts to `/oauth2/bc-authorize` with a `binding_message`; the deployer's `BackchannelNotifier` prompts the end user out-of-band (email, Slack, mobile push); user approves or denies; agent receives the resulting token via poll, ping callback, or push delivery. SSRF-guarded outbound callbacks, per-tenant audit, single-use `auth_req_id`s. +- **Rich Authorization Requests (RAR)** — RFC 9396. Agents can attach a typed `authorization_details` JSON array to a CIBA `/oauth2/bc-authorize` call describing exactly what is being authorized at finer granularity than `scope` — e.g. `{"type": "tool_call", "tool": "transfer_funds", "amount": 50000}`. The `BackchannelNotifier` receives the parsed typed slice so the approver UX can render a per-action prompt instead of "approve this scope." Per-type schema validation is opt-in via `Server.RegisterAuthorizationDetailValidator(typ, fn)`. JSON and form-encoded bodies both supported. Rejections map to the RFC 9396 `invalid_authorization_details` OAuth error code. Full reference: [`docs/rar.md`](docs/rar.md). - **On-Behalf-Of (OBO) Delegation** — RFC 8693 token exchange with automatic scope attenuation at each hop, delegation depth tracking, and cascade revocation when any upstream credential is revoked. The `act` claim carries the full chain per RFC 8693, closing the auditability gap that plagues shared service accounts. - **WIMSE/SPIFFE URIs** — Stable, globally unique identity URIs: `spiffe://{domain}/{account}/{project}/{type}/{id}` for every agent. Tokens carry the WIMSE URI as `sub`, so every downstream system receives a meaningful, verifiable identity—not just a client ID. - **Credential Policies** — Governance templates that enforce TTL, allowed grant types, required trust levels, and max delegation depth. Defines each agent's operational envelope programmatically, replacing per-action consent with policy-based controls. @@ -937,6 +938,7 @@ References: [OpenID Agentic AI](https://openid.net/wp-content/uploads/2025/10/Id | Shared Signals Framework (SSF) | OpenID SSF | Real-time revocation event propagation | | CAEP | OpenID CAEP | Continuous access evaluation signals | | CIBA | OpenID CIBA Core 1.0 | Out-of-band user approval for agent-initiated actions (poll / ping / push) | +| Rich Authorization Requests | RFC 9396 | Typed `authorization_details` on CIBA bc-authorize for per-action approval prompts (vs scope-string) | | DPoP | RFC 9449 | Sender-constrained access tokens — proof-of-possession at `/oauth2/token` and at the resource server | | JWK Thumbprint | RFC 7638 | DPoP `cnf.jkt` key binding | | Dynamic Client Registration | RFC 7591 | Self-service OAuth client registration with initial access token gating | @@ -954,6 +956,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. - **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 new file mode 100644 index 00000000..c56449e7 --- /dev/null +++ b/docs/rar.md @@ -0,0 +1,232 @@ +# Rich Authorization Requests (RFC 9396) — Reference + +ZeroID implements [RFC 9396](https://datatracker.ietf.org/doc/html/rfc9396) on the CIBA bc-authorize endpoint so agents can request per-action approval with typed payloads instead of the coarse-grained `scope` string. Two motivating examples: + +- **Scope** says: *"this agent may call `payments:write`."* Coarse. The end user sees a checkbox. +- **RAR** says: *"this agent wants to call `tool_call` `transfer_funds` for amount `50000` from account `acct_X`."* The end user sees a per-action prompt with bound parameters they can verify before approving. + +For the wire-level CIBA flow that RAR rides on, see the README's [CIBA section](../README.md#pattern-6-agent-pauses-for-out-of-band-user-approval-ciba). This document covers what RAR adds on top. + +--- + +## What RAR adds to CIBA + +A CIBA `bc-authorize` request normally carries: + +```json +{ + "client_id": "agent-1", + "account_id": "acct_X", + "project_id": "proj_Y", + "login_hint": "alice@example.com", + "scope": "payments:write", + "binding_message": "Transfer to vendor Z" +} +``` + +With RAR you add an `authorization_details` array of typed objects describing the actual operation: + +```json +{ + "client_id": "agent-1", + "account_id": "acct_X", + "project_id": "proj_Y", + "login_hint": "alice@example.com", + "scope": "payments:write", + "binding_message": "Transfer to vendor Z", + "authorization_details": [ + { + "type": "tool_call", + "tool": "transfer_funds", + "amount": 50000, + "currency": "USD", + "destination": "acct_Vendor_Z" + } + ] +} +``` + +The deployer-supplied `BackchannelNotifier` then receives the parsed typed slice and renders an approval prompt that shows the actual operation — `tool`, `amount`, `destination` — rather than just the scope name. + +### Multiple actions per request + +A single bc-authorize call can carry several actions; the approver UX can render them as a combined prompt the user approves or denies as a unit: + +```json +"authorization_details": [ + { "type": "tool_call", "tool": "transfer_funds", "amount": 50000 }, + { "type": "audit_entry", "trace": "txn-2025-05-26-001", "actions": ["log"] } +] +``` + +ZeroID preserves declaration order so the approver UX renders entries in the sequence the client supplied. + +--- + +## Validation model — permissive by default, opt-in strict per-type + +### What ZeroID validates unconditionally + +ZeroID enforces only the RFC 9396 outer-shape contract: + +1. The top-level value MUST be a JSON array. +2. Every element MUST be a JSON object. +3. Every object MUST have a `type` field whose value is a non-empty string. + +Any violation returns the RFC 9396 OAuth error code: + +```http +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ + "error": "invalid_authorization_details", + "error_description": "authorization_details[0] must be a JSON object with a string `type` field" +} +``` + +This matches how Auth0 and Okta handle CIBA RAR — the library accepts the shape, the deployer layers strict per-type validation on top. + +### How a deployer adds strict per-type validation + +Register a validator against a specific `type`: + +```go +srv := zeroid.NewServer(cfg) + +srv.RegisterAuthorizationDetailValidator( + "tool_call", + func(raw json.RawMessage) error { + var payload struct { + Tool string `json:"tool"` + Amount int `json:"amount"` + Currency string `json:"currency"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return err + } + if _, ok := allowedTools[payload.Tool]; !ok { + return fmt.Errorf("tool %q is not in the deployer's allow-list", payload.Tool) + } + if payload.Amount <= 0 { + return errors.New("amount must be positive") + } + return nil + }, +) +``` + +Semantics: + +- The validator runs at `bc-authorize` time, after outer-shape validation and before the row is persisted. A rejection fails the entire request — partial accept is intentionally not supported. +- The validator's error message is surfaced in `error_description` so clients see *why* their request was rejected. The OAuth error code is always `invalid_authorization_details`. +- Validators MUST be fast (no network calls, no DB queries beyond in-process caches). They run synchronously on the request path. +- Validators that panic are caught — a buggy deployer registration cannot trip the request into an HTTP 500. The panic message is surfaced as `validator panicked: ...` in `error_description`. +- Pass `nil` to `RegisterAuthorizationDetailValidator(typ, nil)` to unregister. +- Unregistered types pass through with outer-shape validation only. There is no catch-all / fallback hook in this release. A strict type allow-list (reject the request when `type` is not in a known set) is NOT expressible via the validator registry alone — the notifier fires after the bc-authorize response is sent, so a notifier-side rejection records `last_notify_error` on the row but does not surface to the client as a 400. Deployers that need strict allow-listing today must front zeroid with a thin shim that screens `authorization_details` before forwarding. A future zeroid release may add a built-in strict-allowlist option / fallback validator hook. + +--- + +## Notifier integration + +The `BackchannelNotifier` hook receives the parsed typed slice through the `AuthorizationDetails` field on `BackchannelNotification`: + +```go +srv.SetBackchannelNotifier(func(ctx context.Context, n zeroid.BackchannelNotification) error { + for i, ad := range n.AuthorizationDetails { + log.Printf("approval %s: detail[%d] type=%s raw=%s", + n.AuthReqID, i, ad.Type, string(ad.Raw)) + } + // Render a typed approval prompt via your push/email/Slack provider. + return notify.Send(ctx, n) +}) +``` + +Each `AuthorizationDetail` carries: + +- `Type` — the type discriminator string, already validated as non-empty. +- `Raw` — the full original JSON object for this element, preserved verbatim. Notifiers can decode it into their own typed struct for rendering, or forward the bytes unchanged to a downstream system. + +When the client does not supply `authorization_details` (legacy CIBA), `n.AuthorizationDetails` is an empty slice — notifiers can branch on `len(n.AuthorizationDetails) == 0` and fall back to the legacy scope-plus-binding-message render path. + +--- + +## Content types + +Per RFC 9396 §3 (and the underlying RFC 6749 request-body conventions), `authorization_details` is accepted both as part of a JSON request body and as a URL-encoded form parameter whose value is the JSON-array string. + +**JSON body** — `application/json`: + +```http +POST /oauth2/bc-authorize HTTP/1.1 +Content-Type: application/json + +{ + "client_id": "agent-1", + ... + "authorization_details": [{ "type": "tool_call", "tool": "transfer_funds" }] +} +``` + +**Form-encoded** — `application/x-www-form-urlencoded`: + +```http +POST /oauth2/bc-authorize HTTP/1.1 +Content-Type: application/x-www-form-urlencoded + +client_id=agent-1&...&authorization_details=%5B%7B%22type%22%3A%22tool_call%22%2C%22tool%22%3A%22transfer_funds%22%7D%5D +``` + +ZeroID's form-compat middleware detects JSON-shaped fields (currently `authorization_details`) and passes them through as raw JSON so the downstream binder sees the original array shape. + +--- + +## Persistence + +`authorization_details` is stored verbatim as a `JSONB` column on `backchannel_auth_requests` (migration `027_rar_authorization_details.up.sql`): + +```sql +ALTER TABLE backchannel_auth_requests + ADD COLUMN authorization_details JSONB NOT NULL DEFAULT '[]'::jsonb; +``` + +- 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. +- No GIN index ships in v1 — nothing today queries by `authorization_details` content. Add one if usage emerges. + +--- + +## Error code mapping + +| Failure | HTTP | `error` | Notes | +| -------------------------------------------------- | ---- | -------------------------------- | ------------------------------------------------------------------------------------ | +| `authorization_details` is not a JSON array | 400 | `invalid_authorization_details` | RFC 9396 §5 outer-shape | +| Any element missing / non-string / empty `type` | 400 | `invalid_authorization_details` | RFC 9396 §2.1 | +| Payload exceeds 64 KiB | 400 | `invalid_authorization_details` | Size cap is ZeroID policy (RFC 9396 leaves this unbounded) | +| Per-type validator returned an error | 400 | `invalid_authorization_details` | Validator's error message surfaces in `error_description` | +| Per-type validator panicked | 400 | `invalid_authorization_details` | Panic recovered and reported as `validator panicked: ...` | + +`error_description` always carries the offending element index when the failure is per-element so operators can pinpoint the bad entry in a multi-element payload. + +--- + +## What's NOT in this PR (follow-up) + +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. + +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. + +--- + +## 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. + +--- + +## Related + +- ZeroID CIBA reference: README → [Pattern 6: Agent pauses for out-of-band user approval](../README.md#pattern-6-agent-pauses-for-out-of-band-user-approval-ciba) +- Highflame ADR 0002 — AARM STEP_UP via CIBA + RAR: [`highflame-architecture/adrs/0002-aarm-stepup-defer-protocol.md`](https://github.com/highflame-ai/highflame-architecture/blob/main/adrs/0002-aarm-stepup-defer-protocol.md) +- RFC 9396 — Rich Authorization Requests: https://datatracker.ietf.org/doc/html/rfc9396 diff --git a/domain/backchannel_auth.go b/domain/backchannel_auth.go index 4369bb66..13a06b7f 100644 --- a/domain/backchannel_auth.go +++ b/domain/backchannel_auth.go @@ -1,6 +1,10 @@ package domain import ( + "bytes" + "encoding/json" + "errors" + "fmt" "time" "github.com/uptrace/bun" @@ -53,6 +57,131 @@ func IsValidBackchannelDeliveryMode(mode string) bool { // Clients submit this at /oauth2/token along with auth_req_id to poll for a token. const GrantTypeCIBA GrantType = "urn:openid:params:grant-type:ciba" +// ─── RFC 9396 OAuth 2.0 Rich Authorization Requests (RAR) ─────────────────── +// +// RAR extends a CIBA bc-authorize request with an `authorization_details` +// parameter — a JSON array of objects, each with a `type` discriminator, +// describing exactly what is being authorized (vs the coarse `scope` +// string). ZeroID stores the array verbatim and exposes it through the +// BackchannelNotifier hook so the deployer's approver UX can render a +// typed approval prompt. Per-type schema validation is opt-in via +// Server.RegisterAuthorizationDetailValidator — zeroid itself validates +// 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 + +// ErrAuthorizationDetailsOversized is returned when the raw JSON exceeds +// MaxAuthorizationDetailsBytes. +var ErrAuthorizationDetailsOversized = errors.New( + "authorization_details exceeds the per-request size cap", +) + +// ErrAuthorizationDetailsMalformed is returned when the raw JSON is not a +// valid array of objects each carrying a non-empty string `type`. +var ErrAuthorizationDetailsMalformed = errors.New( + "authorization_details is not a valid RFC 9396 array of typed objects", +) + +// AuthorizationDetail is one entry in the RAR `authorization_details` array. +// +// Type is the RFC 9396 type discriminator (required, non-empty string) — the +// only field zeroid validates. Raw is the full original JSON object preserved +// verbatim so consumers (per-type validators, the BackchannelNotifier, the +// future token-side JWT-embed) can decode their own typed shapes without +// re-stringifying or normalising the bytes. +type AuthorizationDetail struct { + Type string `json:"type"` + Raw json.RawMessage `json:"-"` +} + +// AuthorizationDetails is the parsed slice form, used by service code and +// the BackchannelNotifier hook. On the bun model the column is stored as a +// json.RawMessage (the array as a whole); ParseAuthorizationDetails decodes +// it into this typed slice. Round-trip preserves bytes: marshalling the +// slice back produces equivalent JSON (key order may differ; element order +// is preserved). +type AuthorizationDetails []AuthorizationDetail + +// ParseAuthorizationDetails decodes raw JSON into a typed slice, enforcing +// the outer-shape contract: top-level is a JSON array, each element is a +// JSON object, each object has a non-empty string `type` field. An empty +// or null input returns (nil, nil) — backward-compatible with pre-RAR +// rows / clients that omit the parameter. +// +// Returns ErrAuthorizationDetailsMalformed on any structural failure +// (wrapped with a descriptive index/reason for operator-facing logs). +// Does NOT invoke per-type validators — that is the service layer's +// responsibility after this parse succeeds. +func ParseAuthorizationDetails(raw []byte) (AuthorizationDetails, error) { + // Treat empty, whitespace-only, and the literal JSON null as "no RAR + // supplied" — backward compatible with clients that omit the parameter. + // Trim first so the contract matches what the doc comment promises; the + // whitespace case is unreachable from the HTTP path today (Huma's JSON + // decode would never hand us bare whitespace bytes) but the explicit + // trim removes a code/doc mismatch for any future direct caller of + // this function. + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, nil + } + + // Outer must be an array. + var elements []json.RawMessage + if err := json.Unmarshal(trimmed, &elements); err != nil { + return nil, fmt.Errorf("%w: outer must be a JSON array: %w", + ErrAuthorizationDetailsMalformed, err) + } + + if len(elements) == 0 { + return nil, nil + } + + out := make(AuthorizationDetails, 0, len(elements)) + + for i, el := range elements { + // Each element must be a JSON object (not array/string/number/null). + // Decode the `type` discriminator to enforce the contract; preserve + // the full raw bytes for downstream consumers. + var probe struct { + Type *string `json:"type"` + } + + if err := json.Unmarshal(el, &probe); err != nil { + return nil, fmt.Errorf( + "%w: element[%d] must be a JSON object with a string `type` field: %w", + ErrAuthorizationDetailsMalformed, i, err, + ) + } + + if probe.Type == nil { + return nil, fmt.Errorf( + "%w: element[%d] is missing the required `type` field", + ErrAuthorizationDetailsMalformed, i, + ) + } + + if *probe.Type == "" { + return nil, fmt.Errorf( + "%w: element[%d] has an empty `type` (must be a non-empty string)", + ErrAuthorizationDetailsMalformed, i, + ) + } + + out = append(out, AuthorizationDetail{ + Type: *probe.Type, + Raw: el, + }) + } + + return out, nil +} + // BackchannelAuthRequest is a persisted CIBA authentication request. // // The row is created on POST /oauth2/bc-authorize. The auth_req_id is the @@ -65,13 +194,22 @@ const GrantTypeCIBA GrantType = "urn:openid:params:grant-type:ciba" type BackchannelAuthRequest struct { bun.BaseModel `bun:"table:backchannel_auth_requests,alias:bcr"` - AuthReqID string `bun:"auth_req_id,pk,type:varchar(255)" json:"auth_req_id"` - AccountID string `bun:"account_id,type:varchar(255)" json:"account_id"` - ProjectID string `bun:"project_id,type:varchar(255)" json:"project_id"` - ClientID string `bun:"client_id,type:varchar(255)" json:"client_id"` - LoginHint string `bun:"login_hint,type:text" json:"login_hint,omitempty"` - Scope string `bun:"scope,type:text" json:"scope,omitempty"` - BindingMessage string `bun:"binding_message,type:text" json:"binding_message,omitempty"` + AuthReqID string `bun:"auth_req_id,pk,type:varchar(255)" json:"auth_req_id"` + AccountID string `bun:"account_id,type:varchar(255)" json:"account_id"` + ProjectID string `bun:"project_id,type:varchar(255)" json:"project_id"` + ClientID string `bun:"client_id,type:varchar(255)" json:"client_id"` + LoginHint string `bun:"login_hint,type:text" json:"login_hint,omitempty"` + Scope string `bun:"scope,type:text" json:"scope,omitempty"` + BindingMessage string `bun:"binding_message,type:text" json:"binding_message,omitempty"` + // AuthorizationDetailsRaw is the RFC 9396 `authorization_details` JSON + // array as supplied on bc-authorize, preserved verbatim. Stored as a + // 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. + 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"` ClientNotificationToken string `bun:"client_notification_token,type:varchar(1024)" json:"-"` diff --git a/domain/backchannel_auth_test.go b/domain/backchannel_auth_test.go new file mode 100644 index 00000000..7ec2c40c --- /dev/null +++ b/domain/backchannel_auth_test.go @@ -0,0 +1,207 @@ +package domain_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/highflame-ai/zeroid/domain" +) + +// TestParseAuthorizationDetails_BackwardCompatible covers the legacy CIBA +// path: a client that omits the `authorization_details` parameter, or sends +// it as JSON null / empty array, must produce a nil-or-empty typed slice and +// no error. This is the contract that lets pre-RAR clients keep working +// unchanged after the field lands. +func TestParseAuthorizationDetails_BackwardCompatible(t *testing.T) { + cases := []struct { + name string + raw []byte + }{ + {"nil bytes", nil}, + {"empty bytes", []byte{}}, + {"json null", []byte("null")}, + {"empty array", []byte("[]")}, + // Whitespace-only inputs are unreachable from the HTTP path today + // (Huma's JSON decoder would never hand us bare whitespace bytes), + // but the function's contract promises they are treated as "no RAR + // supplied". These cases pin that contract so a future refactor + // can't silently regress it. + {"single space", []byte(" ")}, + {"multiple spaces", []byte(" ")}, + {"newline", []byte("\n")}, + {"mixed whitespace", []byte("\t\n ")}, + {"json null with surrounding whitespace", []byte(" null ")}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := domain.ParseAuthorizationDetails(c.raw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(got) != 0 { + t.Errorf("expected empty slice, got len=%d", len(got)) + } + }) + } +} + +// TestParseAuthorizationDetails_SingleElement validates a well-formed +// single-element payload: the typed slice has one entry whose Type matches +// and whose Raw preserves the original JSON object bytes verbatim. +func TestParseAuthorizationDetails_SingleElement(t *testing.T) { + raw := []byte(`[{"type":"highflame_tool_call","tool":"transfer_funds","amount":50000}]`) + got, err := domain.ParseAuthorizationDetails(raw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(got) != 1 { + t.Fatalf("expected 1 element, got %d", len(got)) + } + + if got[0].Type != "highflame_tool_call" { + t.Errorf("type = %q, want highflame_tool_call", got[0].Type) + } + + // Raw bytes preserved verbatim (key order, whitespace) for downstream + // consumers that need to forward the exact payload they received. + want := `{"type":"highflame_tool_call","tool":"transfer_funds","amount":50000}` + if string(got[0].Raw) != want { + t.Errorf("raw = %q, want %q", got[0].Raw, want) + } +} + +// TestParseAuthorizationDetails_MultiElement covers RFC 9396's multi-element +// array: an approver may need to authorize several related actions in one +// request. The parser preserves declaration order so the approver UX can +// render the array in the order the client supplied. +func TestParseAuthorizationDetails_MultiElement(t *testing.T) { + raw := []byte(`[ + {"type":"transfer","amount":100}, + {"type":"notify","channel":"email"}, + {"type":"log","level":"info"} + ]`) + got, err := domain.ParseAuthorizationDetails(raw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(got) != 3 { + t.Fatalf("expected 3 elements, got %d", len(got)) + } + + wantTypes := []string{"transfer", "notify", "log"} + for i, want := range wantTypes { + if got[i].Type != want { + t.Errorf("element[%d].type = %q, want %q", i, got[i].Type, want) + } + } +} + +// TestParseAuthorizationDetails_OuterShape covers the malformed-outer cases: +// not a JSON array, not parseable JSON, etc. Every failure must wrap +// ErrAuthorizationDetailsMalformed so callers using errors.Is can detect +// the failure class without parsing the error string. +func TestParseAuthorizationDetails_OuterShape(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"plain string", `"not an array"`}, + {"plain object", `{"type":"foo"}`}, + {"number", `42`}, + {"truncated", `[{"type":"x"`}, + {"comma trailing", `[{"type":"x"},]`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := domain.ParseAuthorizationDetails([]byte(c.raw)) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, domain.ErrAuthorizationDetailsMalformed) { + t.Errorf("expected ErrAuthorizationDetailsMalformed wrap, got %v", err) + } + }) + } +} + +// TestParseAuthorizationDetails_ElementShape covers per-element failures: +// missing `type`, non-string `type`, empty `type`, non-object element. RFC +// 9396 §2 mandates the `type` discriminator on every element. +func TestParseAuthorizationDetails_ElementShape(t *testing.T) { + cases := []struct { + name string + raw string + }{ + {"missing type", `[{"foo":"bar"}]`}, + {"empty string type", `[{"type":""}]`}, + {"non-string type", `[{"type":42}]`}, + {"null type", `[{"type":null}]`}, + {"non-object element", `[42]`}, + {"string element", `["not an object"]`}, + {"nested array element", `[["nope"]]`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := domain.ParseAuthorizationDetails([]byte(c.raw)) + if err == nil { + t.Fatalf("expected error for %q, got nil", c.raw) + } + + if !errors.Is(err, domain.ErrAuthorizationDetailsMalformed) { + t.Errorf("expected ErrAuthorizationDetailsMalformed wrap, got %v", err) + } + }) + } +} + +// TestParseAuthorizationDetails_ErrorCarriesIndex confirms the parser's +// error message names the offending element index. Operator-facing logs +// need this to pinpoint which entry in a multi-element payload is at fault. +func TestParseAuthorizationDetails_ErrorCarriesIndex(t *testing.T) { + // Two valid elements followed by one malformed → error must reference + // element[2]. + raw := []byte(`[ + {"type":"ok"}, + {"type":"also_ok"}, + {"foo":"bar"} + ]`) + _, err := domain.ParseAuthorizationDetails(raw) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "element[2]") { + t.Errorf("error %q does not name element[2]", err) + } +} + +// TestAuthorizationDetail_RawPreservesUnknownFields verifies that the +// Raw bytes carry every field the client supplied (not just `type`). This +// is the contract per-type validators rely on — they need access to the +// full payload to enforce their own schemas. +func TestAuthorizationDetail_RawPreservesUnknownFields(t *testing.T) { + raw := []byte(`[{"type":"highflame_tool_call","tool":"transfer","actions":["execute"],"locations":["acct_X"],"datatypes":["pii"]}]`) + got, err := domain.ParseAuthorizationDetails(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Decode the raw bytes back into a generic map to verify every field + // survived round-trip. + var m map[string]any + if err := json.Unmarshal(got[0].Raw, &m); err != nil { + t.Fatalf("raw bytes do not decode as JSON object: %v", err) + } + + for _, key := range []string{"type", "tool", "actions", "locations", "datatypes"} { + if _, ok := m[key]; !ok { + t.Errorf("raw bytes lost field %q", key) + } + } +} diff --git a/hooks.go b/hooks.go index ac579e99..09b6fc74 100644 --- a/hooks.go +++ b/hooks.go @@ -94,6 +94,15 @@ type BackchannelNotification struct { Scope string BindingMessage string ExpiresAt time.Time + // AuthorizationDetails carries the RFC 9396 RAR payload parsed at + // bc-authorize time. Empty when the client did not supply + // authorization_details (legacy CIBA flow), or when the payload was + // rejected by a registered per-type validator (in which case the + // request was never created and this notifier is not invoked). + // Notifiers should render typed approval prompts from this field when + // non-empty; scope and binding_message remain the fallback for clients + // that have not adopted RAR. + AuthorizationDetails domain.AuthorizationDetails } // BackchannelNotifier delivers a CIBA approval prompt to the end user via an @@ -104,3 +113,29 @@ type BackchannelNotification struct { // debuggability but does not block request creation — the user may approve // through another channel. type BackchannelNotifier func(ctx context.Context, n BackchannelNotification) error + +// AuthorizationDetailValidator is the deployer-supplied per-type validator +// for RFC 9396 RAR `authorization_details` entries. Registered against a +// specific `type` discriminator via Server.RegisterAuthorizationDetailValidator; +// invoked at bc-authorize time for every element whose `type` field matches. +// +// The validator receives the original JSON bytes of the element (preserving +// key order and any deployer-specific fields beyond `type`). It MUST return +// nil to accept or a descriptive error to reject — a rejection fails the +// entire bc-authorize request with OAuth error `invalid_authorization_details` +// (RFC 9396 §5.4). +// +// The registry is strictly per-`type`: unregistered `type` values pass +// outer-shape validation and are forwarded to the BackchannelNotifier +// with no extra checks. A type-allowlist that REJECTS unknown types is +// not expressible via this hook in the current release — there is no +// catch-all / fallback registration, and the BackchannelNotifier fires +// after the bc-authorize response is sent (an error there records +// `last_notify_error` on the row but does not surface as a 400 to the +// client). Deployers that need strict allow-listing today must front +// zeroid with a thin shim that screens `authorization_details` before +// forwarding. A future release may add a fallback validator hook. +// +// Validators run synchronously on the bc-authorize request path; keep them +// fast (no network I/O, no DB queries beyond in-process caches). +type AuthorizationDetailValidator func(raw json.RawMessage) error diff --git a/internal/handler/oauth.go b/internal/handler/oauth.go index b87992cd..a82f1dab 100644 --- a/internal/handler/oauth.go +++ b/internal/handler/oauth.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "errors" "net/http" @@ -337,6 +338,15 @@ type BcAuthorizeInput struct { BindingMessage string `json:"binding_message,omitempty" doc:"Human-readable context shown to the user during approval"` RequestedExpiry int `json:"requested_expiry,omitempty" doc:"Auth-request TTL in seconds; bounded by server default"` ClientNotificationToken string `json:"client_notification_token,omitempty" doc:"Bearer the server echoes in the ping callback (required for ping mode)"` + // AuthorizationDetails is the RFC 9396 Rich Authorization Requests + // payload — a JSON array of typed objects describing what is being + // authorized at a finer granularity than scope. ZeroID validates + // the outer shape (array of objects, each with a non-empty string + // `type` field) and runs any registered per-type validators; the + // raw bytes are persisted on the auth request row and delivered + // to the BackchannelNotifier hook for typed approval-prompt + // rendering. Empty / omitted keeps the legacy CIBA flow unchanged. + AuthorizationDetails json.RawMessage `json:"authorization_details,omitempty" doc:"RFC 9396 Rich Authorization Requests payload (JSON array of typed objects)"` } } @@ -365,6 +375,7 @@ func (a *API) bcAuthorizeOp(ctx context.Context, input *BcAuthorizeInput) (*BcAu BindingMessage: input.Body.BindingMessage, RequestedExpiry: input.Body.RequestedExpiry, ClientNotificationToken: input.Body.ClientNotificationToken, + AuthorizationDetailsRaw: []byte(input.Body.AuthorizationDetails), }) if err != nil { log.Error().Err(err).Str("client_id", input.Body.ClientID).Msg("bc-authorize failed") diff --git a/internal/service/backchannel.go b/internal/service/backchannel.go index 7dd150f9..620493a5 100644 --- a/internal/service/backchannel.go +++ b/internal/service/backchannel.go @@ -43,6 +43,15 @@ type BackchannelService struct { notifier BackchannelNotifierFunc notifyDispatchAsync bool // overridable for tests + // rarValidators is the deployer-supplied per-type validator registry + // for RFC 9396 authorization_details. Guarded by mu (the same lock + // that protects notifier) so a concurrent Register/Unregister cannot + // race with a bc-authorize handler reading the map. Map writes are + // rare (deployer-side at server-init); reads are per-request — but + // the map is small (single-digit entries in practice) so RLock + map + // lookup is well under microsecond cost. + rarValidators map[string]AuthorizationDetailValidator + // svcCtx is the long-lived context used by detached notifier goroutines. // Server.Shutdown cancels it via Stop() so in-flight notifier deliveries // can wind down on graceful shutdown instead of leaking past the server's @@ -65,18 +74,25 @@ type BackchannelService struct { // that internal callers don't need to import the top-level package. type BackchannelNotifierFunc func(ctx context.Context, n BackchannelNotification) error +// AuthorizationDetailValidator is the internal alias for the public +// zeroid.AuthorizationDetailValidator. See the top-level package's doc +// for semantics; the wrapper in server.go bridges the two type names so +// internal callers don't reach across packages. +type AuthorizationDetailValidator func(raw json.RawMessage) error + // BackchannelNotification is the payload delivered to the notifier. // Mirrors the public zeroid.BackchannelNotification shape — the top-level // Server.SetBackchannelNotifier hook wraps the public type into this one. type BackchannelNotification struct { - AuthReqID string - AccountID string - ProjectID string - ClientID string - LoginHint string - Scope string - BindingMessage string - ExpiresAt time.Time + AuthReqID string + AccountID string + ProjectID string + ClientID string + LoginHint string + Scope string + BindingMessage string + ExpiresAt time.Time + AuthorizationDetails domain.AuthorizationDetails } // BackchannelServiceConfig bounds the request lifecycle. @@ -200,6 +216,42 @@ func (s *BackchannelService) SetNotifyDispatchSync(sync bool) { s.notifyDispatchAsync = !sync } +// RegisterAuthorizationDetailValidator wires a deployer-supplied validator +// for the named RAR `type` discriminator. Replaces any prior validator for +// the same type. Safe to call concurrently with bc-authorize handling — +// the registry is read under RLock per request. +func (s *BackchannelService) RegisterAuthorizationDetailValidator(typ string, fn AuthorizationDetailValidator) { + if typ == "" || fn == nil { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.rarValidators == nil { + s.rarValidators = make(map[string]AuthorizationDetailValidator) + } + + s.rarValidators[typ] = fn +} + +// UnregisterAuthorizationDetailValidator removes the validator for typ if +// one was registered. No-op when no validator is registered. +func (s *BackchannelService) UnregisterAuthorizationDetailValidator(typ string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.rarValidators, typ) +} + +// rarValidatorFor returns the registered validator for typ, or nil if none. +// Reads under RLock so concurrent bc-authorize handlers don't serialise. +func (s *BackchannelService) rarValidatorFor(typ string) AuthorizationDetailValidator { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.rarValidators[typ] +} + // SetPingTransport overrides the outbound HTTP transport used for CIBA ping // dispatch. Tests inject a capturing RoundTripper here so they don't have to // stand up a real httptest listener. Pass nil to restore the default @@ -231,6 +283,14 @@ type CreateAuthRequestInput struct { // credential in the ping callback's Authorization header. The client // uses it to authenticate the inbound notification. ClientNotificationToken string + // AuthorizationDetailsRaw is the RFC 9396 `authorization_details` JSON + // array as supplied on the bc-authorize form, before parsing or + // 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. + AuthorizationDetailsRaw []byte } // CreateAuthRequestOutput is returned to the client on success. @@ -321,6 +381,22 @@ func (s *BackchannelService) CreateAuthRequest(ctx context.Context, in CreateAut ) } + // RFC 9396 Rich Authorization Requests (RAR). + // + // Three steps in order: + // 1. Size-cap the raw bytes before parsing so a multi-MB payload is + // rejected before allocating the typed slice. + // 2. Parse with domain.ParseAuthorizationDetails — enforces outer + // shape (array of objects, each with a non-empty string `type`). + // 3. Run any deployer-registered per-type validator. RFC 9396 §5.4 + // specifies `invalid_authorization_details` as the OAuth error + // code for any RAR-specific rejection; map both the outer-shape + // failure and any per-type rejection to that code. + rarDetails, rarRaw, err := s.parseAndValidateAuthorizationDetails(in.AuthorizationDetailsRaw) + if err != nil { + return nil, err + } + expiry := time.Duration(in.RequestedExpiry) * time.Second if expiry <= 0 { expiry = s.cfg.DefaultExpiry @@ -343,6 +419,7 @@ func (s *BackchannelService) CreateAuthRequest(ctx context.Context, in CreateAut LoginHint: in.LoginHint, Scope: in.Scope, BindingMessage: bindingMsg, + AuthorizationDetailsRaw: rarRaw, NotificationMode: notificationMode, ClientNotificationEndpoint: notificationEndpoint, ClientNotificationToken: in.ClientNotificationToken, @@ -355,7 +432,7 @@ func (s *BackchannelService) CreateAuthRequest(ctx context.Context, in CreateAut return nil, oauthServerError("failed to persist backchannel auth request", err) } - s.dispatchNotifier(ctx, row) + s.dispatchNotifierWithRAR(ctx, row, rarDetails) return &CreateAuthRequestOutput{ AuthReqID: authReqID, @@ -651,45 +728,141 @@ func (s *BackchannelService) DeleteExpired(ctx context.Context, now time.Time) ( return s.repo.DeleteExpired(ctx, now) } -// dispatchNotifier fires the notifier hook on a goroutine so notifier latency -// (third-party push providers, SMS APIs) does not block the bc-authorize -// response. Failures are recorded on the row's last_notify_error for -// operator debugging — the request remains valid because the user may -// approve through another channel. +// parseAndValidateAuthorizationDetails enforces RFC 9396 §2 outer shape on +// the raw `authorization_details` JSON, then runs any registered per-type +// validators. Returns: +// - the typed slice (nil-or-empty for callers that omit the parameter, +// keeping the legacy CIBA path branch-free), +// - the canonical raw bytes to persist on the row (nil if the client +// supplied nothing, to preserve the empty-array DEFAULT semantics in +// the Postgres column), +// - an OAuth-shaped error mapped to invalid_authorization_details for +// any rejection (RFC 9396 §5.4). +func (s *BackchannelService) parseAndValidateAuthorizationDetails(raw []byte) (domain.AuthorizationDetails, []byte, error) { + // emptyRAR is the canonical persisted value for "client supplied no + // authorization_details" — the same JSON shape as the column's DB + // default. We persist this explicitly (instead of letting bun emit + // NULL via a `nullzero` tag and relying on Postgres to swap NULL for + // DEFAULT — which Postgres does not do) so the insert path is + // independent of bun's zero-value handling. Belt-and-suspenders with + // the migration's NOT NULL DEFAULT '[]'::jsonb. + emptyRAR := []byte("[]") + + if len(raw) == 0 { + return nil, emptyRAR, nil + } + + if len(raw) > domain.MaxAuthorizationDetailsBytes { + return nil, nil, oauthBadRequestCause( + "invalid_authorization_details", + fmt.Sprintf("authorization_details exceeds %d bytes", domain.MaxAuthorizationDetailsBytes), + fmt.Errorf("%w: length %d > cap %d", + domain.ErrAuthorizationDetailsOversized, + len(raw), domain.MaxAuthorizationDetailsBytes), + ) + } + + parsed, err := domain.ParseAuthorizationDetails(raw) + if err != nil { + return nil, nil, oauthBadRequestCause( + "invalid_authorization_details", + "authorization_details is not a valid RFC 9396 array of typed objects", + err, + ) + } + + if len(parsed) == 0 { + // Empty array or `null` — treat as if the client omitted the + // parameter. Persist the canonical empty array so the row's + // authorization_details column always carries a valid JSONB + // value, never NULL. + return nil, emptyRAR, nil + } + + // Run any deployer-registered per-type validator. Types without a + // registration pass through (outer-shape-only validation is the + // permissive default). Strict allow-listing (reject unknown types + // during bc-authorize) is not expressible via this registry — see + // the public docs on Server.RegisterAuthorizationDetailValidator. + for i, d := range parsed { + fn := s.rarValidatorFor(d.Type) + if fn == nil { + continue + } + + // runRARValidator wraps fn in a defer-recover so a buggy + // deployer-registered validator (nil-deref, library panic) maps + // to invalid_authorization_details rather than escaping as + // HTTP 500 via chi's Recoverer. RFC 9396 §5.4 is the only error + // code clients should see for any RAR-side rejection. + if vErr := runRARValidator(fn, d.Raw); vErr != nil { + return nil, nil, oauthBadRequestCause( + "invalid_authorization_details", + fmt.Sprintf("authorization_details[%d] (type=%q): %s", i, d.Type, vErr.Error()), + vErr, + ) + } + } + + return parsed, raw, nil +} + +// runRARValidator invokes a deployer-registered validator and converts any +// panic into an error so the caller can map it to the RFC 9396 §5.4 OAuth +// error code uniformly with explicit-error returns. +func runRARValidator(fn AuthorizationDetailValidator, raw json.RawMessage) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("validator panicked: %v", r) + } + }() + + return fn(raw) +} + +// dispatchNotifierWithRAR fires the BackchannelNotifier hook with the parsed +// authorization_details threaded through to the notification payload. // -// Tests can flip dispatch to synchronous via SetNotifyDispatchSync(true). -func (s *BackchannelService) dispatchNotifier(ctx context.Context, row *domain.BackchannelAuthRequest) { +// Runs on a goroutine by default so notifier latency (third-party push +// providers, SMS APIs) does not block the bc-authorize response. Failures +// are recorded on the row's last_notify_error for operator debugging — +// the request remains valid because the user may approve through another +// channel. Tests can flip dispatch to synchronous via +// SetNotifyDispatchSync(true). +// +// The inbound request's ctx is intentionally NOT carried into the dispatch: +// the deliver goroutine parents on the service's long-lived svcCtx so a +// client disconnect can't cancel an already-fired approval prompt. Graceful +// shutdown still cancels deliveries via Server.Shutdown → Stop(). +func (s *BackchannelService) dispatchNotifierWithRAR( + _ context.Context, + row *domain.BackchannelAuthRequest, + details domain.AuthorizationDetails, +) { + // Single RLock snapshot of all the shared state the dispatch path + // needs. Holding RLock across the snapshot prevents a Stop() racing + // in between two separate acquisitions and leaving us with a + // half-stale view (e.g., notifier installed, svcCtx already nil). s.mu.RLock() fn := s.notifier async := s.notifyDispatchAsync + parent := s.svcCtx s.mu.RUnlock() - if fn == nil { + + if fn == nil || parent == nil { return } payload := BackchannelNotification{ - AuthReqID: row.AuthReqID, - AccountID: row.AccountID, - ProjectID: row.ProjectID, - ClientID: row.ClientID, - LoginHint: row.LoginHint, - Scope: row.Scope, - BindingMessage: row.BindingMessage, - ExpiresAt: row.ExpiresAt, - } - - // Parent the detached context on svcCtx (cancelled by Server.Shutdown via - // BackchannelService.Stop) instead of context.Background — that way a - // graceful shutdown cancels in-flight notifier deliveries instead of - // letting them outlive the server. We still detach from the inbound - // request's ctx so a client disconnect doesn't kill the notification. - s.mu.RLock() - parent := s.svcCtx - s.mu.RUnlock() - if parent == nil { - // Stop() has been called; service is shutting down — drop the - // notification rather than firing into the void. - return + AuthReqID: row.AuthReqID, + AccountID: row.AccountID, + ProjectID: row.ProjectID, + ClientID: row.ClientID, + LoginHint: row.LoginHint, + Scope: row.Scope, + BindingMessage: row.BindingMessage, + ExpiresAt: row.ExpiresAt, + AuthorizationDetails: details, } deliver := func() { diff --git a/migrations/027_rar_authorization_details.down.sql b/migrations/027_rar_authorization_details.down.sql new file mode 100644 index 00000000..c5e4cbc5 --- /dev/null +++ b/migrations/027_rar_authorization_details.down.sql @@ -0,0 +1,4 @@ +-- 027_rar_authorization_details.down.sql + +ALTER TABLE backchannel_auth_requests + DROP COLUMN IF EXISTS authorization_details; diff --git a/migrations/027_rar_authorization_details.up.sql b/migrations/027_rar_authorization_details.up.sql new file mode 100644 index 00000000..17d385d9 --- /dev/null +++ b/migrations/027_rar_authorization_details.up.sql @@ -0,0 +1,29 @@ +-- 027_rar_authorization_details.up.sql +-- RFC 9396 OAuth 2.0 Rich Authorization Requests (RAR). +-- +-- CIBA + RAR is the standards-compliant way to say "approve this specific +-- transaction" rather than "approve this scope" — the agent passes a typed +-- JSON envelope describing the action's intent (tool name, target, params, +-- AARM context-chain hash, etc.) on POST /oauth2/bc-authorize. ZeroID +-- persists the array verbatim so the approver UX can render the typed +-- details and the resource server can read them back at token-introspection +-- time (the latter wired in a follow-up PR). +-- +-- Storage shape: +-- * JSONB column on backchannel_auth_requests +-- * NOT NULL DEFAULT '[]'::jsonb so pre-RAR rows (no authorization_details +-- supplied by the client) read as an empty array, not NULL — keeps the +-- consumer code branch-free +-- * No GIN index in v1; nothing today queries by authorization_details +-- content. Add one in a follow-up if usage emerges +-- +-- Validation depth at parse time is intentionally permissive: ZeroID checks +-- only the outer shape (array of JSON objects, each with a non-empty string +-- `type` field). Per-type schema validation is opt-in via the +-- Server.RegisterAuthorizationDetailValidator hook so deployers like +-- Highflame can layer strict checks (e.g. highflame_tool_call: tool name +-- must be in the registered tool catalog) without zeroid committing to +-- any specific application schema. + +ALTER TABLE backchannel_auth_requests + ADD COLUMN IF NOT EXISTS authorization_details JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/server.go b/server.go index a0993950..54b7c6a8 100644 --- a/server.go +++ b/server.go @@ -538,18 +538,51 @@ func (s *Server) SetBackchannelNotifier(fn BackchannelNotifier) { } s.backchannelSvc.SetNotifier(func(ctx context.Context, n service.BackchannelNotification) error { return fn(ctx, BackchannelNotification{ - AuthReqID: n.AuthReqID, - AccountID: n.AccountID, - ProjectID: n.ProjectID, - ClientID: n.ClientID, - LoginHint: n.LoginHint, - Scope: n.Scope, - BindingMessage: n.BindingMessage, - ExpiresAt: n.ExpiresAt, + AuthReqID: n.AuthReqID, + AccountID: n.AccountID, + ProjectID: n.ProjectID, + ClientID: n.ClientID, + LoginHint: n.LoginHint, + Scope: n.Scope, + BindingMessage: n.BindingMessage, + ExpiresAt: n.ExpiresAt, + AuthorizationDetails: n.AuthorizationDetails, }) }) } +// RegisterAuthorizationDetailValidator wires a deployer-supplied per-type +// validator for RFC 9396 RAR `authorization_details` entries. Invoked at +// bc-authorize time for every element whose `type` discriminator matches. +// +// The registry is strictly per-`type`. Unregistered `type` values pass +// outer-shape validation and are forwarded to the BackchannelNotifier +// with no extra checks — there is no catch-all / fallback registration. +// A strict allowlist that rejects unknown `type` values during the +// bc-authorize call is not expressible via this hook today; the +// BackchannelNotifier fires after the response is sent, so a +// notifier-side rejection records `last_notify_error` but does not +// surface to the client as a 400. Deployers needing that policy must +// front zeroid with a thin shim that screens `authorization_details` +// before forwarding. +// +// Registering twice with the same type replaces the previous validator; +// passing nil unregisters. Safe to call any time after NewServer. +func (s *Server) RegisterAuthorizationDetailValidator(typ string, fn AuthorizationDetailValidator) { + if s.backchannelSvc == nil { + return + } + + if fn == nil { + s.backchannelSvc.UnregisterAuthorizationDetailValidator(typ) + return + } + + // Wrap the public-typed validator into the service-internal alias so the + // service layer stays decoupled from the top-level package's type names. + s.backchannelSvc.RegisterAuthorizationDetailValidator(typ, service.AuthorizationDetailValidator(fn)) +} + // SetBackchannelNotifyDispatchSync forces synchronous notifier dispatch. // Test-only — production must keep async dispatch (the default) so notifier // latency cannot block the bc-authorize response. @@ -820,6 +853,18 @@ var OAuthFormEndpoints = map[string]struct{}{ "/oauth2/bc-authorize": {}, } +// jsonShapedFormFields are OAuth form parameters whose value is itself JSON +// (a typed array or object), not a scalar string. The form-compat middleware +// special-cases these so the downstream JSON binder sees the original shape +// instead of the default string-flatten (which would quote the JSON and +// break array/object binding silently). +// +// Today only RFC 9396 `authorization_details` qualifies; extend this set as +// new JSON-shaped OAuth parameters land. +var jsonShapedFormFields = map[string]struct{}{ + "authorization_details": {}, +} + // mediaTypeEquals parses a Content-Type header and reports whether the media // type portion matches want (case-insensitive per RFC 7231 §3.1.1.1). // Parameters like charset are ignored for the comparison. @@ -871,7 +916,7 @@ func oauthFormCompatMiddleware(next http.Handler) http.Handler { return } - flat := make(map[string]string, len(r.PostForm)) + flat := make(map[string]any, len(r.PostForm)) for k, vs := range r.PostForm { // RFC 6749 §3.1: request parameters MUST NOT be included more // than once. Duplicate keys are rejected rather than silently @@ -889,6 +934,19 @@ func oauthFormCompatMiddleware(next http.Handler) http.Handler { if vs[0] == "" { continue } + + // JSON-shaped fields (e.g. RFC 9396 authorization_details): pass + // the form value through as raw JSON when it parses, so the + // downstream JSON binder sees the original array/object shape. + // When the value is not valid JSON, fall back to the default + // string-flatten — the downstream parser will then reject it + // with the correct OAuth error code (e.g. + // invalid_authorization_details per RFC 9396 §5.4) rather than + // a generic 400 from this middleware. + if _, jsonShaped := jsonShapedFormFields[k]; jsonShaped && gojson.Valid([]byte(vs[0])) { + flat[k] = gojson.RawMessage(vs[0]) + continue + } flat[k] = vs[0] } diff --git a/tests/integration/COMPLIANCE.md b/tests/integration/COMPLIANCE.md index 92e53066..81ad853e 100644 --- a/tests/integration/COMPLIANCE.md +++ b/tests/integration/COMPLIANCE.md @@ -40,6 +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 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 new file mode 100644 index 00000000..04093796 --- /dev/null +++ b/tests/integration/ciba_rar_test.go @@ -0,0 +1,423 @@ +package integration_test + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestCIBA_RAR_NotifierReceivesParsedDetails covers the happy-path RAR flow +// against /oauth2/bc-authorize: a client posts a well-formed +// authorization_details array, the request is accepted, and the +// BackchannelNotifier hook receives the typed slice with each element's +// Type populated and Raw preserving the original JSON bytes. +// +// This is the load-bearing integration assertion for AuthN's BackchannelNotifier +// implementation downstream — AuthN will read AuthorizationDetails to +// construct typed approval prompts for Studio. If this contract breaks, +// AuthN's typed payload construction breaks silently. +func TestCIBA_RAR_NotifierReceivesParsedDetails(t *testing.T) { + clientID := uid("ciba-rar-client") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + notifier := newRecordingNotifier() + testZeroIDServer.SetBackchannelNotifier(notifier.notify) + testZeroIDServer.SetBackchannelNotifyDispatchSync(true) + t.Cleanup(func() { + testZeroIDServer.SetBackchannelNotifyDispatchSync(false) + testZeroIDServer.SetBackchannelNotifier(nil) + }) + + // Multi-element payload — RFC 9396 explicitly allows N entries per + // request. Two distinct types so the test also covers per-element + // Type fidelity. + resp := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{ + { + "type": "highflame_tool_call", + "tool": "transfer_funds", + "amount": 50000, + }, + { + "type": "highflame_audit", + "trace": "abc-123", + "actions": []string{"log"}, + }, + }, + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode, "bc-authorize must accept well-formed RAR") + + got := notifier.last() + require.NotNil(t, got, "notifier must have been invoked") + require.Len(t, got.AuthorizationDetails, 2, + "notifier must receive both RAR elements") + require.Equal(t, "highflame_tool_call", got.AuthorizationDetails[0].Type) + require.Equal(t, "highflame_audit", got.AuthorizationDetails[1].Type) + + // Raw bytes preserve the full per-element payload (not just `type`). + // AuthN's typed payload construction will decode the Raw into its own + // per-type Go struct, so the contract is "every field the client sent + // survives intact." + var first map[string]any + require.NoError(t, json.Unmarshal(got.AuthorizationDetails[0].Raw, &first)) + require.Equal(t, "transfer_funds", first["tool"]) + require.InEpsilon(t, float64(50000), first["amount"], 0.0001) +} + +// TestCIBA_RAR_BackwardCompatibleWhenOmitted confirms the legacy CIBA path +// is unchanged: a client that omits authorization_details continues to work +// exactly as before. The notifier sees an empty (nil) typed slice — not an +// error, not a non-empty array. +func TestCIBA_RAR_BackwardCompatibleWhenOmitted(t *testing.T) { + clientID := uid("ciba-rar-legacy") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + notifier := newRecordingNotifier() + testZeroIDServer.SetBackchannelNotifier(notifier.notify) + testZeroIDServer.SetBackchannelNotifyDispatchSync(true) + t.Cleanup(func() { + testZeroIDServer.SetBackchannelNotifyDispatchSync(false) + testZeroIDServer.SetBackchannelNotifier(nil) + }) + + resp := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode, + "legacy bc-authorize (no RAR) must keep working unchanged") + + got := notifier.last() + require.NotNil(t, got) + require.Empty(t, got.AuthorizationDetails, + "omitted authorization_details must surface as empty slice on the notification") +} + +// TestCIBA_RAR_MalformedRejects covers the fail-closed outer-shape cases. +// Every rejection MUST come back as invalid_authorization_details (RFC 9396 +// §5.4) — not invalid_request, not server_error — so clients can branch on +// the OAuth error code rather than parsing the description string. +func TestCIBA_RAR_MalformedRejects(t *testing.T) { + clientID := uid("ciba-rar-bad") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + cases := []struct { + name string + body map[string]any + }{ + { + name: "outer is object not array", + body: map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": map[string]any{"type": "x"}, + }, + }, + { + name: "element missing type", + body: map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{{"foo": "bar"}}, + }, + }, + { + name: "element empty type", + body: map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{{"type": ""}}, + }, + }, + { + name: "element non-string type", + body: map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{{"type": 42}}, + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + resp := post(t, "/oauth2/bc-authorize", c.body, nil) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "malformed RAR must be rejected with 400") + body := decode(t, resp) + require.Equal(t, "invalid_authorization_details", body["error"], + "RFC 9396 §5.4: error code must be invalid_authorization_details, got %v", body) + }) + } +} + +// TestCIBA_RAR_PerTypeValidator covers the opt-in per-type validator hook: +// a deployer-registered validator runs for matching `type` entries, and a +// validator rejection fails the entire request with the validator's error +// surfaced in the error_description. +func TestCIBA_RAR_PerTypeValidator(t *testing.T) { + clientID := uid("ciba-rar-validator") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + // Register a strict validator for the `highflame_tool_call` type that + // requires `tool` to be one of a small allowlist. Mirrors the kind of + // policy AuthN will register in production. + var validatorCalls int + + allowedTools := map[string]bool{"transfer_funds": true, "send_email": true} + + testZeroIDServer.RegisterAuthorizationDetailValidator( + "highflame_tool_call", + func(raw json.RawMessage) error { + validatorCalls++ + + var payload struct { + Tool string `json:"tool"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return err + } + + if !allowedTools[payload.Tool] { + return errors.New("tool not in allowlist") + } + + return nil + }, + ) + t.Cleanup(func() { + testZeroIDServer.RegisterAuthorizationDetailValidator("highflame_tool_call", nil) + }) + + // Happy path: validator passes. + respOK := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{ + {"type": "highflame_tool_call", "tool": "transfer_funds"}, + }, + }, nil) + require.Equal(t, http.StatusOK, respOK.StatusCode, "registered validator must accept allowed tool") + require.Equal(t, 1, validatorCalls, "validator must have been invoked once for the matching element") + + // Reject path: validator returns error. + respBad := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{ + {"type": "highflame_tool_call", "tool": "drain_funds"}, + }, + }, nil) + require.Equal(t, http.StatusBadRequest, respBad.StatusCode) + body := decode(t, respBad) + require.Equal(t, "invalid_authorization_details", body["error"]) + require.Contains(t, body["error_description"], "tool not in allowlist", + "validator's error message must surface in error_description") + require.Equal(t, 2, validatorCalls, "validator must have been invoked again on the bad payload") + + // Unregistered types pass through with outer-shape validation only — + // the registered validator is type-scoped, not catch-all. + respUnregistered := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{ + {"type": "some_other_type", "anything": "goes"}, + }, + }, nil) + require.Equal(t, http.StatusOK, respUnregistered.StatusCode, + "unregistered types pass through under the permissive default") + require.Equal(t, 2, validatorCalls, + "validator must NOT be invoked for an unregistered type") +} + +// TestCIBA_RAR_ExplicitEmptyArray covers the subtle case where the client +// supplies authorization_details but as an empty array. Distinct from the +// omitted-field case (which is covered above) because an explicit `[]` is +// a deliberate "no RAR for this request, but the client knows the field +// exists" signal. Behaviour must match omission: notifier sees an empty +// typed slice, no error, no validator dispatch. +func TestCIBA_RAR_ExplicitEmptyArray(t *testing.T) { + clientID := uid("ciba-rar-empty") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + notifier := newRecordingNotifier() + testZeroIDServer.SetBackchannelNotifier(notifier.notify) + testZeroIDServer.SetBackchannelNotifyDispatchSync(true) + t.Cleanup(func() { + testZeroIDServer.SetBackchannelNotifyDispatchSync(false) + testZeroIDServer.SetBackchannelNotifier(nil) + }) + + resp := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{}, + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode, + "explicit empty authorization_details must succeed (legacy CIBA semantics)") + + got := notifier.last() + require.NotNil(t, got) + require.Empty(t, got.AuthorizationDetails, + "explicit empty array must surface as an empty slice on the notification") +} + +// TestCIBA_RAR_ValidatorPanicMapsToOAuthError covers the deployer-bug case: +// a registered per-type validator panics (nil-deref, library bug, etc.). The +// service MUST convert the panic into the same invalid_authorization_details +// response RFC 9396 §5.4 specifies for any RAR rejection, not let it propagate +// to a generic HTTP 500. Without this, a single buggy validator can deny +// every bc-authorize request with an opaque server error. +func TestCIBA_RAR_ValidatorPanicMapsToOAuthError(t *testing.T) { + clientID := uid("ciba-rar-panic") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + testZeroIDServer.RegisterAuthorizationDetailValidator( + "highflame_panicky", + func(_ json.RawMessage) error { + panic("simulated validator bug") + }, + ) + t.Cleanup(func() { + testZeroIDServer.RegisterAuthorizationDetailValidator("highflame_panicky", nil) + }) + + resp := post(t, "/oauth2/bc-authorize", map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + "authorization_details": []map[string]any{ + {"type": "highflame_panicky", "any": "payload"}, + }, + }, nil) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "validator panic must map to 400, not 500") + body := decode(t, resp) + require.Equal(t, "invalid_authorization_details", body["error"], + "validator panic must map to RFC 9396 §5.4 error code") +} + +// TestCIBA_RAR_FormEncoded covers RFC 9396 §2.1: a client MAY post the +// bc-authorize body as application/x-www-form-urlencoded. The +// authorization_details value is then a URL-encoded JSON array string. The +// oauthFormCompatMiddleware must bridge that to the JSON shape downstream +// so the notifier sees a typed slice exactly as it would for a JSON-body +// client. Without this, RFC-compliant clients written to the form-encoded +// path silently break. +func TestCIBA_RAR_FormEncoded(t *testing.T) { + clientID := uid("ciba-rar-form") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + notifier := newRecordingNotifier() + testZeroIDServer.SetBackchannelNotifier(notifier.notify) + testZeroIDServer.SetBackchannelNotifyDispatchSync(true) + t.Cleanup(func() { + testZeroIDServer.SetBackchannelNotifyDispatchSync(false) + testZeroIDServer.SetBackchannelNotifier(nil) + }) + + rar := `[{"type":"highflame_tool_call","tool":"transfer_funds","amount":50000}]` + form := url.Values{} + form.Set("client_id", clientID) + form.Set("account_id", testAccountID) + form.Set("project_id", testProjectID) + form.Set("login_hint", "alice@example.com") + form.Set("scope", "openid") + form.Set("authorization_details", rar) + + req, err := http.NewRequest(http.MethodPost, + testServer.URL+"/oauth2/bc-authorize", + bytes.NewReader([]byte(form.Encode())), + ) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode, + "form-encoded RAR must be accepted (RFC 9396 §2.1)") + + got := notifier.last() + require.NotNil(t, got, "notifier must fire for form-encoded RAR") + require.Len(t, got.AuthorizationDetails, 1) + require.Equal(t, "highflame_tool_call", got.AuthorizationDetails[0].Type) + + var first map[string]any + require.NoError(t, json.Unmarshal(got.AuthorizationDetails[0].Raw, &first)) + require.Equal(t, "transfer_funds", first["tool"]) +} + +// TestCIBA_RAR_FormEncodedMalformed covers the form-encoded sad path: an +// invalid JSON value supplied as the form parameter. The downstream parser +// must still return invalid_authorization_details, not a generic 400 — the +// error code is the contract clients branch on. +func TestCIBA_RAR_FormEncodedMalformed(t *testing.T) { + clientID := uid("ciba-rar-form-bad") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + form := url.Values{} + form.Set("client_id", clientID) + form.Set("account_id", testAccountID) + form.Set("project_id", testProjectID) + form.Set("login_hint", "alice@example.com") + form.Set("scope", "openid") + form.Set("authorization_details", "not-json") + + req, err := http.NewRequest(http.MethodPost, + testServer.URL+"/oauth2/bc-authorize", + bytes.NewReader([]byte(form.Encode())), + ) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + require.Equal(t, "invalid_authorization_details", parsed["error"], + "malformed form-encoded RAR must map to RFC 9396 §5.4 error code") +} diff --git a/tests/integration/rar_compliance_test.go b/tests/integration/rar_compliance_test.go new file mode 100644 index 00000000..52b53422 --- /dev/null +++ b/tests/integration/rar_compliance_test.go @@ -0,0 +1,339 @@ +// RFC 9396 (OAuth 2.0 Rich Authorization Requests) compliance suite. +// +// See COMPLIANCE.md for the conventions this file follows. +// +// 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. + +package integration_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupRARClient registers a public client the compliance tests post to. +// Mirrors setupCIBAClient in ciba_compliance_test.go — RAR rides on the +// CIBA bc-authorize endpoint so the client registration shape is identical. +func setupRARClient(t *testing.T) string { + t.Helper() + + clientID := uid("compliance-rar") + registerTestOAuthClient(clientID, []string{"client_credentials"}) + + return clientID +} + +// postBcAuthorize is a thin wrapper that sends the canonical CIBA tenant +// + login_hint scaffolding so every test below only has to vary +// authorization_details. Mirrors the helper pattern in ciba_compliance_test.go. +func postBcAuthorize(t *testing.T, clientID string, authorizationDetails any) *http.Response { + t.Helper() + + body := map[string]any{ + "client_id": clientID, + "account_id": testAccountID, + "project_id": testProjectID, + "login_hint": "alice@example.com", + "scope": "openid", + } + if authorizationDetails != nil { + body["authorization_details"] = authorizationDetails + } + + return post(t, "/oauth2/bc-authorize", body, nil) +} + +// ── RFC 9396 §2 — Request Parameter "authorization_details" ──────────────── + +func TestRFC9396_S2_AuthorizationDetailsMustBeJSONArray(t *testing.T) { + // RFC 9396 §2: "The request parameter authorization_details contains, + // in JSON notation, an array of objects." + // A JSON object at the top level (no surrounding array) violates the + // outer shape and MUST be rejected. + clientID := setupRARClient(t) + + resp := postBcAuthorize(t, clientID, map[string]any{"type": "x"}) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "authorization_details that is not a JSON array MUST be rejected") + + body := decode(t, resp) + require.Equal(t, "invalid_authorization_details", body["error"], + "outer-shape rejection MUST use the invalid_authorization_details error code") +} + +func TestRFC9396_S2_EmptyArrayTreatedAsAbsent(t *testing.T) { + // RFC 9396 §2: an authorization_details array of zero objects conveys + // no rights. ZeroID's contract — and the only sensible reading of + // "the rights of the access token" — is that this is indistinguishable + // from the parameter being omitted. The legacy CIBA flow MUST continue + // to succeed unchanged. + clientID := setupRARClient(t) + + notifier := newRecordingNotifier() + testZeroIDServer.SetBackchannelNotifier(notifier.notify) + testZeroIDServer.SetBackchannelNotifyDispatchSync(true) + t.Cleanup(func() { + testZeroIDServer.SetBackchannelNotifyDispatchSync(false) + testZeroIDServer.SetBackchannelNotifier(nil) + }) + + resp := postBcAuthorize(t, clientID, []map[string]any{}) + require.Equal(t, http.StatusOK, resp.StatusCode, + "explicit empty authorization_details MUST be accepted (legacy CIBA semantics preserved)") + + got := notifier.last() + require.NotNil(t, got) + assert.Empty(t, got.AuthorizationDetails, + "empty array MUST surface as empty typed slice (no synthesised entries)") +} + +func TestRFC9396_S2_MultipleEntriesPreserveOrder(t *testing.T) { + // RFC 9396 §2 (and §2.3): a request may carry multiple authorization + // details objects. The server must process every entry; the typed + // slice handed to a consumer (approver UX) MUST reflect the order + // in which the client supplied them so the approval prompt renders + // in the client's declared sequence. + clientID := setupRARClient(t) + + notifier := newRecordingNotifier() + testZeroIDServer.SetBackchannelNotifier(notifier.notify) + testZeroIDServer.SetBackchannelNotifyDispatchSync(true) + t.Cleanup(func() { + testZeroIDServer.SetBackchannelNotifyDispatchSync(false) + testZeroIDServer.SetBackchannelNotifier(nil) + }) + + resp := postBcAuthorize(t, clientID, []map[string]any{ + {"type": "type-a"}, + {"type": "type-b"}, + {"type": "type-c"}, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + got := notifier.last() + require.NotNil(t, got) + require.Len(t, got.AuthorizationDetails, 3, + "every supplied entry MUST be delivered to the consumer") + + assert.Equal(t, "type-a", got.AuthorizationDetails[0].Type) + assert.Equal(t, "type-b", got.AuthorizationDetails[1].Type) + assert.Equal(t, "type-c", got.AuthorizationDetails[2].Type) +} + +// ── RFC 9396 §2.1 — Authorization Details Types ──────────────────────────── + +func TestRFC9396_S2_1_TypeFieldRequiredOnEveryElement(t *testing.T) { + // RFC 9396 §2.1: "Every authorization details object MUST contain a + // `type` element." + clientID := setupRARClient(t) + + resp := postBcAuthorize(t, clientID, []map[string]any{ + {"not_type": "still not a type"}, + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "authorization_details element missing `type` MUST be rejected") + + body := decode(t, resp) + require.Equal(t, "invalid_authorization_details", body["error"]) +} + +func TestRFC9396_S2_1_TypeMustBeString(t *testing.T) { + // RFC 9396 §2.1: "The string value of the `type` field determines + // the actual structure of the rest of the authorization details + // object." A non-string `type` cannot discriminate the structure + // and MUST be rejected as malformed. + clientID := setupRARClient(t) + + resp := postBcAuthorize(t, clientID, []map[string]any{ + {"type": 42}, + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "non-string `type` MUST be rejected") + + body := decode(t, resp) + require.Equal(t, "invalid_authorization_details", body["error"]) +} + +func TestRFC9396_S2_1_TypeMustBeNonEmpty(t *testing.T) { + // RFC 9396 §2.1: the `type` field is a "string identifier" used as a + // discriminator. An empty string cannot identify anything and so + // cannot dispatch to a per-type validator or to a typed approver UX. + // ZeroID treats `type = ""` as a malformed contract violation, not a + // distinct "untyped" namespace. + clientID := setupRARClient(t) + + resp := postBcAuthorize(t, clientID, []map[string]any{ + {"type": ""}, + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "empty-string `type` MUST be rejected") + + body := decode(t, resp) + require.Equal(t, "invalid_authorization_details", body["error"]) +} + +func TestRFC9396_S2_1_UnknownTypeFieldsPreservedVerbatim(t *testing.T) { + // RFC 9396 §2.1: "The data fields of any concrete authorization details + // object are derived from the `type` field." ZeroID does not know + // any concrete schema beyond `type` — yet the consumer (per-type + // validator, BackchannelNotifier, the future token-embed path) MUST + // see the full original payload, not just `type`. This pins the + // raw-preservation contract that lets deployers layer arbitrary + // typed schemas on top without the library re-marshalling them. + clientID := setupRARClient(t) + + notifier := newRecordingNotifier() + testZeroIDServer.SetBackchannelNotifier(notifier.notify) + testZeroIDServer.SetBackchannelNotifyDispatchSync(true) + t.Cleanup(func() { + testZeroIDServer.SetBackchannelNotifyDispatchSync(false) + testZeroIDServer.SetBackchannelNotifier(nil) + }) + + resp := postBcAuthorize(t, clientID, []map[string]any{ + { + "type": "highflame_tool_call", + "tool": "transfer_funds", + "amount": 50000, + "locations": []string{"acct_X"}, + "datatypes": []string{"pii"}, + }, + }) + require.Equal(t, http.StatusOK, resp.StatusCode) + + got := notifier.last() + require.NotNil(t, got) + require.Len(t, got.AuthorizationDetails, 1) + + var decoded map[string]any + require.NoError(t, json.Unmarshal(got.AuthorizationDetails[0].Raw, &decoded)) + + for _, key := range []string{"type", "tool", "amount", "locations", "datatypes"} { + assert.Contains(t, decoded, key, + "field %q MUST survive round-trip in the raw payload", key) + } +} + +// ── RFC 9396 §3 / §5 — Content Type and Error Response ───────────────────── + +func TestRFC9396_S3_FormEncodedAuthorizationDetailsAccepted(t *testing.T) { + // RFC 9396 §3 (and the underlying RFC 6749 §3.1 / §3.2 form-encoding + // requirements for OAuth-style request bodies): clients MAY send + // the request body as application/x-www-form-urlencoded with + // `authorization_details` carrying the URL-encoded JSON array + // string. The authorization server MUST decode the JSON value and + // process it identically to the JSON-body case. + clientID := setupRARClient(t) + + notifier := newRecordingNotifier() + testZeroIDServer.SetBackchannelNotifier(notifier.notify) + testZeroIDServer.SetBackchannelNotifyDispatchSync(true) + t.Cleanup(func() { + testZeroIDServer.SetBackchannelNotifyDispatchSync(false) + testZeroIDServer.SetBackchannelNotifier(nil) + }) + + rar := `[{"type":"highflame_tool_call","tool":"transfer_funds"}]` + form := url.Values{} + form.Set("client_id", clientID) + form.Set("account_id", testAccountID) + form.Set("project_id", testProjectID) + form.Set("login_hint", "alice@example.com") + form.Set("scope", "openid") + form.Set("authorization_details", rar) + + req, err := http.NewRequest(http.MethodPost, + testServer.URL+"/oauth2/bc-authorize", + bytes.NewReader([]byte(form.Encode())), + ) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusOK, resp.StatusCode, + "form-encoded authorization_details MUST be accepted") + + got := notifier.last() + require.NotNil(t, got) + require.Len(t, got.AuthorizationDetails, 1, + "form-encoded entry MUST reach the consumer identically to JSON-body") + assert.Equal(t, "highflame_tool_call", got.AuthorizationDetails[0].Type) +} + +func TestRFC9396_S5_ErrorCodeIsInvalidAuthorizationDetails(t *testing.T) { + // RFC 9396 §5 (Error Response): "If the request itself is not valid or + // any of the given authorization details is not valid, the + // authorization server fails the request indicating + // `invalid_authorization_details` as the error code." + // + // This pins the OAuth error-code uniformity contract — every RAR-side + // rejection MUST map to `invalid_authorization_details`, not the + // adjacent `invalid_request` (RFC 6749 §5.2) which clients may handle + // differently (retry, reject scope, surface to user). + clientID := setupRARClient(t) + + resp := postBcAuthorize(t, clientID, []map[string]any{ + {"not_type": "still missing"}, + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + var parsed map[string]any + require.NoError(t, json.Unmarshal(body, &parsed)) + require.Equal(t, "invalid_authorization_details", parsed["error"], + "RFC 9396 §5: any authorization_details-side rejection MUST use error code invalid_authorization_details") + assert.NotEmpty(t, parsed["error_description"], + "error_description SHOULD be set so the client can render the failure") +} + +func TestRFC9396_S5_ValidatorErrorMapsToInvalidAuthorizationDetails(t *testing.T) { + // RFC 9396 §5: any "of the given authorization details is not valid" + // path resolves to the same error code. The opt-in per-type validator + // hook is one of those paths — a validator rejection MUST not leak + // as a different error code (e.g. `invalid_request` or `access_denied`) + // just because the rejection source is deployer code rather than + // library code. + clientID := setupRARClient(t) + + testZeroIDServer.RegisterAuthorizationDetailValidator( + "compliance_test_only", + func(_ json.RawMessage) error { + return assert.AnError + }, + ) + t.Cleanup(func() { + testZeroIDServer.RegisterAuthorizationDetailValidator("compliance_test_only", nil) + }) + + resp := postBcAuthorize(t, clientID, []map[string]any{ + {"type": "compliance_test_only"}, + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + + body := decode(t, resp) + require.Equal(t, "invalid_authorization_details", body["error"], + "validator rejection MUST surface as invalid_authorization_details, not invalid_request") +}