Skip to content

feat(oauth): OAuth broker bindings (token vault) — closes #549#555

Merged
kaiweijw merged 29 commits into
mainfrom
feat/issue-549-broker-capability-enabled
Apr 28, 2026
Merged

feat(oauth): OAuth broker bindings (token vault) — closes #549#555
kaiweijw merged 29 commits into
mainfrom
feat/issue-549-broker-capability-enabled

Conversation

@kaiweijw
Copy link
Copy Markdown
Collaborator

@kaiweijw kaiweijw commented Apr 28, 2026

Summary

  • Implements OAuth broker / token vault for [Feature Request] OAuth broker bindings — NyxID-side refresh_token storage with opaque binding_id for third-party apps #549 with full V2 hardening. Third-party OAuth clients (e.g. aevatar) get an opaque binding_id instead of a refresh_token; NyxID holds the refresh_token server-side, rotates it via RFC 8693 token exchange, and issues optionally sender-constrained access tokens (DPoP / mTLS) with revocation webhook propagation.
  • Unblocks aevatar ADR-0017's switch from LocalRefreshTokenCapabilityBroker to NyxIdRemoteCapabilityBroker.
  • 27 commits, ~6,680 lines added, ~1,021 deleted across 68 files (backend + frontend + CLI + skills).

Why this matters

The current OAuth surface forces broker integrations to either (a) hold long-lived user refresh_tokens in their grain state — a compliance liability — or (b) re-prompt the user every 15 minutes. Auth0 popularised the alternative in late 2024: the broker holds an opaque handle, NyxID holds the credential, and tokens are minted server-side on demand via standard RFC 8693 token exchange. This PR implements that pattern end-to-end, then layers in V2 hardening (sender constraint, off-the-wire ingress, revocation webhooks) so the resulting integration is provably more secure than any token-holding alternative.

