feat(oauth): OAuth broker bindings (token vault) — closes #549#555
Merged
Conversation
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
8 tasks
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
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>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
binding_idinstead 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.LocalRefreshTokenCapabilityBrokertoNyxIdRemoteCapabilityBroker.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 ORurn:nyxid:scope:broker_bindinginallowed_scopes), the auth-code response carriesbinding_idinstead ofrefresh_tokenand the access_token TTL is 5 min.POST /oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:token-exchangeandsubject_token_type=urn:nyxid:params:oauth:token-type:binding-id— exchange abinding_idfor a fresh access_token. Refresh chain rotates server-side. OptionalDPoPheader (RFC 9449) orMTLS_CLIENT_CERT_HEADER(RFC 8705) binds the issued token to the client's key/cert viacnf.jkt/cnf.x5t#S256.POST /oauth/par— RFC 9126 Pushed Authorization Requests. Pushexternal_subject_*server-to-server before the browser redirect;/oauth/authorizeaccepts the resultingrequest_uri.Revocation:
POST /oauth/revoke— RFC 7009; recognizesurn:nyxid:params:oauth:token-type:binding-idastoken_type_hint(orbnd_prefix as defensive fallback). Always 200 per RFC.DELETE /oauth/bindings/{binding_id}— REST-style alias; always 204.oauth_broker_binding.revokedHMAC-SHA256-signed webhook to the client'srevocation_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 viatoken_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):grant_types_supportedlist (incl.urn:ietf:params:oauth:grant-type:token-exchange)subject_token_types_supportedincludesurn:nyxid:params:oauth:token-type:binding-idpushed_authorization_request_endpoint,request_uri_parameter_supported,require_pushed_authorization_requests: falsedpop_signing_alg_values_supported: ["ES256"]tls_client_certificate_bound_access_tokens: trueclient_secret_basicintoken_endpoint_auth_methods_supportednyxid_broker_binding_supported: true,oauth_broker_binding_revocation_webhook_supported: trueIssue acceptance checklist
oauth_broker_bindings+ 加密 refresh_token 存储d271f22POST /oauth/tokenreturnsbinding_idfor broker clientsfacb4a602b0115GET /oauth/bindings/{binding_id}introspection5a43bd5GET /oauth/bindings?external_subject_*reverse lookup91cf781DELETE /oauth/bindings/{binding_id}+ cascade1072569+cf3756bf31e355+47692dd62c2ed5ce037f3+25d5999urn:nyxid:params:oauth:token-type:binding-id0c6c1a5urn:nyxid:scope:broker_bindingas broker trigger8a87fd1V2 hardening (now in scope)
4bc6951exchange_via_binding(loser doesn't re-rotate)8a084c23d1ee50external_subjectb3c4adb4e5c5d032e9a34b1b4d22f195bc0Commit narrative (27 total)
V1 implementation (13):
c7f8e63—broker_capability_enabledflag on OauthClient108182f— fix: include flag on OidcCredentialsResponse13b915b— persist optional external_subject on AuthorizationCoded271f22— oauth_broker_binding model + 4 indexesfacb4a6— /oauth/token broker path returns binding_id02b0115— RFC 8693 token-exchange + reuse-detection cascade1072569— /oauth/revoke extension + user-account list/revoke APIf31e355— frontend /settings/authorizations page62c2ed5— discovery metadata, audit events, integration tests47692dd—nyxid oauth bindings {list, show, revoke}CLI5a43bd5— GET /oauth/bindings/{binding_id} introspection91cf781— GET /oauth/bindings reverse lookupce037f3— skills/nyxid V1 reference docContract alignment with #549 comment from @eanzhao (5):
14.
0c6c1a5— refactor: subject_token_type URN tourn:nyxid:params:oauth:token-type:binding-id15.
cf3756b— DELETE /oauth/bindings/{binding_id} REST alias16.
8a87fd1—urn:nyxid:scope:broker_bindingas broker-mode trigger17.
d41c61e— fix: token-exchange gate honors scope trigger too18.
e1872bc— fix: clippy unused-variable on /oauth/bindings authV2 hardening (8 — including the post-V2 review fix):
19–26. See V2 table above. Plus
25d5999— skills/nyxid V2 capabilities note.Security model
binding_idis never persisted server-side. OnlySHA-256(binding_id)is stored as_id. Same pattern asRefreshToken.jti.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.invalid_grant/ 404 / 204-always on every wrong-client / missing / revoked / auth-failure path. No enumeration leaks.RefreshTokenis already revoked when a binding tries to use it, all bindings for(client_id, user_id)are cascade-revoked.cnf.jkt/cnf.x5t#S256. Middleware verifies on every API call.oauth_broker_binding.revokedevent fired fire-and-forget on every revoke (3-attempt exponential backoff, 10s timeout).broker_capability_enabledflag orurn:nyxid:scope:broker_bindinginallowed_scopes. Both are admin-gated.oauth_broker_binding_issued,_token_refreshed(withvia_chain_follow),_revoked(with source),_reuse_detected(with cascade count). Logs include only the OAuthclient_idand a 16-charbinding_hashprefix — never the rawbinding_id, refresh_token, orexternal_user_id.urn:nyxid:params:oauth:*documented in CLAUDE.md.Test Plan
cargo test,npm run build,cargo build -p nyxid-cli)NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs)nyxid oauth bindings --helpand the three sub-commandscargo clippy --all-targets -- -D warningsclean across all 27 commits/settings/authorizationspage renders correctlyOperational notes
MTLS_CLIENT_CERT_HEADERis 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.revocation_webhook_secretis set-once via the developer-app create/update API for v1. Adding arotate-secretendpoint is a follow-up.crypto/dpop.rsandservices/dpop_jti_cache.rsdoc comments.Checklist
#[serde(skip_serializing)]on Mongo fields,#[serde(default)]on every new optional field)Zeroizing<String>for spawn lifetime)Closes #549.