Wire surface (frozen contract aevatar's ADR-0017 builds against)

Issuance + exchange:

  • POST /oauth/token — when a client is broker-capable (admin flag OR urn:nyxid:scope:broker_binding in allowed_scopes), the auth-code response carries binding_id instead of refresh_token and the access_token TTL is 5 min.
  • POST /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange and subject_token_type=urn:nyxid:params:oauth:token-type:binding-id — exchange a binding_id for a fresh access_token. Refresh chain rotates server-side. Optional DPoP header (RFC 9449) or MTLS_CLIENT_CERT_HEADER (RFC 8705) binds the issued token to the client's key/cert via cnf.jkt / cnf.x5t#S256.
  • POST /oauth/par — RFC 9126 Pushed Authorization Requests. Push external_subject_* server-to-server before the browser redirect; /oauth/authorize accepts the resulting request_uri.

Revocation:

  • POST /oauth/revoke — RFC 7009; recognizes urn:nyxid:params:oauth:token-type:binding-id as token_type_hint (or bnd_ prefix as defensive fallback). Always 200 per RFC.
  • DELETE /oauth/bindings/{binding_id} — REST-style alias; always 204.
  • Both fire an oauth_broker_binding.revoked HMAC-SHA256-signed webhook to the client's revocation_webhook_url (when configured) so propagation is seconds, not the 5-min access TTL.

Inspection:

  • GET /oauth/bindings/{binding_id} — client-credentials introspection.
  • GET /oauth/bindings?external_subject_*=... — client-scoped reverse lookup for dedup.
  • POST /oauth/introspect — RFC 7662; recognizes binding handles via token_type_hint=urn:nyxid:params:oauth:token-type:binding-id.

User-facing:

  • GET /api/v1/users/me/broker-bindings + DELETE /{binding_hash} — list/revoke (drives /settings/authorizations).
  • nyxid oauth bindings {list, show, revoke} CLI.

Discovery (/.well-known/openid-configuration + /.well-known/oauth-authorization-server):

  • Full grant_types_supported list (incl. urn:ietf:params:oauth:grant-type:token-exchange)
  • subject_token_types_supported includes urn:nyxid:params:oauth:token-type:binding-id
  • pushed_authorization_request_endpoint, request_uri_parameter_supported, require_pushed_authorization_requests: false
  • dpop_signing_alg_values_supported: ["ES256"]
  • tls_client_certificate_bound_access_tokens: true
  • client_secret_basic in token_endpoint_auth_methods_supported
  • Vendor extensions: nyxid_broker_binding_supported: true, oauth_broker_binding_revocation_webhook_supported: true

Issue acceptance checklist

Acceptance criterion Status Commit
新表 oauth_broker_bindings + 加密 refresh_token 存储 d271f22
POST /oauth/token returns binding_id for broker clients facb4a6
Binding-exchange endpoint + client_credentials auth ✅ via RFC 8693 02b0115
GET /oauth/bindings/{binding_id} introspection 5a43bd5
GET /oauth/bindings?external_subject_* reverse lookup 91cf781
DELETE /oauth/bindings/{binding_id} + cascade ✅ via RFC 7009 + REST alias 1072569 + cf3756b
用户 UI 列出 / 撤销 binding ✅ web + CLI f31e355 + 47692dd
Discovery metadata advertises broker capability 62c2ed5
skills/nyxid documentation ✅ V1 + V2 ce037f3 + 25d5999
Contract URN frozen as urn:nyxid:params:oauth:token-type:binding-id 0c6c1a5
urn:nyxid:scope:broker_binding as broker trigger 8a87fd1
aevatar integration test (ADR-0017 cutover) ⏳ external — owned by aevatar

V2 hardening (now in scope)

# What Commit
V2-1 AAD = binding_hash on AES envelope for broker refresh tokens (defends against ciphertext substitution) 4bc6951
V2-2 Optimistic-rotation chain-follow retry in exchange_via_binding (loser doesn't re-rotate) 8a084c2
V2-3 RFC 7662 introspection for binding handles 3d1ee50
V2-4 RFC 9126 Pushed Authorization Requests; off-the-wire external_subject b3c4adb
V2-5 RFC 9449 DPoP sender-constrained access tokens (ES256, JWK thumbprint, jti replay cache) 4e5c5d0
V2-6 RFC 8705 certificate-bound access tokens (proxy-forwarded mTLS, off-by-default) 32e9a34
V2-7 Continuous Access Evaluation webhook on revocation (HMAC-SHA256, fire-and-forget retry) b1b4d22
Review clippy unused-var fix, PAR TTL → 60s (RFC 9126 ceiling), Zeroizing on HMAC secret f195bc0

Commit narrative (27 total)

V1 implementation (13):

  1. c7f8e63broker_capability_enabled flag on OauthClient
  2. 108182f — fix: include flag on OidcCredentialsResponse
  3. 13b915b — persist optional external_subject on AuthorizationCode
  4. d271f22 — oauth_broker_binding model + 4 indexes
  5. facb4a6 — /oauth/token broker path returns binding_id
  6. 02b0115 — RFC 8693 token-exchange + reuse-detection cascade
  7. 1072569 — /oauth/revoke extension + user-account list/revoke API
  8. f31e355 — frontend /settings/authorizations page
  9. 62c2ed5 — discovery metadata, audit events, integration tests
  10. 47692ddnyxid oauth bindings {list, show, revoke} CLI
  11. 5a43bd5 — GET /oauth/bindings/{binding_id} introspection
  12. 91cf781 — GET /oauth/bindings reverse lookup
  13. ce037f3 — skills/nyxid V1 reference doc

Contract alignment with #549 comment from @eanzhao (5):
14. 0c6c1a5 — refactor: subject_token_type URN to urn:nyxid:params:oauth:token-type:binding-id
15. cf3756b — DELETE /oauth/bindings/{binding_id} REST alias
16. 8a87fd1urn:nyxid:scope:broker_binding as broker-mode trigger
17. d41c61e — fix: token-exchange gate honors scope trigger too
18. e1872bc — fix: clippy unused-variable on /oauth/bindings auth

V2 hardening (8 — including the post-V2 review fix):
19–26. See V2 table above. Plus 25d5999 — skills/nyxid V2 capabilities note.

Security model

  • The raw binding_id is never persisted server-side. Only SHA-256(binding_id) is stored as _id. Same pattern as RefreshToken.jti.
  • Refresh tokens encrypted with the existing EncryptionKeys (AES-256-GCM v2 envelope, KMS-wrapped DEK). V2-1 binds each ciphertext to the binding_hash via AAD so a DB-write attacker can't swap blobs across bindings.
  • Generic invalid_grant / 404 / 204-always on every wrong-client / missing / revoked / auth-failure path. No enumeration leaks.
  • Reuse detection: if the underlying RefreshToken is already revoked when a binding tries to use it, all bindings for (client_id, user_id) are cascade-revoked.
  • V2-2 chain-follow retry: parallel exchanges no longer fail closed; the losing caller mints an access_token off the winner's already-rotated state without re-rotating. Bounded to 3 retries.
  • V2-5 DPoP and V2-6 mTLS bind issued access tokens to a public key / cert thumbprint via cnf.jkt / cnf.x5t#S256. Middleware verifies on every API call.
  • V2-7 revocation webhook: HMAC-SHA256-signed oauth_broker_binding.revoked event fired fire-and-forget on every revoke (3-attempt exponential backoff, 10s timeout).
  • Broker capability is admin-controlled via either the broker_capability_enabled flag or urn:nyxid:scope:broker_binding in allowed_scopes. Both are admin-gated.
  • Audit events: oauth_broker_binding_issued, _token_refreshed (with via_chain_follow), _revoked (with source), _reuse_detected (with cascade count). Logs include only the OAuth client_id and a 16-char binding_hash prefix — never the raw binding_id, refresh_token, or external_user_id.
  • New vendor URN namespace urn:nyxid:params:oauth:* documented in CLAUDE.md.

Test Plan

  • Existing tests pass (cargo test, npm run build, cargo build -p nyxid-cli)
  • 31 broker-specific tests including 13 Mongo-backed integration tests (encrypt/decrypt round-trip, AAD blob-swap rejection, chain-follow retry, reuse-detection cascade, ownership enforcement, list filtering, reverse lookup with absent tenant, exchange via DPoP/mTLS thumbprint, etc.)
  • 4 PAR-specific Mongo-backed tests (single-use atomicity, expiry, wrong-client rejection, round-trip)
  • 4 CAE webhook tests (HMAC stability, signature varies with secret, event JSON shape, Mongo-backed delivery loop)
  • DPoP unit tests including the RFC 7638 §A.1 test vector for the JWK thumbprint (publicly verified: NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs)
  • mTLS thumbprint round-trip with a real self-signed PEM fixture
  • Manually verified nyxid oauth bindings --help and the three sub-commands
  • cargo clippy --all-targets -- -D warnings clean across all 27 commits
  • Frontend /settings/authorizations page renders correctly

Operational notes

  • mTLS opt-in: MTLS_CLIENT_CERT_HEADER is unset by default. Operators must set it AND configure their reverse proxy to strip the header from external traffic before forwarding — otherwise an attacker could forge a binding by injecting the header. Full warning in CLAUDE.md security section.
  • CAE secret rotation: the revocation_webhook_secret is set-once via the developer-app create/update API for v1. Adding a rotate-secret endpoint is a follow-up.
  • Multi-replica deployments: the DPoP jti cache and PAR record TTL guard are per-process. Production multi-replica setups need either sticky routing or a Redis-backed shared cache. Documented in crypto/dpop.rs and services/dpop_jti_cache.rs doc comments.

Checklist

  • Code follows the project's architecture rules (handlers → services → models, no #[serde(skip_serializing)] on Mongo fields, #[serde(default)] on every new optional field)
  • No hardcoded secrets or credentials (CAE webhook HMAC secret encrypted at rest via existing EncryptionKeys envelope; raw secret wrapped in Zeroizing<String> for spawn lifetime)
  • Error messages do not leak internal details (RFC 7009 / RFC 6749 / RFC 8693 / RFC 9126 generic responses throughout)
  • Documentation updated (CLAUDE.md vendor URN namespace + MTLS env var, skills/nyxid V1 + V2 reference docs, discovery metadata)
  • Wire contract aligned with [Feature Request] OAuth broker bindings — NyxID-side refresh_token storage with opaque binding_id for third-party apps #549 comment from @eanzhao (URN, REST DELETE, scope-based trigger, RFC 8693 wire shape)
  • V2 hardening complete (AAD, chain-follow, introspection, PAR, DPoP, mTLS, CAE)

Closes #549.

kaiweijw added 16 commits April 28, 2026 16:19
Adds a per-client capability flag that future commits will gate
broker-binding behavior on. Plumbs the field through the model,
oauth_client_service create/update, developer-app and admin handlers,
the dynamic-registration and OIDC-provisioning call sites (set to
false), the frontend developer-app types/hook, the org tab test
fixture, and the CLI create/update flags. No wire-level behavior
change.

Refs: #549
Backend response was missing the new field that the frontend
OidcCredentials type added in c7f8e63, leaving the field undefined
at runtime even though TS treated it as required. Sources from
oauth_client.broker_capability_enabled so the OIDC credentials
view fully describes the client's mode.

Refs: #549
Accepts external_subject_platform, external_subject_tenant, and
external_subject_external_user_id as optional query parameters on
/oauth/authorize and round-trips them through the consent page form.
Persists a validated ExternalSubjectRef on the AuthorizationCode so
future broker-binding commits can copy it onto the binding at
exchange time. No wire-level behavior change for clients that omit
these parameters.

Refs: #549
Adds the storage primitive for the OAuth broker / token vault pattern.
Bindings are looked up by SHA-256(binding_id), keep the user's
refresh_token in an AES-256-GCM v2 envelope (existing EncryptionKeys
helper, no AAD in v1), carry an optimistic rotation_version for
concurrent-rotation safety, and link to RefreshToken.jti so revocation
cascades. ExternalSubjectRef is reused from authorization_code so the
binding can record what external account it represents.

Indexes cover client+user listing, partial-sparse reverse lookup by
external_subject, the user authorizations page, and refresh_token_jti
cascade revoke.

Service layer, token-issuance plumbing, and revoke handlers land in
commits #4–#6.

Refs: #549
When a broker_capability_enabled OAuth client redeems an authorization
code, NyxID stores the freshly-minted refresh_token in an
oauth_broker_bindings document (encrypted via the existing
EncryptionKeys envelope) and returns an opaque binding_id to the
client INSTEAD of the refresh_token. The access token is tightened to
a 300-second TTL on this path so revocation propagates fast.

Auth0 Token Vault wire shape — Standard /oauth/token plus a top-level
binding_id field per RFC 6749 §4.5. Standard clients ignore the new
field and continue to work; broker-aware clients use it for the
RFC 8693 token-exchange call landing in commit #5.

Non-broker flows are unchanged byte-for-byte: same TTL, same
refresh_token in the response, same JSON shape (the new binding_id
field skip-serializes when None).

Refs: #549
A broker client now redeems its opaque binding_id at the standard
/oauth/token endpoint via RFC 8693 token exchange with
subject_token_type=urn:nyxid:token-type:binding_id. The refresh_token
never leaves the server: NyxID decrypts it server-side, rotates it
via the existing replaced_by chain, re-encrypts the new refresh_token
under the same binding row, and returns a fresh 5-minute access_token.

If the underlying refresh_token has already been revoked when the
broker tries to use it (reuse signal), all bindings for that
(client_id, user_id) pair are cascade-revoked and the call fails with
a generic invalid_grant — never leaking whether the binding existed.

Optimistic concurrency on rotation_version: on conflict the losing
caller gets invalid_grant and is expected to retry. Full
chain-follow retry is a v2 hardening (noted in code comment).

Refs: #549
…t API

Adds two explicit revocation surfaces:

1. RFC 7009 client-initiated revoke at /oauth/revoke. The handler now
   detects token_type_hint=urn:nyxid:token-type:binding_id (or a bnd_
   prefix as a defensive fallback) and revokes the binding plus its
   underlying RefreshToken. Always returns 200 per RFC 7009 §2.2 — no
   information leak on miss or ownership mismatch.

2. User-initiated revoke at /api/v1/users/me/broker-bindings (GET to
   list, DELETE /{binding_hash} to revoke). The list response is
   scrubbed of refresh_token_encrypted, refresh_token_jti, and
   rotation_version. DELETE returns 204 on success or 404 on miss.

Both paths emit an oauth_broker_binding_revoked audit event with the
revoke source (client or user), the OAuth client_id, and a truncated
16-char binding_hash. The cascade-on-reuse-detection from commit #5
is unchanged — explicit revoke targets only the named binding.

Refs: #549
User-facing settings page at /settings/authorizations that lists the
current user's OAuth broker bindings and lets them revoke. Mirrors
the existing consents page in structure (skeleton/empty/error states,
shadcn Table + Trash2 confirmation dialog) and consumes the
/api/v1/users/me/broker-bindings endpoints landed in commit #6.

Distinct from the existing "Authorized Apps" (consents) page:
consents = "I authorized this OAuth client to call NyxID on my
behalf"; authorizations = "this app holds a server-side credential
binding for me, so it can act via NyxID without ever holding my
refresh_token". Adjacent in the sidebar; merging into one
"Authorizations" view is a future polish.

Refs: #549
… tests

Closes the v1 surface for OAuth broker bindings:

- /.well-known/openid-configuration and /.well-known/oauth-authorization-server
  now advertise the full set of supported grant_types (including
  client_credentials and token-exchange), the new
  subject_token_types_supported list (with urn:nyxid:token-type:binding_id),
  client_secret_basic auth, and a nyxid_broker_binding_supported: true
  vendor flag clients can check before relying on the URN.

- Adds the two missing audit events: oauth_broker_binding_issued (emitted
  by the broker /oauth/token path on creation) and
  oauth_broker_binding_reuse_detected (emitted by the token-exchange path
  when reuse triggers cascade revoke). Both redact PII per the existing
  audit-redaction conventions.

- Adds integration tests against a real MongoDB covering create_binding
  encrypt/decrypt round-trip, revoke_binding_by_client idempotency and
  ownership, revoke_binding_by_user not-found behavior, list filtering and
  ordering, exchange_via_binding cross-client rejection, and reuse-detection
  cascade. Tests skip cleanly when no MongoDB is available so CI without
  the service container stays green.

- Documents the urn:nyxid: vendor URN namespace in CLAUDE.md.

Refs: #549
Final v1 surface for OAuth broker bindings (#549). Adds three CLI
sub-commands under a new `oauth` namespace that wrap the user-scope
endpoints landed in commit #6:

- `nyxid oauth bindings list` — list active broker bindings owned by
  the current user (table or JSON output).
- `nyxid oauth bindings show <hash>` — show one binding's details.
  Accepts the full hash or any unique 8+ char prefix.
- `nyxid oauth bindings revoke <hash>` — revoke a binding. Prompts
  for confirmation unless --yes.

The 3-level namespace (oauth → bindings → action) leaves room for
future `nyxid oauth <other>` groups without restructuring.

Refs: #549
Adds the binding-introspection endpoint required by issue #549's
acceptance criteria. Authenticated by client_credentials (Basic header
or ?client_id=&client_secret= query params) and returns the binding
metadata to its owning client only. Generic 404 on miss, ownership
mismatch, or auth failure — no enumeration leak. Revoked bindings
still return 200 with revoked: true so a client can introspect after
the fact.

Refs: #549
Closes the optional reverse-lookup endpoint from issue #549's acceptance
criteria. Authenticated by client_credentials (Basic header or
?client_id=&client_secret= query params). Returns the caller's own
non-revoked bindings whose external_subject matches the given
(platform, tenant?, external_user_id) triple — used for dedup before
initiating a new OAuth flow.

Backed by the existing partial-sparse compound index on
oauth_broker_bindings landed in commit #3. Mismatched/missing
client_id quietly returns an empty list to avoid enumeration leaks;
missing external_subject criteria return 400 (this is a filtered
lookup, not a list-all enumeration).

Refs: #549
Adds skills/nyxid/references/oauth-broker.md describing the
user-facing surfaces shipped in #1#11: nyxid oauth bindings
{list,show,revoke}, the /settings/authorizations web UI, the
binding-vs-consent distinction, revocation semantics, and common
RFC-aligned error responses. Adds a row in SKILL.md's reference map
so AI agents using the skill load it on relevant queries.

Refs: #549
Renames the broker-binding subject_token_type from
urn:nyxid:token-type:binding_id to
urn:nyxid:params:oauth:token-type:binding-id per eanzhao's contract
proposal on issue #549. Two changes:

- Insert params:oauth: infix to mirror the IETF URN style at
  urn:ietf:params:oauth:* so generic OAuth vendor-extension parsers
  recognize the suffix shape.
- Replace underscore with hyphen in binding-id so the suffix follows
  IETF URN conventions (binding-id, grant-type, token-type all use
  hyphens).

Single-source-of-truth rename via BROKER_SUBJECT_TOKEN_TYPE constant
ripples to discovery metadata, /oauth/token token-exchange branch,
/oauth/revoke broker detection, and audit hashes. CLAUDE.md vendor
URN documentation updated to describe the params:oauth namespace
convention. Frozen as the contract aevatar's ADR-0017 builds against.

Refs: #549
Adds the REST-style binding revoke endpoint called for in issue #549's
acceptance criteria, complementing the existing RFC 7009 /oauth/revoke
extension landed in commit #6. Client-credentials authenticated (Basic
header or ?client_id=&client_secret= query params) and always returns
204 — missing, already-revoked, and ownership-mismatched bindings are
indistinguishable from a successful revoke (no enumeration leak).

Both endpoints call the same revoke_binding_by_client service helper,
so audit semantics and underlying RefreshToken cascade revocation
match across the two surfaces. /oauth/revoke remains the standards-
track choice for clients that already speak RFC 7009; this REST
endpoint is the alias the issue spec calls for and aevatar's ADR-0017
points to.

Refs: #549
Adds the OAuth-scope-based broker trigger requested by eanzhao on
issue #549. When `urn:nyxid:scope:broker_binding` appears in an
OAuth client's `allowed_scopes`, the client is treated as broker-
capable in addition to the existing per-client
`broker_capability_enabled` admin flag. Either trigger is sufficient.

This keeps the flag (admin-controlled) as the canonical internal
mechanism while exposing scope-based opt-in as the standards-aligned
interface the contract proposal calls for. Admin still gates scope
assignment via `allowed_scopes`, so this is not a security regression.

The scope is added to KNOWN_OIDC_SCOPES (so admins can put it in
allowed_scopes without "unknown scope" rejection) and to the
`scopes_supported` list in both .well-known discovery responses so
clients can detect the capability.

is_broker_client() centralizes the OR check; oauth_service uses it
in place of the bare `client.broker_capability_enabled` access.
Four new unit tests cover flag-only, scope-only, neither, and
substring-collision protection.

Refs: #549
kaiweijw added 13 commits April 28, 2026 20:15
Commit 8a87fd1 added urn:nyxid:scope:broker_binding as a parallel
trigger to the broker_capability_enabled flag, with is_broker_client()
as the canonical OR check. The /oauth/token authorization_code path
was migrated to use the helper, but the token-exchange branch was
still reading client.broker_capability_enabled directly.

Result: a scope-opted-in client could ISSUE bindings (commit #4 path)
but COULD NOT exchange them at /oauth/token with grant_type=
urn:ietf:params:oauth:grant-type:token-exchange — the exchange
returned a generic invalid_grant. Caught while auditing the wire
contract end-to-end against eanzhao's #549 spec. One-line fix:
swap the bare flag access for is_broker_client(&client).

Refs: #549
The list_bindings_by_external_subject handler binds (id, secret) in
a match arm whose body returns early on client_id mismatch. clippy
under -D warnings (CI's setting) flagged `secret` as unused — the
local pre-commit hook runs plain `cargo clippy` without `-D warnings`
so it shipped. Use a wildcard pattern for the unused half.

Refs: #549
Adds encrypt_with_aad / decrypt_with_aad on EncryptionKeys and rewires
the OAuth broker's refresh_token_encrypted persistence to bind each
ciphertext to its owning binding_hash via AES-GCM Additional
Authenticated Data. After this lands, an attacker with database write
access can no longer swap encrypted blobs across binding rows —
decryption with a different binding's hash as AAD fails the auth-tag
check.

The new methods are additive: existing encrypt/decrypt callers
(UserProviderToken etc.) are untouched. The wire format of the v2
envelope is unchanged; AAD only affects the AES-GCM authentication
tag. A blob encrypted without AAD will fail decrypt_with_aad and
vice versa — that's the intended security boundary.

Tests cover AAD round-trip, AAD-mismatch rejection, no-AAD blob
rejection, and a Mongo-backed exchange_via_binding scenario that
swaps two bindings' ciphertexts and confirms the swap is detected.

Refs: #549 (V2 hardening 1/7)
When two concurrent callers exchange the same binding_id, today's
exchange_via_binding returns invalid_grant to the loser and forces
the client to retry the whole flow. This commit lets the server
retry internally: on rotation_version conflict, re-read the binding's
now-rotated state, validate the descendant RefreshToken via its
replaced_by chain, and mint an access_token from that state without
re-rotating.

Mirrors the first-party flow handled by
token_service::follow_active_replacement_chain. Bounded to 3 retries
to bound DB pressure under thundering-herd. Reuse detection (revoked
refresh_token with no replaced_by pointer) still cascade-revokes the
family — chain-follow only kicks in for legitimate rotation chains.

Also fixes an orphaned-RefreshToken leak: when a rotation attempt
loses the race, the new RefreshToken row inserted before the conflict
detection is now cleaned up. Without this, refresh_tokens accumulated
unreachable rows under contention until their TTL fired.

BindingExchangeResult gains a via_chain_follow field plumbed into
the oauth_broker_binding_token_refreshed audit event so operators
can see the contention rate in production logs.

Refs: #549 (V2 hardening 2/7)
Extends /oauth/introspect to recognize binding_id values via the
existing token_type_hint=urn:nyxid:params:oauth:token-type:binding-id
URN or a defensive bnd_ prefix match. An owning broker client can
now check whether a stored binding_id is still active without doing
a token-exchange round-trip — the canonical RFC 7662 use case.

Returns active=true with client_id, sub (user_id), iat, scope, and
token_type=broker_binding for the owning client. Returns
{"active": false} for revoked, missing, or wrong-client tokens —
RFC 7662 §2.2 / no enumeration leak. exp and jti are intentionally
omitted (bindings don't have a JWT-style expiry; binding_hash is the
unique ID, not a jti). RBAC enrichment is skipped for bindings to
keep the introspection response minimal.

Refs: #549 (V2 hardening 3/7)
Adds POST /oauth/par for clients to push authorize-request parameters
server-to-server before the browser redirect. The endpoint is
client-credentials authenticated, validates redirect_uri, response_type,
code_challenge_method, and external_subject params at submission time,
persists a single-use PushedAuthorizationRequest (TTL 90s), and returns
the canonical request_uri + expires_in shape per RFC 9126 §2.2.

/oauth/authorize is extended to accept ?request_uri=... and atomically
consume the persisted record (find_one_and_delete with TTL guard).
The persisted client_id wins over the query-string client_id; mismatch
is rejected. Other query params besides client_id + request_uri are
logged but otherwise ignored per RFC 9126 §4.

The primary use case for the OAuth broker is keeping external_subject_*
params off the wire — they no longer ride in browser URLs / Referer
headers / access logs. Discovery metadata advertises
pushed_authorization_request_endpoint, request_uri_parameter_supported,
and require_pushed_authorization_requests=false on both .well-known
endpoints.

Refs: #549 (V2 hardening 4/7)
Adds optional DPoP sender-constraint to broker-issued access tokens.
When a broker client includes a DPoP header on its token-exchange call,
the issued access_token is bound to the client's public key via a
cnf.jkt claim (RFC 7800), and resource access requires presenting a
fresh DPoP proof matching the request method + URI on every call.

Three layers:

- crypto/dpop.rs validates DPoP proof JWTs (ES256 only in v1):
  signature, typ=dpop+jwt, alg=ES256, htm, htu, iat within ±300s,
  jti unseen. Returns the JWK SHA-256 thumbprint per RFC 7638.

- services/dpop_jti_cache.rs is a bounded FIFO replay cache for jti
  claims (capacity 16384, TTL 600s) mirroring event_dedup_cache.

- The broker token-exchange branch issues access tokens with cnf.jkt
  populated when DPoP is present and returns token_type=DPoP. The
  AuthUser middleware enforces cnf.jkt by requiring + validating a
  matching DPoP header on inbound API requests.

Existing Bearer-token flows are untouched. DPoP is opt-in per request
based on header presence. mw/auth.rs uses the same dpop module to
verify the inbound proof and check thumbprint equality with the
token's cnf.jkt before allowing access.

Discovery metadata advertises dpop_signing_alg_values_supported:
["ES256"] on both .well-known endpoints.

Refs: #549 (V2 hardening 5/7)
…d mTLS)

Adds optional certificate-bound access tokens for the broker
token-exchange path. When MTLS_CLIENT_CERT_HEADER is configured AND
the inbound broker exchange carries that header (set by an upstream
mTLS-terminating proxy), NyxID parses the forwarded PEM client cert,
computes its SHA-256 thumbprint over the DER, and binds the issued
access_token to it via the cnf.x5t#S256 claim per RFC 8705 §3.

Resource access then requires the same cert header on every API call;
the AuthUser middleware verifies the thumbprint matches the token's
cnf.x5t#S256 before allowing access. Tokens without the claim still
work as plain Bearer.

DPoP (V2-5) takes precedence when both headers are presented — it's
stronger because it doesn't require trusting a deployment-level proxy.

Off by default: MTLS_CLIENT_CERT_HEADER unset means mTLS binding is
disabled regardless of any X-Client-Cert header on incoming requests.
Operators MUST set the env var explicitly AND configure their proxy
to strip the header from external traffic before forwarding —
otherwise an attacker can forge a binding by injecting the header
themselves.

Discovery metadata advertises tls_client_certificate_bound_access_tokens:
true on both .well-known endpoints so spec-compliant clients can
discover the capability.

Refs: #549 (V2 hardening 6/7)
…evocation

Adds optional outbound webhook delivery on broker binding revocation.
When the OAuth client has a revocation_webhook_url + secret configured,
revoke paths (client-initiated, user-initiated, reuse-detection cascade)
fire an HMAC-SHA256-signed POST containing the binding_hash, client_id,
revoke_source, reason, and revoked_at timestamp.

The webhook secret is stored encrypted on the OauthClient via the
existing EncryptionKeys envelope. Delivery is fire-and-forget via
tokio::spawn with bounded retry (3 attempts, 1s/4s/16s exponential
backoff, 10s timeout per attempt). HTTP failures are logged but do not
roll back the revoke.

Signature header: X-NyxID-Signature: sha256=<hex>. Companion headers:
X-NyxID-Event, X-NyxID-Delivery-Id for receiver-side correlation.
Event type: "oauth_broker_binding.revoked".

Shrinks the revocation propagation window from "wait for the 5-min
broker access_token to expire" to "fire-and-forget HTTP within
seconds." Combined with V2-5 (DPoP) and V2-6 (mTLS), revocation is
now near-real-time for cooperating broker integrations.

Discovery metadata advertises
oauth_broker_binding_revocation_webhook_supported: true on both
.well-known endpoints so spec-compliant clients can detect the
capability.

Refs: #549 (V2 hardening 7/7 — V2 complete)
Three small follow-ups from the post-V2 code review:

- handlers/oauth.rs:1114 — the PAR endpoint's client-credentials match
  arm bound `secret` in a guard branch that returns early without using
  it. Same -D warnings unused-variable failure that bit commit #11
  earlier on the /oauth/bindings reverse-lookup. Fix mirrors that one:
  swap the unused half for a wildcard pattern.

- services/par_service.rs — PAR_TTL_SECS lowered from 90s to 60s. RFC
  9126 §2.2 says "MUST be short and SHOULD NOT exceed sixty seconds";
  the 90s ceiling slightly exceeded the spec. 60s still covers MFA-
  inclusive logins in practice while staying inside the spec.

- services/cae_webhook_service.rs — wrap the raw HMAC secret in
  `Zeroizing<String>` inside dispatch_revocation_event before the
  spawned task moves it. Best-effort wipe of the String buffer when
  the task completes; matches the existing user_credentials_service
  pattern.

Refs: #549
Adds a "V2 hardening" section to the skill reference doc summarising
the four user-/integration-visible V2 capabilities:

- DPoP / mTLS sender-constrained access tokens (cnf.jkt / cnf.x5t#S256)
- Pushed Authorization Requests for off-the-wire external_subject
- Binding introspection via /oauth/introspect (RFC 7662)
- Revocation webhooks shrinking propagation from minutes to seconds

The user-facing CLI and web UI are unchanged, so the existing CLI /
Web UI sections stay the way they were. The new section frames the
V2 capabilities as deployment / integration concerns and points
implementers at the discovery metadata for capability detection.

Refs: #549
V2-6 (32e9a34) added MTLS_CLIENT_CERT_HEADER to AppConfig but skipped
.env.example. Adds it under a new "OAuth broker bindings" section with
the operator-facing security warning: the proxy MUST strip this
header from external traffic before forwarding, otherwise an attacker
can inject the header to forge a certificate-bound token.

Includes the stock header names for nginx, ALB, and Envoy so operators
have a starting point. Off by default — leaving the var unset is
backward-compatible.

Refs: #549
Companion to a8b8113 (.env.example). Adds a new "OAuth Broker
Bindings" section to docs/ENV.md, right before "Rate Limiting", with
the same operator-facing security warning and proxy-specific header
names (nginx X-Client-Cert, ALB x-amzn-mtls-clientcert, Envoy
x-forwarded-client-cert).

Notes that the rest of V2 hardening (DPoP, PAR, AAD, chain-follow,
RFC 7662 introspection, CAE webhooks) is configured via compile-time
constants — no other env vars added in this PR.

Refs: #549
@kaiweijw kaiweijw merged commit 0d0dd22 into main Apr 28, 2026
10 checks passed
@kaiweijw kaiweijw deleted the feat/issue-549-broker-capability-enabled branch April 28, 2026 15:42
eanzhao added a commit to aevatarAI/aevatar that referenced this pull request Apr 29, 2026
NyxID#549 merged 2026-04-28 (PR ChronoAIProject/NyxID#555) — broker
contract is frozen. ADR status flips proposed -> accepted; the broker
seam in code splits cleanly along the contract.

Major:
- ExternalIdentityBindingGAgent: defensive subject-mismatch guard at
  the top of HandleCommitBinding / HandleRevokeBinding so a routing bug
  cannot silently persist a binding under the wrong actor key. ApplyBound
  no longer overwrites State.ExternalSubject on subsequent events — the
  field is an actor-identity invariant set once on first bind.
- INyxIdCapabilityBroker: drop ResolveBindingAsync. Reads now go strictly
  through IExternalIdentityBindingQueryPort so write-side and read-side
  seams stay separated. ADR §INyxIdCapabilityBroker updated to match.
- New BindingNotFoundException for the "never bound" case so callers can
  distinguish it from BindingRevokedException ("NyxID-side invalid_grant
  on a previously-bound subject"). InMemoryCapabilityBroker switched.
- InMemoryCapabilityBroker: _revokedBindings -> ConcurrentDictionary so
  it shares the thread-safety model of _bindings; the fake now also
  implements IExternalIdentityBindingQueryPort over the same dictionary
  so test wiring stays tight.
- ExternalSubjectRefExtensionsTests: cover EnsureValid + ToActorId
  (valid subject, empty platform / external_user_id, colon-in-field,
  null), so the actor-id format and required-field invariants are
  pinned by tests, not just by review.

Minor:
- HandleCommitBinding now uses string.IsNullOrEmpty consistently with
  HandleRevokeBinding — protobuf fields never produce whitespace-only
  strings, so the IsNullOrWhiteSpace inconsistency was a footgun.
- Idempotency check carries a comment explaining the guarantee comes
  from cluster event-store OCC plus single-actor turn ordering, not
  from the in-handler check alone.
- Proto comment on ExternalIdentityBindingState.external_subject notes
  the field exists for projection ergonomics and that ApplyBound never
  overwrites it.
- ExternalIdentityBindingGAgentTests: cover null-subject paths for both
  HandleCommitBinding and HandleRevokeBinding.
- InMemoryCapabilityBrokerTests: read paths exercise the QueryPort seam
  explicitly (no more broker.ResolveBindingAsync).

Tests: 29 Identity-tagged tests pass (was 18 — 11 new from extensions
coverage and null-subject paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] OAuth broker bindings — NyxID-side refresh_token storage with opaque binding_id for third-party apps

1 participant