diff --git a/README.md b/README.md index bc5e7f7e..bcafddf4 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ OAuth/OIDC authenticates a human to a service. **ZeroID implements true delegate - **Agent Identity Registry** — Register agents, MCP servers, services, and applications as first-class entities. Classify by role (`orchestrator`, `autonomous`, `tool_agent`), enrich with metadata (`framework`, `version`, `publisher`, `capabilities`), assign trust levels, and manage the full lifecycle: register → activate → deactivate → de-provision. - **OAuth 2.1 Token Issuance** — Full OAuth 2.1 support: `client_credentials`, `jwt_bearer` (RFC 7523), `token_exchange` (RFC 8693) for delegation, `api_key`, `authorization_code` (PKCE), `refresh_token`, `urn:openid:params:grant-type:ciba` (OpenID CIBA Core 1.0). +- **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. - **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. @@ -706,15 +708,114 @@ curl -s -X POST https://auth.highflame.ai/oauth2/token \ --- +### Pattern 7: Sender-constrained tokens for high-risk agents (DPoP) + +**Scenario:** A finance-team agent issues high-value payment instructions. The bearer access token it carries is, by default, a portable credential — anyone who steals it gets full impersonation until expiry or revocation. For a 1-hour TTL on a budget-transferring NHI, that's an unacceptable blast radius. + +**The problem without ZeroID:** Mitigations like network-bound MTLS, IP allowlists, or short token TTLs are coarse and operationally painful. They either constrain where the agent can run (defeating workload portability) or add a refresh storm that hits the token endpoint every few minutes. + +**With ZeroID:** RFC 9449 DPoP — Demonstrating Proof of Possession. The agent generates an asymmetric key in its own process memory, signs a fresh DPoP proof for each request to `/oauth2/token`, and ZeroID binds the issued token to that key by embedding `cnf.jkt` (the JWK thumbprint) inside the JWT. The same proof mechanism is replayed at every resource-server call: a stolen access token is useless without the private key that signed the proof. + +```bash +# 1. Agent (or its SDK) generates an ephemeral ES256 keypair and signs a DPoP proof +# over { typ=dpop+jwt, htm=POST, htu=https://auth.example/oauth2/token, iat, jti }. +# 2. Request a token with the proof in the DPoP header. +curl -s -X POST https://auth.example/oauth2/token \ + -H "DPoP: " \ + -d 'grant_type=client_credentials' \ + -d 'client_id=finance-bot' \ + -d 'client_secret=...' \ + -d 'account_id=acme' -d 'project_id=prod' \ + -d 'scope=payments:write' +# → +# { +# "access_token": "...", ← carries cnf.jkt = thumbprint of agent's key +# "token_type": "DPoP", ← signals to the agent that proofs are required downstream +# "expires_in": 3600 +# } + +# 3. Calling a resource server: include the access token + a *new* DPoP proof +# whose ath claim hashes the access token and whose htu/htm match this call. +curl -s -X POST https://payments.example/transfer \ + -H "Authorization: DPoP " \ + -H "DPoP: " \ + -d '...' + +# 4. The resource server validates the per-request proof against the cnf.jkt +# it pulls from ZeroID's introspection response. Stolen token without the +# key → invalid_dpop_proof, instantly. +``` + +**Why this matters:** Every grant type ZeroID issues — `client_credentials`, `jwt_bearer`, `token_exchange`, `api_key`, `authorization_code`, `refresh_token`, even CIBA — produces a DPoP-bound token when the caller attaches a proof. The agent's key never leaves its process; ZeroID never stores it; rotation is a no-op (next request, new key, new cnf). For agents whose tokens cross orchestrator → sub-agent → tool-agent hops, the binding survives the entire `token_exchange` chain because the new proof's key gets bound at each step. + +Replay defence is atomic: each proof's `jti` is INSERT-or-fail on a primary-key column. No pre-check race window — the second request collapses to `invalid_dpop_proof` at the database level. Full reference: [`docs/dpop-and-dcr.md`](docs/dpop-and-dcr.md). + +--- + +### Pattern 8: Self-service agent registration (Dynamic Client Registration) + +**Scenario:** An MCP server, an SDK, or a per-deployment AI agent needs an OAuth client to talk to ZeroID — but you can't ship the platform's admin API surface to every tenant who installs your tool. Hand-rolling a sign-up flow adds an operator burden and a security hole the moment the form is exposed. + +**The problem without ZeroID:** Most OAuth servers force you to provision every client through their admin console, which makes any "install this tool and it works" experience impossible. The teams that try to automate it usually expose their internal admin API to the public internet behind a thin shim, then patch CVEs in that shim for the next decade. + +**With ZeroID:** RFC 7591 dynamic client registration with RFC 7592 management — gated by an initial access token (an ordinary ZeroID-issued JWT with the reserved `client:register` scope). The platform mints initial access tokens to authorised registrants (your installer, your CLI's first-run bootstrap, your MCP server's onboarding flow); each one is single-issuer / single-audience / scope-restricted, so the surface is the same shape as any other OAuth call. + +```bash +# 1. Platform mints an initial access token (ordinary client_credentials grant +# against a confidential client whose allowed_scopes includes client:register). +IAT=$(curl -s -X POST https://auth.example/oauth2/token \ + -d 'grant_type=client_credentials' \ + -d "client_id=$BOOTSTRAP_CLIENT" -d "client_secret=$BOOTSTRAP_SECRET" \ + -d 'account_id=acme' -d 'project_id=prod' \ + -d 'scope=client:register' | jq -r .access_token) + +# 2. Tool registers itself. +curl -s -X POST https://auth.example/oauth2/register \ + -H "Authorization: Bearer $IAT" \ + -d '{ + "client_name": "Acme Notebook MCP", + "grant_types": ["client_credentials"], + "scope": "notebook:read notebook:write", + "software_id": "com.acme.notebook", + "software_version": "2.4.0" + }' +# → +# { +# "client_id": "9f...", +# "client_secret": "", +# "registration_access_token": "", +# "registration_client_uri": "https://auth.example/oauth2/register/9f..." +# } + +# 3. The tool stores its own client_id + client_secret locally and uses them +# for every subsequent /oauth2/token call. The registration_access_token +# only ever leaves the tool's hands when it's calling its own +# /oauth2/register/{client_id} endpoint to update/rotate/delete itself. +curl -X PUT https://auth.example/oauth2/register/9f... \ + -H "Authorization: Bearer " \ + -d '{"client_name":"Acme Notebook MCP","grant_types":["client_credentials"],"scope":"notebook:read"}' + +curl -X DELETE https://auth.example/oauth2/register/9f... \ + -H "Authorization: Bearer " +``` + +**Why this matters:** DCR-registered clients carry `registration_source = 'dynamic'`; admin-provisioned clients carry `registration_source = 'internal'`. The two pools are isolated at the repo layer — a stolen `registration_access_token` can never reach an internal client, regardless of how the service code is called. DCR clients are deliberately blocked from `token_exchange` and `authorization_code` (no `IdentityID` binding, no interactive flow) so a self-registered tool cannot escalate into a delegation actor or run a PKCE consent flow. `client:register` is in ZeroID's reserved-claims set, so it cannot be smuggled in via `additional_claims` on `token_exchange`. + +Operationally: deployers who never mint a `client:register`-scoped token have DCR effectively disabled (the endpoint exists but every request 401s). Adoption is gradual and explicit. Full reference: [`docs/dpop-and-dcr.md`](docs/dpop-and-dcr.md). + +--- + ## Architecture ```mermaid graph TD subgraph ZEROID ["ZeroID"] direction TB - IR[Identity Registry] --> CS[Credential Service
ES256 signing · Policy enforcement · Audit] + IR[Identity Registry] --> CS[Credential Service
ES256/RS256 signing · Policy enforcement · Audit] OG[OAuth2 Grants
client_credentials · jwt_bearer · api_key
authorization_code · refresh_token · ciba
] --> CS DL[Delegation Engine
RFC 8693 token_exchange] --> CS + DPOP[DPoP Validator
RFC 9449 · jti replay store · cnf.jkt binding] --> OG + DCR[Dynamic Registration
RFC 7591 / 7592 · IAT-gated] --> IR CS --> AT[Attestation] CS --> CAE[CAE Signals
Real-time revocation] @@ -724,12 +825,14 @@ graph TD CAE --> DB WPT --> DB CS --> DB + DPOP --> DB end - Agent([AI Agent]) -- "api_key / jwt_bearer" --> OG + Agent([AI Agent]) -- "api_key / jwt_bearer
(+ optional DPoP)" --> OG Orchestrator([Orchestrator]) -- "token_exchange" --> DL SDK([SDK / CLI]) -- "authorization_code" --> OG - Downstream([MCP Server / Tool]) -- "introspect / verify" --> WPT + Installer([Installer / Bootstrap]) -- "client:register IAT" --> DCR + Downstream([MCP Server / Tool]) -- "introspect / verify
(+ DPoP for bound tokens)" --> WPT style ZEROID fill:#1a1a2e,stroke:#e94560,stroke-width:3px,color:#fff style CS fill:#2d6a4f,color:#fff @@ -750,6 +853,8 @@ graph TD | CLI (`authorization_code`) | RS256 | 90 days | User ID | — | | MCP (`authorization_code` + refresh) | RS256 | 1 hour | User ID | — | | CIBA (`urn:openid:params:grant-type:ciba`) | RS256 | 15 min | Approving user ID | `email`, `name`, `backchannel_client_id`, `token_exchange="ciba"` | +| **DPoP-bound** (any grant + `DPoP` header) | — (modifier) | — (inherits grant) | — (inherits grant) | adds `cnf.jkt` claim; response `token_type: "DPoP"` | +| **DCR client_credentials** | ES256 | 1 hour | DCR client's WIMSE URI (when bound) | identical claims to internal `client_credentials`; client's `registration_source` distinguishes provenance for audit | --- @@ -767,6 +872,8 @@ graph TD | POST | `/oauth2/token/introspect` | Token introspection (RFC 7662) | | POST | `/oauth2/token/revoke` | Token revocation (RFC 7009) | | POST | `/oauth2/bc-authorize` | CIBA backchannel authorization request (OpenID CIBA Core §7) | +| POST | `/oauth2/register` | Dynamic client registration (RFC 7591). Requires initial access token with `client:register` scope. | +| GET / PUT / DELETE | `/oauth2/register/{client_id}` | Client management (RFC 7592). Authenticated by `registration_access_token`. | | GET | `/oauth2/token/verify` | Forward-auth endpoint for reverse proxies (nginx `auth_request`, Caddy `forward_auth`) | ### Admin (protect at network layer) @@ -827,6 +934,10 @@ 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) | +| 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 | +| Client Configuration Endpoint | RFC 7592 | Read/update/delete of dynamically registered clients via `registration_access_token` | --- diff --git a/config.go b/config.go index 1bd01ec3..49463cf5 100644 --- a/config.go +++ b/config.go @@ -132,6 +132,14 @@ type ServerConfig struct { // // Set to empty string ("") to register admin routes at the router root. AdminPathPrefix *string `koanf:"admin_path_prefix"` + + // TrustForwardedHeaders tells the server to read X-Forwarded-Proto and + // X-Forwarded-Host when reconstructing the effective request URL for + // DPoP htu validation (RFC 9449 §4.3). Production deployers behind a + // trusted edge proxy (nginx, AWS ALB, GCP LB) flip this on; deployers + // that terminate TLS at the service itself leave it false so spoofed + // proxy headers cannot move the htu goalposts. + TrustForwardedHeaders bool `koanf:"trust_forwarded_headers"` } // GetAdminPathPrefix returns the admin route prefix. Defaults to "/api/v1" @@ -175,7 +183,27 @@ type KeysConfig struct { // TokenConfig holds JWT issuance settings. type TokenConfig struct { - Issuer string `koanf:"issuer"` + Issuer string `koanf:"issuer"` + // BaseURL is the publicly-visible URL clients use to reach this server. + // It seeds every URI returned in responses (`registration_endpoint` and + // `registration_client_uri` in DCR responses, the JWT `iss` claim's + // authority for verification, and the well-known discovery doc). + // + // MUST be the URL clients actually hit — including any reverse-proxy + // rewrites or path prefixes the deployment adds. If a proxy fronts + // zeroid at https://auth.example.com/v1 and forwards to a backend on + // http://10.0.0.5:8080, set BaseURL = "https://auth.example.com/v1" + // (the public form), NOT the backend URL. A wrong value here doesn't + // break token signing — JWTs continue to verify against jwks_uri — but + // every URI the server PUBLISHES (DCR responses, discovery) becomes + // unreachable from outside. Validate() will reject empty values; format + // validity is the deployer's responsibility. + // + // Note: DPoP `htu` validation does NOT depend on BaseURL — it compares + // against the request's effective URL (via RequestURLMiddleware) so + // reverse-proxied deployments don't need to keep BaseURL and the proxy + // in lock-step for token issuance to work. BaseURL is purely about the + // shape of URIs the server hands BACK to clients. BaseURL string `koanf:"base_url"` DefaultTTL int `koanf:"default_ttl"` MaxTTL int `koanf:"max_ttl"` @@ -261,6 +289,9 @@ func (c *Config) Validate() error { if err := validateWIMSEDomain(c.WIMSEDomain); err != nil { return fmt.Errorf("wimse_domain: %w", err) } + if c.Token.BaseURL == "" { + return fmt.Errorf("token.base_url is required: every URI the server hands back (DCR registration_client_uri, well-known discovery) derives from it; see TokenConfig.BaseURL") + } return nil } diff --git a/docs/dpop-and-dcr.md b/docs/dpop-and-dcr.md new file mode 100644 index 00000000..c03f1dfc --- /dev/null +++ b/docs/dpop-and-dcr.md @@ -0,0 +1,326 @@ +# DPoP & Dynamic Client Registration — Reference + +ZeroID implements two standards that together let agents and tools onboard themselves and prove ongoing possession of their credentials: + +- **DPoP** ([RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449)) — sender-constrained access tokens. Defeats bearer-token theft. +- **Dynamic Client Registration** ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) + **Client Configuration Endpoint** ([RFC 7592](https://datatracker.ietf.org/doc/html/rfc7592)) — self-service OAuth client onboarding. Defeats hand-rolled admin shims. + +This document covers both because they share design choices (intrinsic per-request auth, ZeroID-specific tenant + scope constraints) and they ship together. For the conceptual one-page overview see [the Real-World Patterns section of the README](../README.md#real-world-patterns) (Pattern 7 = DPoP, Pattern 8 = DCR). + +--- + +## DPoP — Demonstrating Proof of Possession (RFC 9449) + +### What it solves + +A standard OAuth2 access token is a **bearer credential**: anyone who has the bytes can use them until expiry or revocation. For a finance-bot or a high-trust orchestrator, the window between "token stolen" and "token revoked" is wide enough to do real damage. Network mitigations (mTLS, IP allowlists) don't scale to portable workloads. + +DPoP closes the gap by **binding the access token to a key the client holds in process memory**. Every request that presents the token must also present a fresh JWT signed by that key. A stolen token without the key is useless. + +### Wire shape + +#### 1. Requesting a DPoP-bound token + +The client generates an asymmetric key (ES256 or RS256), then signs a proof JWT whose payload covers the HTTP method (`htm`), the target URI (`htu`), an issued-at timestamp (`iat`), and a fresh JWT ID (`jti`). The proof's protected header carries `typ: "dpop+jwt"` and the **public** JWK. + +```http +POST /oauth2/token HTTP/1.1 +Host: auth.example.com +Content-Type: application/x-www-form-urlencoded +DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7... + +grant_type=client_credentials&client_id=...&client_secret=...&account_id=...&project_id=...&scope=payments:write +``` + +Response: + +```json +{ + "access_token": "eyJ0eXAi...", + "token_type": "DPoP", + "expires_in": 3600, + "scope": "payments:write" +} +``` + +The access token's claims include a `cnf` (confirmation) member with `jkt` = the base64url-encoded SHA-256 JWK thumbprint of the proof key (RFC 7638). + +#### 2. Calling a resource server + +Per RFC 9449 §7, the access token is presented with the `DPoP` (not `Bearer`) auth scheme, and a **new** proof JWT is signed for **this** call. The new proof carries an `ath` claim — `base64url(SHA-256(access_token))` — that binds the proof to this specific access token. + +```http +POST /api/v1/transfer HTTP/1.1 +Host: payments.example.com +Authorization: DPoP eyJ0eXAi... +DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7... (different jti, ath claim set) +``` + +The resource server calls ZeroID's `POST /oauth2/token/introspect`, sees `cnf.jkt` in the response, and validates the per-request proof against that thumbprint plus its own htm/htu. + +### How ZeroID validates a proof + +Implemented in [`internal/service/dpop.go`](../internal/service/dpop.go). Twelve steps, ordered for security: + +1. Parse the JWS, fail on malformed input. +2. `typ` header must be `dpop+jwt`. +3. `alg` must be one of the allow-listed asymmetric algorithms (`ES256`, `RS256`). Symmetric algs are spec-forbidden. +4. `jwk` header must be present and must **not** carry private-key material (we type-assert against `jwk.ECDSAPrivateKey` / `jwk.RSAPrivateKey` / `jwk.OKPPrivateKey`). +5. Verify the JWS signature using the embedded public JWK. +6. Parse the payload (only after signature is verified). +7. `htm` matches the request method **exactly** (case-sensitive per RFC 9110 §9.1). +8. `htu` matches the request URL **after stripping query and fragment**. The URL we compare against is the **request's effective URL** — captured by `internal/middleware/RequestURLMiddleware` — not the configured `cfg.Token.BaseURL`. This makes reverse-proxied deployments work transparently when `ServerConfig.TrustForwardedHeaders = true`. +9. `iat` must fall inside the freshness window (60 s in the past + 5 s of clock-skew tolerance). +10. `jti` is consumed atomically by INSERTing into `dpop_jti` with `jti` as the primary key. A `23505` duplicate-key error → replay. **Wall-clock expiry** (`now + freshness + skew`), not iat-relative — a malicious client cannot backdate `iat` to shrink the row's replay-coverage window. +11. If an access token is being validated at a resource server (`ValidateProofForToken`), `ath` is required and must equal `base64url(SHA-256(access_token))`. +12. Compute the JWK thumbprint (SHA-256 per RFC 7638) — this is what becomes `cnf.jkt` on the issued token. + +### Token-endpoint behaviour + +`/oauth2/token` reads the `DPoP` header on **every** grant type. When present and valid: + +- The issued JWT carries `cnf: {"jkt": ""}`. +- The persisted `IssuedCredential` row records the thumbprint in `dpop_key_thumbprint`. +- The HTTP response's `token_type` field is `"DPoP"` instead of `"Bearer"`. +- Token introspection (`POST /oauth2/token/introspect`) surfaces the `cnf` claim alongside other claims. + +When absent: standard Bearer behaviour. Existing callers see no change. + +#### Error mapping + +| Outcome | HTTP | OAuth `error` field | +|---|---|---| +| `DPoP` header missing | (n/a — DPoP is optional) | — | +| Malformed JWS / wrong typ / bad alg / private-key JWK / htm/htu/iat/jti/ath failure | 400 | `invalid_dpop_proof` | +| JTI replay detected | 400 | `invalid_dpop_proof` | +| **`dpop_jti` table unreachable** | 500 | `server_error` | + +The 500 case is deliberate: a database-unreachable signal must never look like an "invalid proof" 4xx, because that would mask outages as client errors. The service returns `ErrDPoPStorageFailure` (in `internal/service/dpop.go`) and the handler maps it explicitly. + +### Reverse-proxy deployments + +If ZeroID sits behind nginx / an AWS ALB / a GCP LB, set: + +```yaml +server: + trust_forwarded_headers: true +``` + +`RequestURLMiddleware` will then read `X-Forwarded-Proto` and `X-Forwarded-Host` when reconstructing the URL the client signed. **Leave it `false` if the service terminates TLS itself** — otherwise a spoofed `X-Forwarded-Host` could move the `htu` goalpost. + +### Replay store and cleanup + +The `dpop_jti` table is INSERT-only at the service layer; the cleanup worker (`internal/worker/cleanup.go`) sweeps rows where `expires_at < now()` on its periodic tick. Storage parameters are tuned for high churn: + +```sql +CREATE TABLE dpop_jti ( + jti VARCHAR(512) PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL +) WITH (fillfactor = 90, autovacuum_vacuum_scale_factor = 0.05); +``` + +`autovacuum_vacuum_scale_factor = 0.05` keeps dead-tuple ratio under control (the default 0.2 is too lazy for INSERT-then-DELETE workloads). + +> **Operational follow-up (not in this PR):** at >100 token/sec sustained DPoP traffic, split the `dpop_jti` cleanup into a tighter 5-minute ticker independent of the credential/auth-code sweep. The hourly cadence is fine for early adoption; the analyst flagged the cutoff for visibility. + +### When the DPoP-bound token reaches a downstream resource server + +ZeroID does not gate its **own** endpoints on a downstream DPoP proof — `/oauth2/token/introspect` and `/oauth2/token/revoke` accept the access token under either auth scheme. The proof check is the resource server's job, and resource servers reach for `ValidateProofForToken` (passes `accessToken` so the `ath` check fires) rather than `ValidateProof`. + +### Refresh-token binding (RFC 9449 §5) + +When a refresh token is issued in conjunction with a DPoP-bound access token (via the `authorization_code` grant whose `/oauth2/token` call carried a proof), the refresh token itself is bound to the same public key. Implementation: + +- `refresh_tokens.dpop_key_thumbprint` (added in migration 026) records the thumbprint. +- `RotateRefreshToken` accepts the presented proof's thumbprint as a parameter; the comparison runs **inside the rotation transaction**, so a bound refresh token that's presented with a wrong key / no proof: + - returns `invalid_dpop_proof`, **not** `invalid_grant`, + - does **not** consume the refresh token (the transaction rolls back), + - leaves the legitimate caller's next request with the correct key still working. +- The successor row carries the same thumbprint, so binding survives the rotation chain indefinitely. + +An **unbound** refresh token (issued without DPoP) is not retroactively bound — even if a later rotation request presents a proof. That decision could change later; today it preserves the explicit user opt-in to DPoP. + +### Limitations / future work + +- **CIBA push mode**: the CIBA push delivery path mints a token server-side with no client proof available; those tokens come out as Bearer regardless. CIBA poll mode is fully DPoP-capable today (the poll's `/oauth2/token` call carries the proof normally). +- **Resource-server SDKs**: the in-tree SDK helpers do not yet implement client-side proof generation. Tracking issue: future work. +- **PS256 / EdDSA**: only ES256 and RS256 are advertised today via `dpop_signing_alg_values_supported`. Adding more is a one-line allow-list change. +- **Unbound → bound upgrade on rotation**: today an unbound refresh token stays unbound across rotation even if the new request carries a proof. Upgrading on first proof is a small extension once we agree it's the desired UX. + +--- + +## Dynamic Client Registration (RFC 7591 / RFC 7592) + +### What it solves + +OAuth clients are normally provisioned by an admin via a console. That works when the deployer of a service is the same team that runs the AS — but agent-tooling vendors who ship MCP servers, SDKs, or installer scripts to other tenants have no way to express "register an OAuth client when you install me." The workarounds (expose the admin API publicly with a sign-up form, ask each tenant's ops team to file a ticket) are operationally and security-wise bad. + +RFC 7591 defines a standard registration endpoint; RFC 7592 defines the per-client management endpoints that follow it. + +### Wire shape + +#### 1. Mint an initial access token (one-time, per registrant) + +The platform decides who's allowed to self-register and mints an **initial access token (IAT)** — an ordinary ZeroID-issued JWT whose `scopes` claim contains the reserved `client:register` scope. Tokens are minted via any standard ZeroID grant (typically `client_credentials` against a confidential bootstrap client whose `allowed_scopes` list includes `client:register`). + +```bash +IAT=$(curl -s -X POST https://auth.example/oauth2/token \ + -d 'grant_type=client_credentials' \ + -d 'client_id=...' -d 'client_secret=...' \ + -d 'account_id=acme' -d 'project_id=prod' \ + -d 'scope=client:register' | jq -r .access_token) +``` + +#### 2. Register + +```bash +curl -s -X POST https://auth.example/oauth2/register \ + -H "Authorization: Bearer $IAT" \ + -H "Content-Type: application/json" \ + -d '{ + "client_name": "Acme Notebook MCP", + "grant_types": ["client_credentials"], + "scope": "notebook:read notebook:write", + "token_endpoint_auth_method": "client_secret_post", + "software_id": "com.acme.notebook", + "software_version": "2.4.0" + }' +``` + +The response contains the new `client_id` + `client_secret` (the plaintext secret is shown once and **never** persisted in plain form) and a `registration_access_token` that authenticates subsequent management calls. + +```json +{ + "client_id": "9f43b1c2...", + "client_secret": "shown-once", + "client_id_issued_at": 1716000000, + "client_secret_expires_at": 0, + "client_name": "Acme Notebook MCP", + "grant_types": ["client_credentials"], + "scope": "notebook:read notebook:write", + "token_endpoint_auth_method": "client_secret_post", + "registration_access_token": "shown-once", + "registration_client_uri": "https://auth.example/oauth2/register/9f43b1c2..." +} +``` + +#### 3. Manage (RFC 7592) + +```bash +# Read current registration +curl -X GET https://auth.example/oauth2/register/9f43b1c2 \ + -H "Authorization: Bearer " + +# Replace registration (full replacement — RFC 7592 §3) +curl -X PUT https://auth.example/oauth2/register/9f43b1c2 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"client_name":"Acme Notebook MCP","grant_types":["client_credentials"],"scope":"notebook:read"}' + +# Delete +curl -X DELETE https://auth.example/oauth2/register/9f43b1c2 \ + -H "Authorization: Bearer " +``` + +GET and PUT responses re-include the public client metadata but **never** re-reveal `client_secret` or `registration_access_token`. + +### What ZeroID enforces + +Implemented in [`internal/handler/dynamic_registration.go`](../internal/handler/dynamic_registration.go) (handler) and [`internal/service/oauth_client.go`](../internal/service/oauth_client.go) (service). + +#### On the IAT (RFC 7591 §3.4) + +`validateInitialAccessToken` rejects unless **all** of the following hold: + +- JWS signature verifies against the local JWKS (any zeroid signing key). +- `iss` equals `cfg.Token.Issuer`. +- `aud` contains `cfg.Token.Issuer` — defence against tokens minted for a different protected resource being replayed here. (Per RFC 9068 §3, ZeroID-issued access tokens default to `aud = [issuer]`, so this works out of the box.) +- `iat`/`exp` are in-window per `jwt.WithValidate(true)`. +- The `scopes` claim contains `client:register`. The accessor tries `[]string` first then falls back to `[]any` — matching `internal/middleware/AgentAuthMiddleware`'s pattern. + +Tenant claims (`account_id`, `project_id`, `sub`) are extracted and surfaced into the audit log; OAuth clients themselves are global per ZeroID's design (see `domain/token.go`'s `OAuthClient` comment) so they are not stored with a tenant column. + +#### On the registration body (RFC 7591 §2) + +Validated by `validateDCRClientMetadata`: + +- `client_name` is required. +- `grant_types` defaults to `["client_credentials"]`. The allow-list for DCR-registered clients is **`client_credentials`** and **`urn:ietf:params:oauth:grant-type:jwt-bearer`** only. Notably absent: + - `authorization_code` — no interactive consent flow exists for self-registered clients. + - `urn:ietf:params:oauth:grant-type:token-exchange` — DCR clients have no `IdentityID` binding and so cannot legitimately act as a delegation actor. Re-enable once that binding exists. +- `token_endpoint_auth_method` is `client_secret_post`, `client_secret_basic`, or empty (defaults to `client_secret_basic` per RFC 7591 §2). `"none"` is explicitly rejected — this server requires client authentication. +- `redirect_uris` is accepted for spec compliance but ignored. + +#### On the registration_access_token + +`VerifyRegistrationToken` performs a **constant-time** check: regardless of whether the client_id exists, exactly one bcrypt comparison runs (against the stored hash on a hit, against `dummyRegistrationTokenHash` on a miss). Both hashes use `dcrBcryptCost = 12` so timing is balanced. + +`RegistrationSource != "dynamic"` short-circuits to "not found" before the bcrypt comparison so an admin-registered (internal) client can never be authenticated via a registration token — even if one is somehow guessed. + +#### On the delete path + +`DeleteByClientID` (in `internal/store/postgres/oauth_client.go`) adds `WHERE registration_source = 'dynamic'` as defence-in-depth. Even if a service-layer check is skipped or bypassed, the repository refuses to remove an internal client. + +### Reserved `cnf` claim + +For the DPoP/DCR cross-cut: `cnf` is now in `reservedClaims` in `internal/service/oauth.go`. The external-principal-exchange flow (which lets a trusted service inject claims via `additional_claims`) cannot smuggle a `cnf.jkt` value through; the only path that writes `cnf` is `credential.IssueCredential` when `req.DPoPKeyThumbprint` came from a validated proof. + +### Database + +DCR adds two columns on the existing `oauth_clients` table: + +```sql +ALTER TABLE oauth_clients + ADD COLUMN registration_source VARCHAR(50) NOT NULL DEFAULT 'internal', + ADD COLUMN registration_access_token VARCHAR(255); +``` + +Existing rows back-fill to `'internal'`; no manual migration step. The `registration_access_token` column is `nullzero`-tagged in the Go model so internal clients persist NULL (not `""`). + +### Discovery + +`/.well-known/oauth-authorization-server` advertises: + +- `registration_endpoint` — set to `{baseURL}/oauth2/register` when DCR is wired (it always is in this build; the endpoint exists but every request 401s if the deployer doesn't mint `client:register`-scoped tokens). +- `dpop_signing_alg_values_supported: ["ES256", "RS256"]`. + +### Limitations / future work + +- **`software_statement`** (RFC 7591 §2.3) — signed metadata assertions — not implemented. +- **Per-client `client_secret_expires_at`** — DCR clients today have a non-expiring secret (the response field is `0` per RFC 7591 §3.2.1 conventions). Rotation is supported via the `RotateSecret` admin path on the underlying client, but no automatic expiry/rotation policy is wired. +- **Initial-access-token issuance UX** — ZeroID does not yet ship a one-call "mint me an IAT" admin endpoint. Today it's an ordinary `client_credentials` call against a confidential client whose `allowed_scopes` list includes `client:register`. + +--- + +## Configuration knobs + +```yaml +server: + trust_forwarded_headers: false # set true when behind a trusted edge proxy (nginx/ALB/etc.) for DPoP htu correctness +``` + +No DCR-specific config knobs — the feature is governed by which clients hold `client:register` scope. + +## Operational signals + +| Signal | What it means | Fix | +|---|---|---| +| `level=info, msg="DCR: dynamic client registered", client_id=..., registered_by_*=...` | DCR registration succeeded | informational; preserve for audit | +| `level=info, msg="DCR: initial access token rejected"` | A POST /oauth2/register call presented an IAT that failed validation | check IAT issuer / audience / freshness / scope | +| `level=info, msg="DCR: initial access token rejected — insufficient scope"` | IAT validated cryptographically but lacked `client:register` | client error; respond 403 (handler already does) | +| `level=error, msg="DPoP JTI store unavailable"` | DB write to `dpop_jti` failed for a non-23505 reason | check PG availability; ZeroID returned 500 | + +## Files + +| Concern | File | +|---|---| +| DPoP validator | [`internal/service/dpop.go`](../internal/service/dpop.go) | +| DPoP handler integration | [`internal/handler/oauth.go`](../internal/handler/oauth.go) (search `DPoPProof`) | +| Request-URL middleware (for DPoP htu) | [`internal/middleware/request_url.go`](../internal/middleware/request_url.go) | +| DCR handler (POST/GET/PUT/DELETE) | [`internal/handler/dynamic_registration.go`](../internal/handler/dynamic_registration.go) | +| DCR service methods | [`internal/service/oauth_client.go`](../internal/service/oauth_client.go) (search `DynamicRegisterClient`, `VerifyRegistrationToken`, `UpdateDynamicClient`, `DeleteDynamicClient`) | +| Repo guard | [`internal/store/postgres/oauth_client.go`](../internal/store/postgres/oauth_client.go) (`DeleteByClientID`) | +| Cleanup worker (sweeps `dpop_jti`) | [`internal/worker/cleanup.go`](../internal/worker/cleanup.go) | +| Discovery (well-known) | [`internal/handler/wellknown.go`](../internal/handler/wellknown.go) | +| Migrations | [`migrations/024_dynamic_client_registration.up.sql`](../migrations/024_dynamic_client_registration.up.sql), [`migrations/025_dpop.up.sql`](../migrations/025_dpop.up.sql), [`migrations/026_refresh_token_dpop_binding.up.sql`](../migrations/026_refresh_token_dpop_binding.up.sql) | +| Refresh-token rotation w/ binding | [`internal/service/refresh_token.go`](../internal/service/refresh_token.go) (`RotateRefreshToken`, `ErrDPoPBindingMismatch`) | diff --git a/domain/credential.go b/domain/credential.go index 63bc6715..5bf3f19a 100644 --- a/domain/credential.go +++ b/domain/credential.go @@ -89,4 +89,9 @@ type IssuedCredential struct { // every credential in the tree so workflow-scoped audit queries are // O(1) instead of walking the parent_jti chain. Issue #81. MissionID string `bun:"mission_id,type:varchar(255),nullzero" json:"mission_id,omitempty"` + // DPoPKeyThumbprint is the base64url JWK thumbprint (RFC 7638 SHA-256) of + // the DPoP key bound to this credential (RFC 9449 §6.1). Empty for plain + // Bearer tokens. When non-empty, the access token carries a cnf.jkt claim + // and must be presented with a valid DPoP proof at the protected resource. + DPoPKeyThumbprint string `bun:"dpop_key_thumbprint,type:text" json:"dpop_key_thumbprint,omitempty"` } diff --git a/domain/refresh_token.go b/domain/refresh_token.go index e538120b..be2c55cc 100644 --- a/domain/refresh_token.go +++ b/domain/refresh_token.go @@ -39,4 +39,9 @@ type RefreshToken struct { ExpiresAt time.Time `bun:"type:timestamptz,notnull" json:"expires_at"` RevokedAt *time.Time `bun:"revoked_at" json:"revoked_at,omitempty"` CreatedAt time.Time `bun:"type:timestamptz,notnull,default:current_timestamp" json:"created_at"` + // DPoPKeyThumbprint is the base64url JWK thumbprint (RFC 7638) of the + // DPoP key the refresh token is bound to. NULL/empty ⇒ unbound (Bearer). + // Copied verbatim onto every successor row on rotation; checked against + // the presented proof inside the rotation transaction (RFC 9449 §5). + DPoPKeyThumbprint string `bun:"dpop_key_thumbprint,nullzero" json:"-"` } diff --git a/domain/token.go b/domain/token.go index e4110a99..cf71850c 100644 --- a/domain/token.go +++ b/domain/token.go @@ -125,6 +125,17 @@ type OAuthClient struct { // api_key paths have. Nil for plain human-session clients (CLI, MCP). IdentityID *string `bun:"identity_id,type:uuid,nullzero" json:"identity_id,omitempty"` + // Dynamic Client Registration (RFC 7591/7592) + // RegistrationSource is "internal" for clients created via the admin/internal + // API path, "dynamic" for clients created via POST /oauth2/register. + RegistrationSource string `bun:"registration_source" json:"registration_source,omitempty"` + // RegistrationAccessToken is a bcrypt hash of the management bearer token + // returned at RFC 7591 registration. NULL for internal clients (the column + // is NULL-able in the schema; `nullzero` ensures bun INSERTs NULL when the + // field is the Go zero value instead of persisting an empty string that + // would defeat `IS NULL` queries). Never JSON-serialized. + RegistrationAccessToken string `bun:"registration_access_token,nullzero" json:"-"` + // Lifecycle IsActive bool `bun:"is_active" json:"is_active"` CreatedAt time.Time `bun:"created_at" json:"created_at"` diff --git a/internal/handler/dynamic_registration.go b/internal/handler/dynamic_registration.go new file mode 100644 index 00000000..f3c88cf5 --- /dev/null +++ b/internal/handler/dynamic_registration.go @@ -0,0 +1,421 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + "github.com/lestrrat-go/jwx/v4/jwt" + "github.com/rs/zerolog/log" + + "github.com/highflame-ai/zeroid/domain" + "github.com/highflame-ai/zeroid/internal/service" +) + +// allowedDCRGrantTypes are the only grant types permitted for dynamically +// registered clients. authorization_code is intentionally excluded — this is +// a machine-to-machine server and DCR-registered clients can't run an +// interactive consent flow. token-exchange (RFC 8693) is intentionally excluded +// too — DCR-registered clients have no IdentityID binding and so cannot +// legitimately act as a delegation actor; allowing the grant type at +// registration time creates a sharp edge for no benefit. Add it back when +// DCR-clients-as-actors becomes a real use case with explicit identity binding. +var allowedDCRGrantTypes = map[string]bool{ + "client_credentials": true, + "urn:ietf:params:oauth:grant-type:jwt-bearer": true, +} + +// dcrClientRegisterScope is the scope an initial access token must carry to +// be allowed to call POST /oauth2/register. +const dcrClientRegisterScope = "client:register" + +// ── DCR types ──────────────────────────────────────────────────────────────── + +// DCRRegisterInput is the RFC 7591 §3.1 registration request, with the +// initial access token presented as a Bearer header. +type DCRRegisterInput struct { + Authorization string `header:"Authorization" required:"true" doc:"Initial access token: Bearer "` + Body struct { + ClientName string `json:"client_name" required:"true" doc:"Human-readable client name"` + GrantTypes []string `json:"grant_types,omitempty" doc:"OAuth grant types (defaults to client_credentials)"` + Scope string `json:"scope,omitempty" doc:"Space-separated scope list"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty" doc:"client_secret_post or client_secret_basic"` + SoftwareID string `json:"software_id,omitempty" doc:"Software identifier (RFC 7591)"` + SoftwareVersion string `json:"software_version,omitempty" doc:"Software version (RFC 7591)"` + Contacts []string `json:"contacts,omitempty" doc:"Operator contact emails"` + // RedirectURIs is accepted, persisted, and echoed back on GET/PUT + // for RFC 7591/7592 metadata-roundtrip fidelity, but it is not + // consulted at /oauth2/token time — DCR clients have no + // authorization_code grant in the allow-list and therefore no + // redirect-URI step in any code path. Stored, not used. + RedirectURIs []string `json:"redirect_uris,omitempty" doc:"Stored on the client record but unused at token time (no interactive flows are allowed for DCR clients)"` + } +} + +// DCROutput is the polymorphic response body. RFC 7591/7592 success bodies are +// dynamic-shape; error bodies are oauthErrorBody. +type DCROutput struct { + Status int + Body any +} + +// DCRGetInput / DCRUpdateInput / DCRDeleteInput share the same auth shape: +// the registration_access_token in the Authorization header, and client_id in +// the path. +type DCRGetInput struct { + Authorization string `header:"Authorization" required:"true" doc:"Bearer registration_access_token"` + ClientID string `path:"client_id" required:"true" doc:"OAuth client_id from registration"` +} + +type DCRUpdateInput struct { + Authorization string `header:"Authorization" required:"true" doc:"Bearer registration_access_token"` + ClientID string `path:"client_id" required:"true" doc:"OAuth client_id from registration"` + Body struct { + ClientName string `json:"client_name" required:"true"` + GrantTypes []string `json:"grant_types,omitempty"` + Scope string `json:"scope,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + SoftwareID string `json:"software_id,omitempty"` + SoftwareVersion string `json:"software_version,omitempty"` + Contacts []string `json:"contacts,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + } +} + +type DCRDeleteInput struct { + Authorization string `header:"Authorization" required:"true" doc:"Bearer registration_access_token"` + ClientID string `path:"client_id" required:"true" doc:"OAuth client_id from registration"` +} + +// ── DCR routes ─────────────────────────────────────────────────────────────── + +// registerDynamicRegistrationRoutes mounts the RFC 7591/7592 endpoints on the +// public group. Authentication is intrinsic to each request: +// - POST — initial access token JWT with client:register scope. +// - GET / PUT / DELETE — registration_access_token issued at registration. +func (a *API) registerDynamicRegistrationRoutes(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "oauth-register", + Method: http.MethodPost, + Path: "/oauth2/register", + Summary: "Dynamic Client Registration (RFC 7591)", + Description: "Registers a new OAuth2 client. Requires an initial access token JWT " + + "with the `client:register` scope in its scopes claim — issued out-of-band " + + "to authorised registrants. Returns the new client_id, client_secret, and a " + + "registration_access_token that authenticates subsequent RFC 7592 management calls.", + Tags: []string{"OAuth"}, + }, a.dcrRegisterOp) + + huma.Register(api, huma.Operation{ + OperationID: "oauth-registration-get", + Method: http.MethodGet, + Path: "/oauth2/register/{client_id}", + Summary: "Read Client Registration (RFC 7592)", + Tags: []string{"OAuth"}, + }, a.dcrGetOp) + + huma.Register(api, huma.Operation{ + OperationID: "oauth-registration-update", + Method: http.MethodPut, + Path: "/oauth2/register/{client_id}", + Summary: "Update Client Registration (RFC 7592)", + Description: "Full replacement, not partial update (RFC 7592 §3). Omitted fields revert to RFC 7591 defaults.", + Tags: []string{"OAuth"}, + }, a.dcrUpdateOp) + + huma.Register(api, huma.Operation{ + OperationID: "oauth-registration-delete", + Method: http.MethodDelete, + Path: "/oauth2/register/{client_id}", + Summary: "Delete Client Registration (RFC 7592)", + Tags: []string{"OAuth"}, + }, a.dcrDeleteOp) +} + +// ── DCR ops ────────────────────────────────────────────────────────────────── + +func (a *API) dcrRegisterOp(ctx context.Context, input *DCRRegisterInput) (*DCROutput, error) { + iatClaims, err := a.validateInitialAccessToken(input.Authorization) + if err != nil { + return dcrErr(err), nil + } + + v, err := validateDCRClientMetadata(input.Body.ClientName, input.Body.Scope, input.Body.TokenEndpointAuthMethod, input.Body.GrantTypes) + if err != nil { + return dcrErr(err), nil + } + + client, plainSecret, plainRegToken, regErr := a.oauthClientSvc.DynamicRegisterClient(ctx, service.DynamicRegisterClientRequest{ + Name: input.Body.ClientName, + GrantTypes: v.GrantTypes, + Scopes: v.Scopes, + TokenEndpointAuthMethod: v.AuthMethod, + SoftwareID: input.Body.SoftwareID, + SoftwareVersion: input.Body.SoftwareVersion, + Contacts: input.Body.Contacts, + RedirectURIs: input.Body.RedirectURIs, + }) + if regErr != nil { + if errors.Is(regErr, service.ErrOAuthClientAlreadyExists) { + return dcrErr(&dcrError{status: http.StatusConflict, code: "invalid_client_metadata", desc: "client already exists"}), nil + } + log.Error().Err(regErr).Msg("dynamic client registration failed") + return dcrErr(&dcrError{status: http.StatusInternalServerError, code: "server_error", desc: "failed to register client"}), nil + } + + // Audit log: who minted what. registered_by_* claims are derived from the + // initial access token; clients themselves remain global per zeroid's + // design but the registrant's tenant context is preserved here for ops. + log.Info(). + Str("client_id", client.ClientID). + Str("registered_by_sub", iatClaims.Subject). + Str("registered_by_account_id", iatClaims.AccountID). + Str("registered_by_project_id", iatClaims.ProjectID). + Msg("DCR: dynamic client registered") + + // Register response = the standard GET/PUT shape (dcrClientResponse) plus + // the two values that are shown exactly once and never re-revealed. + body := a.dcrClientResponse(client) + body["client_secret"] = plainSecret + body["registration_access_token"] = plainRegToken + return &DCROutput{Status: http.StatusCreated, Body: body}, nil +} + +func (a *API) dcrGetOp(ctx context.Context, input *DCRGetInput) (*DCROutput, error) { + cl, err := a.authorizeDCRManagement(ctx, input.Authorization, input.ClientID) + if err != nil { + return dcrErr(err), nil + } + return &DCROutput{Status: http.StatusOK, Body: a.dcrClientResponse(cl)}, nil +} + +func (a *API) dcrUpdateOp(ctx context.Context, input *DCRUpdateInput) (*DCROutput, error) { + if _, err := a.authorizeDCRManagement(ctx, input.Authorization, input.ClientID); err != nil { + return dcrErr(err), nil + } + + v, err := validateDCRClientMetadata(input.Body.ClientName, input.Body.Scope, input.Body.TokenEndpointAuthMethod, input.Body.GrantTypes) + if err != nil { + return dcrErr(err), nil + } + + updated, updateErr := a.oauthClientSvc.UpdateDynamicClient(ctx, input.ClientID, service.DynamicRegisterClientRequest{ + Name: input.Body.ClientName, + GrantTypes: v.GrantTypes, + Scopes: v.Scopes, + TokenEndpointAuthMethod: v.AuthMethod, + SoftwareID: input.Body.SoftwareID, + SoftwareVersion: input.Body.SoftwareVersion, + Contacts: input.Body.Contacts, + RedirectURIs: input.Body.RedirectURIs, + }) + if updateErr != nil { + // Race window between authorizeDCRManagement and Update: client may + // have been deleted in between. Map ErrOAuthClientNotFound to the + // same 401 the auth path uses; other errors are infra failures. + if errors.Is(updateErr, service.ErrOAuthClientNotFound) { + return dcrErr(&dcrError{status: http.StatusUnauthorized, code: "invalid_token", desc: "client no longer exists"}), nil + } + log.Error().Err(updateErr).Str("client_id", input.ClientID).Msg("dynamic client update failed") + return dcrErr(&dcrError{status: http.StatusInternalServerError, code: "server_error", desc: "failed to update client registration"}), nil + } + return &DCROutput{Status: http.StatusOK, Body: a.dcrClientResponse(updated)}, nil +} + +func (a *API) dcrDeleteOp(ctx context.Context, input *DCRDeleteInput) (*DCROutput, error) { + if _, err := a.authorizeDCRManagement(ctx, input.Authorization, input.ClientID); err != nil { + return dcrErr(err), nil + } + if err := a.oauthClientSvc.DeleteDynamicClient(ctx, input.ClientID); err != nil { + log.Error().Err(err).Str("client_id", input.ClientID).Msg("dynamic client delete failed") + return dcrErr(&dcrError{status: http.StatusInternalServerError, code: "server_error", desc: "failed to delete client registration"}), nil + } + return &DCROutput{Status: http.StatusNoContent, Body: nil}, nil +} + +// ── DCR helpers ────────────────────────────────────────────────────────────── + +// dcrValidatedFields collects the post-validation client metadata shared by +// register + update. +type dcrValidatedFields struct { + GrantTypes []string + Scopes []string + AuthMethod string +} + +// validateDCRClientMetadata applies RFC 7591/7592 input rules: client_name +// required, grant_types subset of allowedDCRGrantTypes, token_endpoint_auth_method +// constrained to client_secret_post / client_secret_basic. Defaults are filled +// in. Returns the normalised fields or a *dcrError ready for dcrErr(). +func validateDCRClientMetadata(clientName, scopeStr, authMethodIn string, grantTypesIn []string) (*dcrValidatedFields, *dcrError) { + if clientName == "" { + return nil, &dcrError{status: http.StatusBadRequest, code: "invalid_client_metadata", desc: "client_name is required"} + } + grantTypes := grantTypesIn + if len(grantTypes) == 0 { + grantTypes = []string{"client_credentials"} + } + for _, gt := range grantTypes { + if !allowedDCRGrantTypes[gt] { + return nil, &dcrError{status: http.StatusBadRequest, code: "invalid_client_metadata", desc: "unsupported grant_type: " + gt} + } + } + authMethod := authMethodIn + switch authMethod { + case "": + // RFC 7591 §2 default. Applying it here makes the validator's + // "normalised fields" contract honest — the response body's + // token_endpoint_auth_method will report the effective value + // even when the caller omitted the field. + authMethod = "client_secret_basic" + case "client_secret_post", "client_secret_basic": + // accepted + case "none": + return nil, &dcrError{status: http.StatusBadRequest, code: "invalid_client_metadata", desc: "token_endpoint_auth_method 'none' is not supported; this server requires client authentication"} + default: + return nil, &dcrError{status: http.StatusBadRequest, code: "invalid_client_metadata", desc: "unsupported token_endpoint_auth_method: " + authMethod} + } + var scopes []string + if scopeStr != "" { + scopes = strings.Fields(scopeStr) + } else { + scopes = []string{} + } + return &dcrValidatedFields{GrantTypes: grantTypes, Scopes: scopes, AuthMethod: authMethod}, nil +} + +// dcrError is the structured error a DCR op returns to the dispatch layer. +// It's the only error type the helpers below ever produce; the typed +// parameter in `dcrErr` (rather than the `error` interface) makes that +// invariant compile-time-enforced and avoids a dead "unexpected error" +// fallback branch. +type dcrError struct { + status int + code string + desc string +} + +func (e *dcrError) Error() string { return e.code + ": " + e.desc } + +func dcrErr(de *dcrError) *DCROutput { + return &DCROutput{Status: de.status, Body: oauthErrorBody{Error: de.code, ErrorDescription: de.desc}} +} + +// initialAccessTokenClaims captures the tenant-relevant claims of a successfully +// validated initial access token. Used for audit logging only — DCR-registered +// OAuth clients are global (no tenant column) by design. +type initialAccessTokenClaims struct { + Subject string + AccountID string + ProjectID string +} + +// validateInitialAccessToken parses the Authorization header as `Bearer `, +// verifies against the server's JWKS, and requires: +// - iss equal to the configured issuer (defense against tokens from another AS), +// - aud containing the configured issuer (defense against tokens minted for a +// different protected resource being replayed at /oauth2/register; per +// RFC 9068 §3, ZeroID-issued access tokens default to aud=[issuer]), +// - the `client:register` scope present in the scopes claim. +// +// Returns the extracted tenant claims on success or a *dcrError on failure. +func (a *API) validateInitialAccessToken(authHeader string) (*initialAccessTokenClaims, *dcrError) { + if !strings.HasPrefix(authHeader, "Bearer ") { + return nil, &dcrError{status: http.StatusUnauthorized, code: "invalid_token", desc: "Authorization header with Bearer initial access token is required"} + } + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + + parsed, err := jwt.Parse([]byte(tokenStr), + jwt.WithKeySet(a.jwksSvc.KeySet()), + jwt.WithValidate(true), + jwt.WithIssuer(a.issuer), + jwt.WithAudience(a.issuer), + ) + if err != nil { + log.Info().Err(err).Msg("DCR: initial access token rejected") + return nil, &dcrError{status: http.StatusUnauthorized, code: "invalid_token", desc: "initial access token is invalid or expired"} + } + + // The scopes claim may decode as []string (when jwx preserves the issuance + // shape) or []any (when JSON-parsed without type hints). Mirror the + // AgentAuthMiddleware pattern: try []string first, then []any. Without + // this, ZeroID-issued tokens (which set scopes as []string at issuance) + // would never appear to have the client:register scope. + hasRegisterScope := false + if scopes, err := jwt.Get[[]string](parsed, "scopes"); err == nil { + for _, sc := range scopes { + if sc == dcrClientRegisterScope { + hasRegisterScope = true + break + } + } + } else if scopes, err := jwt.Get[[]any](parsed, "scopes"); err == nil { + for _, sc := range scopes { + if str, ok := sc.(string); ok && str == dcrClientRegisterScope { + hasRegisterScope = true + break + } + } + } + if !hasRegisterScope { + log.Info().Msg("DCR: initial access token rejected — insufficient scope") + return nil, &dcrError{status: http.StatusForbidden, code: "insufficient_scope", desc: "initial access token must have '" + dcrClientRegisterScope + "' scope"} + } + + claims := &initialAccessTokenClaims{} + claims.Subject, _ = parsed.Subject() + claims.AccountID, _ = jwt.Get[string](parsed, "account_id") + claims.ProjectID, _ = jwt.Get[string](parsed, "project_id") + // Audit trail integrity: an IAT whose account_id / project_id claims are + // stripped would still pass the iss/aud/scope checks but produce an + // audit log with empty registered_by_* fields, muddying who registered + // what. The IAT issuer (the platform) controls these claims, so the + // requirement is a sanity check against mis-issued tokens, not a + // trust-boundary check. + if claims.AccountID == "" || claims.ProjectID == "" { + log.Info(). + Str("registered_by_sub", claims.Subject). + Msg("DCR: initial access token rejected — missing tenant claims") + return nil, &dcrError{status: http.StatusForbidden, code: "invalid_token", desc: "initial access token must carry account_id and project_id claims"} + } + return claims, nil +} + +// authorizeDCRManagement verifies the registration_access_token in the +// Authorization header against the stored bcrypt hash for the path's client_id. +func (a *API) authorizeDCRManagement(ctx context.Context, authHeader, clientID string) (*domain.OAuthClient, *dcrError) { + if !strings.HasPrefix(authHeader, "Bearer ") { + return nil, &dcrError{status: http.StatusUnauthorized, code: "invalid_token", desc: "Authorization header with Bearer registration_access_token is required"} + } + regToken := strings.TrimPrefix(authHeader, "Bearer ") + + client, err := a.oauthClientSvc.VerifyRegistrationToken(ctx, clientID, regToken) + if err != nil { + // 401 for genuine not-found / bad token; 500 for DB / infra failures so an + // outage isn't masked as an auth rejection. + if errors.Is(err, service.ErrOAuthClientNotFound) { + return nil, &dcrError{status: http.StatusUnauthorized, code: "invalid_token", desc: "invalid or unknown registration_access_token"} + } + log.Error().Err(err).Str("client_id", clientID).Msg("DCR: registration-token verification failed") + return nil, &dcrError{status: http.StatusInternalServerError, code: "server_error", desc: "failed to verify registration token"} + } + return client, nil +} + +// dcrClientResponse returns the RFC 7591 §3.2.1 / RFC 7592 §3 representation of a +// registered client. Used for GET/PUT responses (secrets are not re-revealed +// after the initial registration). +func (a *API) dcrClientResponse(cl *domain.OAuthClient) map[string]any { + return map[string]any{ + "client_id": cl.ClientID, + "client_id_issued_at": cl.CreatedAt.Unix(), + "client_secret_expires_at": 0, + "client_name": cl.Name, + "grant_types": cl.GrantTypes, + "scope": strings.Join(cl.Scopes, " "), + "token_endpoint_auth_method": cl.TokenEndpointAuthMethod, + "registration_client_uri": a.baseURL + "/oauth2/register/" + cl.ClientID, + } +} diff --git a/internal/handler/oauth.go b/internal/handler/oauth.go index 92718756..b87992cd 100644 --- a/internal/handler/oauth.go +++ b/internal/handler/oauth.go @@ -16,7 +16,11 @@ import ( // ── OAuth types ────────────────────────────────────────────────────────────── type TokenInput struct { - Body struct { + // DPoPProof carries the RFC 9449 proof-of-possession JWT. When non-empty, + // the issued token is bound to the proof key (cnf.jkt) and token_type is + // returned as "DPoP" instead of "Bearer". + DPoPProof string `header:"DPoP" doc:"DPoP proof JWT (RFC 9449)"` + Body struct { GrantType string `json:"grant_type" required:"true" doc:"OAuth grant type"` ClientID string `json:"client_id,omitempty" doc:"OAuth client ID"` ClientSecret string `json:"client_secret,omitempty" doc:"OAuth client secret"` @@ -128,7 +132,11 @@ func (a *API) registerOAuthRoutes(api huma.API) { "This happens — silently, RFC 6749 §3.3-style — when the issued token's lifetime would otherwise outlive a bound that " + "constrains it. Effective TTL is `min(requested_ttl, service_max_ttl, policy.max_ttl_seconds, time_until(identity.expires_at), time_until(credential.expires_at))`. " + "Callers MUST use `expires_in` from the response (not the value they requested) when scheduling refresh. " + - "Server-side logs name the clamp reason; the chokepoint emits a structured log line with `requested_ttl` and the remaining lifetime that won.", + "Server-side logs name the clamp reason; the chokepoint emits a structured log line with `requested_ttl` and the remaining lifetime that won.\n\n" + + "**DPoP (RFC 9449):** Clients may attach a `DPoP` header carrying a proof JWT to bind the issued token to a key. " + + "When the proof validates, the response sets `token_type: \"DPoP\"` (instead of `\"Bearer\"`) and the issued JWT carries " + + "a `cnf.jkt` claim equal to the proof key's JWK thumbprint. Resource servers retrieve `cnf` via introspection and " + + "validate the caller's per-request DPoP proof themselves. Proof JTIs are single-use within a 60s freshness window.", Tags: []string{"OAuth"}, }, a.tokenOp) @@ -225,28 +233,64 @@ func advertiseFormContentType(api huma.API, paths ...string) { } func (a *API) tokenOp(ctx context.Context, input *TokenInput) (*TokenOutput, error) { + // DPoP: optional. When a proof is present the issued token is bound to the + // proof key via cnf.jkt and token_type is returned as "DPoP" (RFC 9449). + var dpopThumbprint string + if input.DPoPProof != "" { + if a.dpopSvc == nil { + return &TokenOutput{ + Status: http.StatusBadRequest, + Body: oauthErrorBody{Error: "invalid_dpop_proof", ErrorDescription: "DPoP is not enabled on this deployment"}, + }, nil + } + // htu must match what the client signed. Prefer the request's effective URL + // (recorded by RequestURLMiddleware) so reverse-proxied deployments work + // transparently; fall back to the configured baseURL only when the + // middleware was not installed (defensive, should not happen in production). + htu := internalMiddleware.EffectiveRequestURL(ctx) + if htu == "" { + htu = a.baseURL + "/oauth2/token" + } + tp, dpopErr := a.dpopSvc.ValidateProof(ctx, http.MethodPost, htu, input.DPoPProof) + if dpopErr != nil { + if errors.Is(dpopErr, service.ErrDPoPStorageFailure) { + log.Error().Err(dpopErr).Msg("DPoP JTI store unavailable") + return &TokenOutput{ + Status: http.StatusInternalServerError, + Body: oauthErrorBody{Error: "server_error", ErrorDescription: "failed to validate DPoP proof"}, + }, nil + } + return &TokenOutput{ + Status: http.StatusBadRequest, + Body: oauthErrorBody{Error: "invalid_dpop_proof", ErrorDescription: dpopErr.Error()}, + }, nil + } + dpopThumbprint = tp + } + accessToken, err := a.oauthSvc.Token(ctx, service.TokenRequest{ - GrantType: input.Body.GrantType, - ClientID: input.Body.ClientID, - ClientSecret: input.Body.ClientSecret, - Scope: input.Body.Scope, - AccountID: input.Body.AccountID, - ProjectID: input.Body.ProjectID, - Subject: input.Body.Subject, - APIKey: input.Body.APIKey, - SubjectToken: input.Body.SubjectToken, - SubjectTokenType: input.Body.SubjectTokenType, - ActorToken: input.Body.ActorToken, - UserID: input.Body.UserID, - UserEmail: input.Body.UserEmail, - UserName: input.Body.UserName, - ApplicationID: input.Body.ApplicationID, - AdditionalClaims: input.Body.AdditionalClaims, - Code: input.Body.Code, - CodeVerifier: input.Body.CodeVerifier, - RedirectURI: input.Body.RedirectURI, - RefreshTokenStr: input.Body.RefreshToken, - AuthReqID: input.Body.AuthReqID, + GrantType: input.Body.GrantType, + ClientID: input.Body.ClientID, + ClientSecret: input.Body.ClientSecret, + Scope: input.Body.Scope, + AccountID: input.Body.AccountID, + ProjectID: input.Body.ProjectID, + Subject: input.Body.Subject, + APIKey: input.Body.APIKey, + SubjectToken: input.Body.SubjectToken, + SubjectTokenType: input.Body.SubjectTokenType, + ActorToken: input.Body.ActorToken, + UserID: input.Body.UserID, + UserEmail: input.Body.UserEmail, + UserName: input.Body.UserName, + ApplicationID: input.Body.ApplicationID, + AdditionalClaims: input.Body.AdditionalClaims, + Code: input.Body.Code, + CodeVerifier: input.Body.CodeVerifier, + RedirectURI: input.Body.RedirectURI, + RefreshTokenStr: input.Body.RefreshToken, + AuthReqID: input.Body.AuthReqID, + DPoPKeyThumbprint: dpopThumbprint, }) if err != nil { log.Error().Err(err).Str("grant_type", input.Body.GrantType).Msg("oauth token request failed") diff --git a/internal/handler/routes.go b/internal/handler/routes.go index c142f82d..fd44d023 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -34,6 +34,7 @@ type API struct { agentSvc *service.AgentService auditSvc *service.AuditService backchannelSvc *service.BackchannelService + dpopSvc *service.DPoPService jwksSvc *signing.JWKSService signingCredSvc *service.SigningCredentialService db *bun.DB @@ -57,6 +58,7 @@ func NewAPI( agentSvc *service.AgentService, auditSvc *service.AuditService, backchannelSvc *service.BackchannelService, + dpopSvc *service.DPoPService, jwksSvc *signing.JWKSService, signingCredSvc *service.SigningCredentialService, db *bun.DB, @@ -76,6 +78,7 @@ func NewAPI( agentSvc: agentSvc, auditSvc: auditSvc, backchannelSvc: backchannelSvc, + dpopSvc: dpopSvc, jwksSvc: jwksSvc, signingCredSvc: signingCredSvc, db: db, @@ -105,11 +108,15 @@ func NewHumaAPI(router chi.Router) huma.API { // RegisterPublic registers endpoints that require no authentication: // health, well-known, OAuth2 endpoints (token, revoke), and forward-auth verify. +// The /oauth2/register endpoints (RFC 7591/7592) live here too — they enforce +// their own intrinsic auth (initial-access-token or registration_access_token) +// per request, so they are not gated by the admin middleware. func (a *API) RegisterPublic(api huma.API, router chi.Router) { a.registerHealthRoutes(api) a.registerWellKnownRoutes(api) a.registerSigningJWKSRoute(api) a.registerOAuthRoutes(api) + a.registerDynamicRegistrationRoutes(api) a.registerAuthVerifyRoute(router) } diff --git a/internal/handler/wellknown.go b/internal/handler/wellknown.go index 3301582a..2a87b19a 100644 --- a/internal/handler/wellknown.go +++ b/internal/handler/wellknown.go @@ -114,6 +114,14 @@ func (a *API) oauthMetadataOp(_ context.Context, _ *struct{}) (*OAuthMetadataOut "response_types_supported": []string{"token"}, "token_endpoint_auth_signing_alg_values_supported": []string{"ES256", "RS256"}, + // RFC 7591 dynamic client registration. + "registration_endpoint": a.baseURL + "/oauth2/register", + + // RFC 9449 — Demonstrating Proof of Possession (DPoP). Algorithms the + // token endpoint will accept on the DPoP header. Symmetric algs are + // excluded by spec. + "dpop_signing_alg_values_supported": []string{"ES256", "RS256"}, + // CIBA (OpenID CIBA Core 1.0) discovery metadata. The fields here // let CIBA-aware clients auto-discover that this AS supports // backchannel authentication and which delivery modes are wired. diff --git a/internal/middleware/request_url.go b/internal/middleware/request_url.go new file mode 100644 index 00000000..a4ce7ca3 --- /dev/null +++ b/internal/middleware/request_url.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "context" + "net/http" + "strings" +) + +// requestURLKey is the context key for the effective request URL. +type requestURLKey struct{} + +// RequestURLMiddleware stores the effective external URL of each request +// on context.Context. Used by DPoP proof validation (RFC 9449 §4.3 htu +// claim) so the htu comparison runs against what the client actually +// hit, not against a static config value that could drift from reality +// under reverse-proxying. +// +// X-Forwarded-Proto / X-Forwarded-Host are consulted only when the +// `trustForwardedHeaders` flag is set — production deployers behind a +// trusted edge proxy (nginx, AWS ALB, GCP LB) flip it on; deployers who +// terminate TLS at the service itself leave it off so spoofed proxy +// headers can't move the goalposts. +func RequestURLMiddleware(trustForwardedHeaders bool) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + host := r.Host + if trustForwardedHeaders { + if v := r.Header.Get("X-Forwarded-Proto"); v != "" { + // Some proxies send "https, http" — first value wins. + scheme = strings.TrimSpace(strings.SplitN(v, ",", 2)[0]) + } + if v := r.Header.Get("X-Forwarded-Host"); v != "" { + host = strings.TrimSpace(strings.SplitN(v, ",", 2)[0]) + } + } + path := r.URL.Path + full := scheme + "://" + host + path + ctx := context.WithValue(r.Context(), requestURLKey{}, full) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + }) + } +} + +// EffectiveRequestURL returns the URL the client used to reach the server, +// as recorded by RequestURLMiddleware. Returns empty string if the +// middleware was not installed (caller falls back to a configured value). +func EffectiveRequestURL(ctx context.Context) string { + v, _ := ctx.Value(requestURLKey{}).(string) + return v +} diff --git a/internal/service/backchannel.go b/internal/service/backchannel.go index 5b011aab..7dd150f9 100644 --- a/internal/service/backchannel.go +++ b/internal/service/backchannel.go @@ -503,6 +503,11 @@ func (s *BackchannelService) dispatchResolution(ctx context.Context, row *domain type RedeemInput struct { AuthReqID string ClientID string + // DPoPKeyThumbprint forwards the proof key thumbprint from the token + // endpoint so a CIBA-redeemed token can still be DPoP-bound (RFC 9449). + // Non-empty when the polling /oauth2/token call carried a valid DPoP + // proof; the issued credential then carries cnf.jkt + token_type "DPoP". + DPoPKeyThumbprint string } // Redeem implements the polling response state machine per CIBA Core §11. @@ -561,7 +566,7 @@ func (s *BackchannelService) Redeem(ctx context.Context, in RedeemInput) (*domai return nil, oauthBadRequest("access_denied", "auth_req_id has already been redeemed") case domain.BackchannelStatusApproved: - return s.issueTokenForApprovedRow(ctx, row) + return s.issueTokenForApprovedRow(ctx, row, in.DPoPKeyThumbprint) default: return nil, oauthBadRequest("invalid_grant", fmt.Sprintf("unexpected request status %q", row.Status)) @@ -576,7 +581,7 @@ func (s *BackchannelService) Redeem(ctx context.Context, in RedeemInput) (*domai // Caller MUST hold the invariant that row.Status == approved. The MarkIssued // guard provides the actual at-most-once gate; on a lost race the second // caller gets affected=0 and an *OAuthError signalling the duplication. -func (s *BackchannelService) issueTokenForApprovedRow(ctx context.Context, row *domain.BackchannelAuthRequest) (*domain.AccessToken, error) { +func (s *BackchannelService) issueTokenForApprovedRow(ctx context.Context, row *domain.BackchannelAuthRequest, dpopKeyThumbprint string) (*domain.AccessToken, error) { // Claim-first: flip approved → issued BEFORE minting the token so only // one caller can ever reach IssueCredential. The conditional UPDATE in // MarkIssued (status='approved' guard) is the at-most-once invariant; @@ -616,15 +621,16 @@ func (s *BackchannelService) issueTokenForApprovedRow(ctx context.Context, row * } accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ - Identity: identity, - Scopes: parseScopeString(row.Scope), - GrantType: domain.GrantTypeCIBA, - TTL: 900, // 15 minutes — short-lived; matches ExternalPrincipalExchange - UseRS256: true, - SubjectOverride: row.ApprovedSubjectID, - UserEmail: row.ApprovedSubjectEmail, - UserName: row.ApprovedSubjectName, - CustomClaims: customClaims, + Identity: identity, + Scopes: parseScopeString(row.Scope), + GrantType: domain.GrantTypeCIBA, + TTL: 900, // 15 minutes — short-lived; matches ExternalPrincipalExchange + UseRS256: true, + SubjectOverride: row.ApprovedSubjectID, + UserEmail: row.ApprovedSubjectEmail, + UserName: row.ApprovedSubjectName, + CustomClaims: customClaims, + DPoPKeyThumbprint: dpopKeyThumbprint, }) if err != nil { return nil, oauthServerError("failed to issue CIBA-grant token", err) @@ -728,7 +734,11 @@ func (s *BackchannelService) dispatchPushApproval(ctx context.Context, row *doma return } - accessToken, err := s.issueTokenForApprovedRow(ctx, row) + // Push mode mints server-side without a client polling the token endpoint, + // so there is no DPoP proof — passes empty thumbprint to keep the token + // as Bearer. Resource-server-side DPoP for CIBA-push tokens is a future + // item if it's ever needed. + accessToken, err := s.issueTokenForApprovedRow(ctx, row, "") if err != nil { // Most likely an OAuthError("access_denied") from a lost race against // a concurrent dispatch. Log and exit — the first dispatcher will diff --git a/internal/service/credential.go b/internal/service/credential.go index 151bc0cd..7747f0fb 100644 --- a/internal/service/credential.go +++ b/internal/service/credential.go @@ -106,6 +106,11 @@ type IssueRequest struct { // mission). Non-empty on token_exchange — the caller has resolved the // subject_token's mission and is propagating it down the chain. MissionID string + // DPoPKeyThumbprint is the base64url JWK thumbprint (RFC 7638 SHA-256) of + // the client's DPoP public key. When non-empty, the issued JWT carries a + // cnf.jkt claim binding the token to that key, and token_type is returned + // as "DPoP" instead of "Bearer" (RFC 9449 §6.1). + DPoPKeyThumbprint string } // ErrScopesNotAllowed is returned when one or more requested scopes are not in the identity's AllowedScopes list. @@ -398,6 +403,11 @@ func (s *CredentialService) IssueCredential(ctx context.Context, req IssueReques _ = token.Set("act", map[string]string{"sub": req.ActingUserID}) } + // DPoP binding: embed cnf.jkt so resource servers can match the proof key (RFC 9449 §6.1). + if req.DPoPKeyThumbprint != "" { + _ = token.Set("cnf", map[string]string{"jkt": req.DPoPKeyThumbprint}) + } + // Sign: RS256 for api_key grant (compatible), ES256 for all agent/NHI flows. // kid lets verifiers pick the right key from the JWKS; typ=JWT is per // JWT-SVID §3 (jwx doesn't default it). @@ -435,6 +445,7 @@ func (s *CredentialService) IssueCredential(ctx context.Context, req IssueReques ParentJTI: req.ParentJTI, DelegatedByWIMSEURI: req.DelegatedBy, MissionID: missionID, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, } if err := s.repo.Create(ctx, cred); err != nil { @@ -448,9 +459,13 @@ func (s *CredentialService) IssueCredential(ctx context.Context, req IssueReques Int("ttl_seconds", ttl). Msg("Credential issued") + tokenType := "Bearer" + if req.DPoPKeyThumbprint != "" { + tokenType = "DPoP" + } accessToken := &domain.AccessToken{ AccessToken: string(signed), - TokenType: "Bearer", + TokenType: tokenType, ExpiresIn: ttl, Scope: strings.Join(req.Scopes, " "), JTI: jti, diff --git a/internal/service/dpop.go b/internal/service/dpop.go new file mode 100644 index 00000000..ed4722c3 --- /dev/null +++ b/internal/service/dpop.go @@ -0,0 +1,291 @@ +package service + +import ( + "context" + "crypto" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v4/jwa" + "github.com/lestrrat-go/jwx/v4/jwk" + "github.com/lestrrat-go/jwx/v4/jws" + "github.com/lestrrat-go/jwx/v4/jwt" + "github.com/uptrace/bun" +) + +// ErrDPoPStorageFailure is returned when the JTI replay-prevention store is +// unavailable. Callers must map this to a 5xx response, not a 4xx "invalid proof". +var ErrDPoPStorageFailure = errors.New("dpop jti store unavailable") + +// dpopFreshnessWindow is the maximum age of a DPoP proof's iat claim. +// RFC 9449 §4.2 recommends a window of at most a few minutes; 60 s is conservative. +const dpopFreshnessWindow = 60 * time.Second + +// dpopClockSkewTolerance allows proofs whose iat is slightly in the future +// to compensate for minor clock differences between client and server. +const dpopClockSkewTolerance = 5 * time.Second + +// dpopMaxJTILen caps the JTI claim at the database column width so an oversized +// jti from a malicious client surfaces as a 4xx proof-invalid error rather than +// a Postgres "value too long for type" that consumeJTI would mis-map to a 5xx. +const dpopMaxJTILen = 512 + +// dpopJTIRecord is the bun model for the dpop_jti replay-prevention table. +type dpopJTIRecord struct { + bun.BaseModel `bun:"table:dpop_jti"` + JTI string `bun:"jti,pk"` + ExpiresAt time.Time `bun:"expires_at"` +} + +// DPoPService validates DPoP proofs (RFC 9449) and prevents proof replay via JTI tracking. +type DPoPService struct { + db *bun.DB +} + +// NewDPoPService creates a new DPoPService backed by the given database. +func NewDPoPService(db *bun.DB) *DPoPService { + return &DPoPService{db: db} +} + +// ValidateProof validates a DPoP proof JWT at the token endpoint. +// method is the HTTP method (e.g. "POST") and htu is the full target URI. +// Returns the base64url JWK thumbprint (RFC 7638 SHA-256) of the proof key on success. +func (s *DPoPService) ValidateProof(ctx context.Context, method, htu, proofJWT string) (string, error) { + return s.validate(ctx, method, htu, proofJWT, nil) +} + +// ValidateProofForToken validates a DPoP proof at a protected resource endpoint for a +// DPoP-bound access token. The proof must carry an ath claim equal to +// base64url(SHA-256(accessToken)). Returns the JWK thumbprint on success. +// Per RFC 9449 §8.2, the authorization server's introspection endpoint returns the +// cnf claim but does not itself validate proofs — resource servers call this method. +func (s *DPoPService) ValidateProofForToken(ctx context.Context, method, htu, proofJWT string, accessToken []byte) (string, error) { + return s.validate(ctx, method, htu, proofJWT, accessToken) +} + +func (s *DPoPService) validate(ctx context.Context, method, htu, proofJWT string, accessToken []byte) (string, error) { + // 1. Parse the JWS message to access protected headers without verifying the signature yet. + msg, err := jws.Parse([]byte(proofJWT)) + if err != nil { + return "", fmt.Errorf("dpop proof is malformed: %w", err) + } + sigs := msg.Signatures() + // RFC 9449 §4.2 defines a DPoP proof as a JWT (compact JWS), which has + // exactly one signature. JWS JSON Serialization allows multiple, and + // blindly reading sigs[0] would let a forger attach a benign signature + // at index 0 alongside an attacker-controlled one at index 1 — only the + // first set of protected headers would be inspected. Reject anything + // that isn't a single-signature compact JWS. + if len(sigs) != 1 { + return "", fmt.Errorf("dpop proof: expected single-signature compact JWS, got %d signatures", len(sigs)) + } + hdr := sigs[0].ProtectedHeaders() + + // 2. typ MUST be "dpop+jwt" (RFC 9449 §4.2). + typ, _ := hdr.Type() + if typ != "dpop+jwt" { + return "", errors.New("dpop proof: typ header must be dpop+jwt") + } + + // 3. Algorithm MUST be an asymmetric signature algorithm (RFC 9449 §4.2). + // We accept ES256 and RS256; symmetric algorithms are not allowed for DPoP. + alg, ok := hdr.Algorithm() + if !ok { + return "", errors.New("dpop proof: alg header is required") + } + switch alg { + case jwa.ES256(), jwa.RS256(): + // accepted + default: + return "", fmt.Errorf("dpop proof: algorithm %s is not supported; use ES256 or RS256", alg) + } + + // 4. jwk header MUST be present and MUST NOT contain a private key (RFC 9449 §4.2). + embeddedKey, ok := hdr.JWK() + if !ok || embeddedKey == nil { + return "", errors.New("dpop proof: jwk header is required") + } + switch embeddedKey.(type) { + case jwk.ECDSAPrivateKey, jwk.RSAPrivateKey, jwk.OKPPrivateKey: + return "", errors.New("dpop proof: jwk header must not contain a private key") + case jwk.SymmetricKey: + // Defence-in-depth: an oct JWK in the jwk header is a private key + // (a shared secret). The alg-asymmetric gate at step 3 already + // rejects HS256-shaped proofs whose signature would require this, + // but enumerating the type explicitly keeps the policy honest if + // a future jwx version adds another asymmetric alg that some + // implementer wires to a symmetric key. + return "", errors.New("dpop proof: jwk header must not contain a symmetric key") + } + + // 5. Verify the proof signature using the embedded public key. + if _, err := jws.Verify([]byte(proofJWT), jws.WithKey(alg, embeddedKey)); err != nil { + return "", fmt.Errorf("dpop proof: signature verification failed: %w", err) + } + + // 6. Parse the JWT payload (signature already verified above). + parsed, err := jwt.ParseInsecure([]byte(proofJWT)) + if err != nil { + return "", fmt.Errorf("dpop proof: payload is malformed: %w", err) + } + + // 7. htm MUST match the HTTP method of the request. RFC 9110 §9.1 says method + // names are case-sensitive uppercase, and RFC 9449 §4.2 inherits that — + // we compare exactly so a lowercase htm cannot slip past on a server that + // later adds DPoP-protected resources with case-collision-sensitive methods. + htm, _ := jwt.Get[string](parsed, "htm") + if htm != method { + return "", fmt.Errorf("dpop proof: htm mismatch (expected %s, got %s)", method, htm) + } + + // 8. htu MUST match the target URI, ignoring query and fragment (RFC 9449 §4.2). + htuClaim, _ := jwt.Get[string](parsed, "htu") + normalizedHTU := normalizeHTU(htu) + if normalizeHTU(htuClaim) != normalizedHTU { + return "", fmt.Errorf("dpop proof: htu mismatch (expected %s)", normalizedHTU) + } + + // 9. iat MUST be present, not too far in the future (clock skew), and within the freshness window. + iat, ok := parsed.IssuedAt() + if !ok { + return "", errors.New("dpop proof: iat claim is required") + } + now := time.Now() + if iat.After(now.Add(dpopClockSkewTolerance)) { + return "", errors.New("dpop proof: iat is too far in the future") + } + if now.After(iat.Add(dpopFreshnessWindow)) { + return "", errors.New("dpop proof: iat is outside the freshness window (proof has expired)") + } + + // 9a. If the proof carries optional exp / nbf claims (jwt.ParseInsecure + // does NOT validate them; jwx v4's WithKey-based jwt.Parse path is + // unavailable here because the signing key is the embedded jwk + // header rather than a pre-known KeySet), enforce them ourselves. + // RFC 9449 §4.2 permits but does not require exp; if a client + // provides it, ignoring it would let an explicitly-expired proof + // succeed on the iat freshness check alone. jwx's `ok` bool already + // signals presence, so an `IsZero()` follow-up would be redundant. + if exp, ok := parsed.Expiration(); ok && now.After(exp.Add(dpopClockSkewTolerance)) { + return "", errors.New("dpop proof: exp has passed") + } + if nbf, ok := parsed.NotBefore(); ok && now.Add(dpopClockSkewTolerance).Before(nbf) { + return "", errors.New("dpop proof: nbf is in the future") + } + + // 10. jti MUST be present and bounded at the column width. + jti, _ := parsed.JwtID() + if jti == "" { + return "", errors.New("dpop proof: jti claim is required") + } + if len(jti) > dpopMaxJTILen { + // Bounded at the column width; oversized JTIs are a 4xx (malformed + // proof), not a 5xx storage failure. + return "", fmt.Errorf("dpop proof: jti exceeds %d bytes", dpopMaxJTILen) + } + + // 11. ath MUST be present and correct when the proof is for a bound access token (RFC 9449 §4.2). + // Checked BEFORE consumeJTI so an ath mismatch doesn't burn the proof's + // jti — the legitimate client can retry with a corrected ath value. + if len(accessToken) > 0 { + ath, err := jwt.Get[string](parsed, "ath") + if err != nil || ath == "" { + return "", errors.New("dpop proof: ath claim is required when presenting a bound access token") + } + if ath != computeATH(accessToken) { + return "", errors.New("dpop proof: ath mismatch") + } + } + + // 12. Compute the JWK thumbprint (RFC 7638 SHA-256) — this becomes the cnf.jkt claim value. + // Done BEFORE consumeJTI so a thumbprint-computation failure (vanishingly + // unlikely on an ES256/RS256 key that already passed jws.Verify) doesn't + // leave the jti consumed but the response a 5xx. + thumbprintBytes, err := embeddedKey.Thumbprint(crypto.SHA256) + if err != nil { + return "", fmt.Errorf("dpop proof: failed to compute key thumbprint: %w", err) + } + thumbprint := base64.RawURLEncoding.EncodeToString(thumbprintBytes) + + // 13. Consume the jti atomically. This is the last side-effecting step; + // anything that can reject the proof on its own merits has already + // run, so a successful consume → valid proof and we hand back the + // thumbprint with confidence the row in dpop_jti will outlive the + // freshness window. + // + // Replay-coverage runs on wall clock (now + freshness + skew), not on + // the client-supplied iat. iat-relative expiry would let a client + // backdate iat to shorten the row's lifetime in the JTI store; + // clock-relative expiry decouples replay-defence from anything the + // client controls. + if err := s.consumeJTI(ctx, jti, now.Add(dpopFreshnessWindow+dpopClockSkewTolerance)); err != nil { + return "", fmt.Errorf("dpop proof: %w", err) + } + return thumbprint, nil +} + +// consumeJTI atomically inserts a JTI into the replay-prevention table. +// A primary-key conflict (SQLSTATE 23505) means the JTI was already seen — replay. +// Any other DB error is returned as ErrDPoPStorageFailure so callers can map it +// to a 5xx instead of a misleading 4xx "invalid proof" response. +func (s *DPoPService) consumeJTI(ctx context.Context, jti string, expiresAt time.Time) error { + rec := &dpopJTIRecord{JTI: jti, ExpiresAt: expiresAt} + _, err := s.db.NewInsert().Model(rec).Exec(ctx) + if err == nil { + return nil + } + if isDuplicateKeyError(err) { + return errors.New("jti replay detected") + } + return fmt.Errorf("%w: %w", ErrDPoPStorageFailure, err) +} + +// normalizeHTU strips the query and fragment from a URL per RFC 9449 §4.2, +// lowercases scheme + host per RFC 3986 §3.1 / §3.2.2 (both components are +// case-insensitive), and strips the scheme's default port per §3.2.3 +// (`http://example.com:80` and `http://example.com` are URI-equivalent). +// Without these normalisations a client signing one form and a server seeing +// the other — common when a reverse proxy rewrites either case or default +// port — would fail an otherwise-valid proof. +func normalizeHTU(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + u.Scheme = strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + port := u.Port() + if port != "" && !isDefaultPort(u.Scheme, port) { + u.Host = host + ":" + port + } else { + u.Host = host + } + u.RawQuery = "" + u.Fragment = "" + return u.String() +} + +// isDefaultPort reports whether port is the IANA default for scheme. +// Used by normalizeHTU to fold `https://a.com:443` into `https://a.com` +// (and the http/80 equivalent) before comparison. +func isDefaultPort(scheme, port string) bool { + switch { + case scheme == "https" && port == "443": + return true + case scheme == "http" && port == "80": + return true + } + return false +} + +// computeATH computes the base64url-encoded SHA-256 hash of an access token, +// as required by the ath claim of a DPoP proof (RFC 9449 §4.2). +func computeATH(accessToken []byte) string { + h := sha256.Sum256(accessToken) + return base64.RawURLEncoding.EncodeToString(h[:]) +} diff --git a/internal/service/oauth.go b/internal/service/oauth.go index a9efdd22..633474ed 100644 --- a/internal/service/oauth.go +++ b/internal/service/oauth.go @@ -67,6 +67,11 @@ var reservedClaims = map[string]bool{ "user_email": true, "user_name": true, // ZeroID internal claims "act": true, "token_exchange": true, "trusted_by": true, + // RFC 9449 — cnf.jkt is set only from a validated DPoP proof. Block + // callers from injecting it via additional_claims, which would otherwise + // let a trusted-service caller mint a token that appears DPoP-bound to + // an attacker-chosen key thumbprint. + "cnf": true, } // trustedServiceValidatorFunc checks whether the current request comes from a trusted @@ -170,6 +175,10 @@ type TokenRequest struct { TrustedService bool // CIBA (urn:openid:params:grant-type:ciba) grant fields: AuthReqID string // opaque handle returned by POST /oauth2/bc-authorize + // DPoPKeyThumbprint is the base64url JWK thumbprint of the client's DPoP key. + // Non-empty when the token endpoint received a valid DPoP proof (RFC 9449). + // The issued credential will carry cnf.jkt and token_type "DPoP" when set. + DPoPKeyThumbprint string } // Token handles the /oauth2/token endpoint dispatch. @@ -192,8 +201,9 @@ func (s *OAuthService) Token(ctx context.Context, req TokenRequest) (*domain.Acc return nil, oauthBadRequest("unsupported_grant_type", "CIBA is not enabled on this deployment") } return s.backchannelSvc.Redeem(ctx, RedeemInput{ - AuthReqID: req.AuthReqID, - ClientID: req.ClientID, + AuthReqID: req.AuthReqID, + ClientID: req.ClientID, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) default: // Check custom grant handlers registered via RegisterGrant. @@ -250,10 +260,11 @@ func (s *OAuthService) clientCredentials(ctx context.Context, req TokenRequest) } accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ - Identity: identity, - IdentityPolicyID: policy.ID, - Scopes: scopes, - GrantType: domain.GrantTypeClientCredentials, + Identity: identity, + IdentityPolicyID: policy.ID, + Scopes: scopes, + GrantType: domain.GrantTypeClientCredentials, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) if err != nil { return nil, err @@ -338,10 +349,11 @@ func (s *OAuthService) jwtBearer(ctx context.Context, req TokenRequest) (*domain scopes := intersectScopes(parseScopeString(req.Scope), effectiveAllowedScopes(policy, identity)) accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ - Identity: identity, - IdentityPolicyID: policy.ID, - Scopes: scopes, - GrantType: domain.GrantTypeJWTBearer, + Identity: identity, + IdentityPolicyID: policy.ID, + Scopes: scopes, + GrantType: domain.GrantTypeJWTBearer, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) if err != nil { return nil, err @@ -523,14 +535,15 @@ func (s *OAuthService) tokenExchange(ctx context.Context, req TokenRequest) (*do // level, allowed grant types, max TTL) is enforced inside // IssueCredential against actor.IdentityPolicyID. accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ - Identity: actorIdentity, - IdentityPolicyID: actorPolicy.ID, - Scopes: scopes, - GrantType: domain.GrantTypeTokenExchange, - DelegatedBy: delegatedBy, - ParentJTI: subjectJTI, - DelegationDepth: parentDepth + 1, - MissionID: missionID, + Identity: actorIdentity, + IdentityPolicyID: actorPolicy.ID, + Scopes: scopes, + GrantType: domain.GrantTypeTokenExchange, + DelegatedBy: delegatedBy, + ParentJTI: subjectJTI, + DelegationDepth: parentDepth + 1, + MissionID: missionID, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) if err != nil { return nil, err @@ -634,17 +647,18 @@ func (s *OAuthService) ExternalPrincipalExchange(ctx context.Context, req TokenR // them from ES256 NHI tokens in downstream verification. scopes := parseScopeString(req.Scope) accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ - Identity: identity, - IdentityPolicyID: identityPolicyID, - GrantType: domain.GrantTypeTokenExchange, - Scopes: scopes, - UseRS256: true, - SubjectOverride: req.UserID, - UserEmail: req.UserEmail, - UserName: req.UserName, - ApplicationID: req.ApplicationID, - TTL: 900, // 15 minutes — short-lived for external principals - CustomClaims: customClaims, + Identity: identity, + IdentityPolicyID: identityPolicyID, + GrantType: domain.GrantTypeTokenExchange, + Scopes: scopes, + UseRS256: true, + SubjectOverride: req.UserID, + UserEmail: req.UserEmail, + UserName: req.UserName, + ApplicationID: req.ApplicationID, + TTL: 900, // 15 minutes — short-lived for external principals + CustomClaims: customClaims, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) if err != nil { return nil, oauthServerError("failed to issue external principal token", err) @@ -764,6 +778,7 @@ func (s *OAuthService) apiKeyGrant(ctx context.Context, req TokenRequest) (*doma // Clamp the JWT exp by the API key's own expires_at — a 7-day key // must never mint a 30-day token even if the identity policy allows. CredentialExpiresAt: sk.ExpiresAt, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) if err != nil { return nil, err @@ -911,14 +926,15 @@ func (s *OAuthService) authorizationCode(ctx context.Context, req TokenRequest) } accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ - Identity: identity, - IdentityPolicyID: identityPolicyID, - GrantType: domain.GrantTypeAuthorizationCode, - UseRS256: true, - SubjectOverride: authCode.UserID, - ApplicationID: authCode.ClientID, - TTL: ttl, - Scopes: authCode.Scopes, + Identity: identity, + IdentityPolicyID: identityPolicyID, + GrantType: domain.GrantTypeAuthorizationCode, + UseRS256: true, + SubjectOverride: authCode.UserID, + ApplicationID: authCode.ClientID, + TTL: ttl, + Scopes: authCode.Scopes, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) if err != nil { return nil, err @@ -933,13 +949,14 @@ func (s *OAuthService) authorizationCode(ctx context.Context, req TokenRequest) // Issue refresh token when the client is registered for the refresh_token grant. if hasRefreshGrant && s.refreshTokenSvc != nil { rtResult, rtErr := s.refreshTokenSvc.IssueRefreshToken(ctx, &RefreshTokenParams{ - ClientID: req.ClientID, - AccountID: authCode.AccountID, - ProjectID: authCode.ProjectID, - UserID: authCode.UserID, - IdentityID: oauthClient.IdentityID, - Scopes: strings.Join(authCode.Scopes, " "), - TTL: oauthClient.RefreshTokenTTL, + ClientID: req.ClientID, + AccountID: authCode.AccountID, + ProjectID: authCode.ProjectID, + UserID: authCode.UserID, + IdentityID: oauthClient.IdentityID, + Scopes: strings.Join(authCode.Scopes, " "), + TTL: oauthClient.RefreshTokenTTL, + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) if rtErr != nil { log.Error().Err(rtErr).Msg("Failed to issue refresh token — returning access token only") @@ -1015,8 +1032,15 @@ func (s *OAuthService) refreshToken(ctx context.Context, req TokenRequest) (*dom accessTTL = defaultAccessTokenTTLWithRefresh } - oldToken, newRT, err := s.refreshTokenSvc.RotateRefreshToken(ctx, req.RefreshTokenStr, refreshTokenTTL) + oldToken, newRT, err := s.refreshTokenSvc.RotateRefreshToken(ctx, req.RefreshTokenStr, refreshTokenTTL, req.DPoPKeyThumbprint) if err != nil { + // A DPoP binding mismatch is a proof failure, not an invalid refresh + // token. The token row is untouched (the rotation transaction rolled + // back) so the legitimate caller's next request still works. RFC 9449 + // §5 carriage: the AS rejects the request without revoking the token. + if errors.Is(err, ErrDPoPBindingMismatch) { + return nil, oauthBadRequest("invalid_dpop_proof", "refresh token is DPoP-bound; the presented proof does not match the original key") + } return nil, oauthBadRequestCause("invalid_grant", "invalid or expired refresh token", err) } @@ -1060,14 +1084,15 @@ func (s *OAuthService) refreshToken(ctx context.Context, req TokenRequest) (*dom // scopes), breaking the contract that refresh preserves the original // grant's authority. accessToken, _, err := s.credentialSvc.IssueCredential(ctx, IssueRequest{ - Identity: identity, - IdentityPolicyID: identityPolicyID, - GrantType: domain.GrantTypeRefreshToken, - UseRS256: true, - SubjectOverride: oldToken.UserID, - ApplicationID: oldToken.ClientID, - TTL: accessTTL, - Scopes: parseScopeString(oldToken.Scopes), + Identity: identity, + IdentityPolicyID: identityPolicyID, + GrantType: domain.GrantTypeRefreshToken, + UseRS256: true, + SubjectOverride: oldToken.UserID, + ApplicationID: oldToken.ClientID, + TTL: accessTTL, + Scopes: parseScopeString(oldToken.Scopes), + DPoPKeyThumbprint: req.DPoPKeyThumbprint, }) if err != nil { return nil, err @@ -1147,8 +1172,10 @@ func (s *OAuthService) Introspect(ctx context.Context, tokenStr string) (map[str } // Custom claims via the v4 generic accessor. jwt.Get[any] survives both - // string and structured shapes (e.g. act is a nested object). - for _, claim := range []string{"agent_id", "trust_level", "identity_type", "external_id", "delegation_depth", "act"} { + // string and structured shapes (e.g. act and cnf are nested objects). + // cnf is surfaced so resource servers see the RFC 9449 jkt binding and + // can validate the caller's DPoP proof against the expected thumbprint. + for _, claim := range []string{"agent_id", "trust_level", "identity_type", "external_id", "delegation_depth", "act", "cnf"} { if v, err := jwt.Get[any](parsed, claim); err == nil { result[claim] = v } diff --git a/internal/service/oauth_client.go b/internal/service/oauth_client.go index 43abc473..ba30aba0 100644 --- a/internal/service/oauth_client.go +++ b/internal/service/oauth_client.go @@ -3,6 +3,7 @@ package service import ( "context" "crypto/rand" + "database/sql" "encoding/hex" "encoding/json" "errors" @@ -204,6 +205,7 @@ func (s *OAuthClientService) RegisterClient(ctx context.Context, req RegisterCli IdentityID: identityID, ClientNotificationEndpoint: req.ClientNotificationEndpoint, BackchannelTokenDeliveryMode: deliveryMode, + RegistrationSource: "internal", IsActive: true, CreatedAt: now, UpdatedAt: now, @@ -312,6 +314,273 @@ func (s *OAuthClientService) DeleteClient(ctx context.Context, id string) error return s.repo.Delete(ctx, id) } +// ── RFC 7591 / RFC 7592 Dynamic Client Registration ────────────────────────── + +// dcrBcryptCost is the work factor used for hashing the registration_access_token. +// Stricter than the default to reflect the higher trust placed in a long-lived +// management bearer that survives a single registration call. +const dcrBcryptCost = 12 + +// dcrAllowedAuthMethods are the only token_endpoint_auth_method values +// dynamically-registered clients may declare. Enforced at the service layer +// as defense-in-depth so a direct call to DynamicRegisterClient or +// UpdateDynamicClient (bypassing the handler) cannot smuggle in "none" or +// an unsupported value. +var dcrAllowedAuthMethods = map[string]bool{ + "client_secret_post": true, + "client_secret_basic": true, +} + +// dcrAllowedGrantTypes mirrors the handler-layer allow-list. Service-layer +// enforcement guards against direct service-method callers. +var dcrAllowedGrantTypes = map[string]bool{ + "client_credentials": true, + "urn:ietf:params:oauth:grant-type:jwt-bearer": true, +} + +// validateDCRSubmittedFields runs the service-layer defense for grant_types +// and token_endpoint_auth_method. Returns the normalised auth method (with +// the default applied for empty input) or an error. +func validateDCRSubmittedFields(grantTypes []string, authMethod string) (string, error) { + for _, gt := range grantTypes { + if !dcrAllowedGrantTypes[gt] { + return "", fmt.Errorf("grant_type %q is not permitted for dynamically-registered clients", gt) + } + } + if authMethod == "" { + // RFC 7591 §2 default. Older drafts and some examples used + // client_secret_post; we conform to the published spec for + // interoperability with standards-compliant clients that omit + // the field expecting the spec default. + return "client_secret_basic", nil + } + if !dcrAllowedAuthMethods[authMethod] { + return "", fmt.Errorf("token_endpoint_auth_method %q is not permitted for dynamically-registered clients", authMethod) + } + return authMethod, nil +} + +// DynamicRegisterClientRequest is the input shape for RFC 7591 dynamic registration. +// Mirrors RegisterClientRequest's confidential-client subset — DCR-issued clients +// are always confidential (they get a client_secret and an opaque registration token). +type DynamicRegisterClientRequest struct { + Name string + GrantTypes []string + Scopes []string + RedirectURIs []string + TokenEndpointAuthMethod string + SoftwareID string + SoftwareVersion string + Contacts []string + Metadata json.RawMessage +} + +// DynamicRegisterClient creates an OAuth2 client via RFC 7591 dynamic registration. +// Returns the created client, the plain-text client_secret, and the plain-text +// registration_access_token. Both are shown once and never stored in plain form; +// callers MUST return them to the registrant on the registration response and +// then drop the values from memory. +func (s *OAuthClientService) DynamicRegisterClient(ctx context.Context, req DynamicRegisterClientRequest) (*domain.OAuthClient, string, string, error) { + if req.Name == "" { + return nil, "", "", fmt.Errorf("name is required") + } + + clientID, err := generateSecureToken(16) + if err != nil { + return nil, "", "", fmt.Errorf("failed to generate client_id: %w", err) + } + + plainSecret, err := generateSecureToken(32) + if err != nil { + return nil, "", "", fmt.Errorf("failed to generate client_secret: %w", err) + } + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), dcrBcryptCost) + if err != nil { + return nil, "", "", fmt.Errorf("failed to hash client secret: %w", err) + } + + plainRegToken, err := generateSecureToken(32) + if err != nil { + return nil, "", "", fmt.Errorf("failed to generate registration_access_token: %w", err) + } + hashedRegToken, err := bcrypt.GenerateFromPassword([]byte(plainRegToken), dcrBcryptCost) + if err != nil { + return nil, "", "", fmt.Errorf("failed to hash registration token: %w", err) + } + + grantTypes := req.GrantTypes + if len(grantTypes) == 0 { + grantTypes = []string{"client_credentials"} + } + authMethod, err := validateDCRSubmittedFields(grantTypes, req.TokenEndpointAuthMethod) + if err != nil { + return nil, "", "", err + } + scopes := req.Scopes + if scopes == nil { + scopes = []string{} + } + redirectURIs := req.RedirectURIs + if redirectURIs == nil { + redirectURIs = []string{} + } + contacts := req.Contacts + if contacts == nil { + contacts = []string{} + } + + now := time.Now() + client := &domain.OAuthClient{ + ID: uuid.New().String(), + ClientID: clientID, + ClientSecret: string(hashedSecret), + Name: req.Name, + ClientType: "confidential", + TokenEndpointAuthMethod: authMethod, + GrantTypes: grantTypes, + RedirectURIs: redirectURIs, + Scopes: scopes, + SoftwareID: req.SoftwareID, + SoftwareVersion: req.SoftwareVersion, + Contacts: contacts, + Metadata: req.Metadata, + // backchannel_token_delivery_mode has a NOT NULL DEFAULT 'poll' + a CHECK + // constraint (migration 021). Bun inserts the Go zero value rather than + // letting the DB default fire, so DCR clients must set this explicitly or + // the INSERT fails the CHECK with SQLSTATE 23514 (a 500 at the handler). + // DCR clients don't use CIBA, so 'poll' is the safe baseline. + BackchannelTokenDeliveryMode: string(domain.BackchannelNotificationPoll), + RegistrationSource: "dynamic", + RegistrationAccessToken: string(hashedRegToken), + IsActive: true, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.repo.Create(ctx, client); err != nil { + if isDuplicateKeyError(err) { + return nil, "", "", ErrOAuthClientAlreadyExists + } + return nil, "", "", fmt.Errorf("failed to register oauth client: %w", err) + } + + log.Info(). + Str("client_id", clientID). + Msg("OAuth2 client registered via RFC 7591 dynamic registration") + + return client, plainSecret, plainRegToken, nil +} + +// dummyRegistrationTokenHash is a pre-computed bcrypt hash used for constant-time +// comparison when a client_id is not found, preventing timing-based client_id enumeration. +// The plaintext is irrelevant — no real token will ever match this. +var dummyRegistrationTokenHash []byte + +func init() { + h, err := bcrypt.GenerateFromPassword([]byte("dummy-timing-equaliser"), dcrBcryptCost) + if err != nil { + panic("failed to generate dummy bcrypt hash for timing equalisation: " + err.Error()) + } + dummyRegistrationTokenHash = h +} + +// VerifyRegistrationToken looks up a dynamically registered client by client_id +// and verifies the provided registration_access_token against the stored bcrypt hash. +// Used to authenticate RFC 7592 management requests (GET/PUT/DELETE /oauth2/register/{client_id}). +// +// Always runs a bcrypt comparison regardless of whether the client_id exists, so that +// response time does not leak client_id existence to an attacker. +// +// Errors are distinguished: +// - ErrOAuthClientNotFound: row absent or row is not a dynamic client or hash mismatch. +// Callers map this to 401 invalid_token. +// - any other error: a DB or infrastructure failure. Callers must map this to 5xx, +// not 401, so an outage is not masquerading as an auth failure. +func (s *OAuthClientService) VerifyRegistrationToken(ctx context.Context, clientID, regToken string) (*domain.OAuthClient, error) { + client, err := s.repo.GetByClientID(ctx, clientID) + if err != nil { + // sql.ErrNoRows is the genuine not-found; equalise timing and return 401. + // Anything else is a DB/infra failure that the handler must propagate as 500. + if errors.Is(err, sql.ErrNoRows) { + _ = bcrypt.CompareHashAndPassword(dummyRegistrationTokenHash, []byte(regToken)) + return nil, ErrOAuthClientNotFound + } + return nil, fmt.Errorf("verify registration token: %w", err) + } + if client.RegistrationSource != "dynamic" { + _ = bcrypt.CompareHashAndPassword(dummyRegistrationTokenHash, []byte(regToken)) + return nil, ErrOAuthClientNotFound + } + if err := bcrypt.CompareHashAndPassword([]byte(client.RegistrationAccessToken), []byte(regToken)); err != nil { + return nil, ErrOAuthClientNotFound + } + return client, nil +} + +// UpdateDynamicClient replaces the mutable metadata of a dynamically registered client +// per RFC 7592 §3 (full replacement, not partial update). +// The client_id and secrets are immutable after registration. +func (s *OAuthClientService) UpdateDynamicClient(ctx context.Context, clientID string, req DynamicRegisterClientRequest) (*domain.OAuthClient, error) { + client, err := s.repo.GetByClientID(ctx, clientID) + if err != nil { + // Distinguish genuine not-found from infra failure so the handler + // can return 401 vs 5xx appropriately. Same pattern as + // VerifyRegistrationToken — a DB outage must not look like "no + // such client". + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrOAuthClientNotFound + } + return nil, fmt.Errorf("update dynamic client: lookup failed: %w", err) + } + if client.RegistrationSource != "dynamic" { + return nil, ErrOAuthClientNotFound + } + // RFC 7592 §3: PUT is a full replacement. The handler applies RFC 7591 defaults + // for any omitted fields before calling here, so all fields are unconditionally replaced. + grantTypes := req.GrantTypes + if grantTypes == nil { + grantTypes = []string{"client_credentials"} + } + authMethod, err := validateDCRSubmittedFields(grantTypes, req.TokenEndpointAuthMethod) + if err != nil { + return nil, err + } + scopes := req.Scopes + if scopes == nil { + scopes = []string{} + } + redirectURIs := req.RedirectURIs + if redirectURIs == nil { + redirectURIs = []string{} + } + contacts := req.Contacts + if contacts == nil { + contacts = []string{} + } + + client.Name = req.Name + client.GrantTypes = grantTypes + client.Scopes = scopes + client.RedirectURIs = redirectURIs + client.TokenEndpointAuthMethod = authMethod + client.SoftwareID = req.SoftwareID + client.SoftwareVersion = req.SoftwareVersion + client.Contacts = contacts + client.Metadata = req.Metadata + client.UpdatedAt = time.Now() + if err := s.repo.Update(ctx, client); err != nil { + return nil, fmt.Errorf("failed to update dynamic client: %w", err) + } + return client, nil +} + +// DeleteDynamicClient removes a dynamically registered client by its client_id. +// The registration_source = 'dynamic' guard is enforced at the repo layer too, +// so this method cannot accidentally remove an internal client. +func (s *OAuthClientService) DeleteDynamicClient(ctx context.Context, clientID string) error { + return s.repo.DeleteByClientID(ctx, clientID) +} + // generateSecureToken creates a cryptographically random hex-encoded token. func generateSecureToken(byteLen int) (string, error) { b := make([]byte, byteLen) diff --git a/internal/service/refresh_token.go b/internal/service/refresh_token.go index 07db6d83..090155da 100644 --- a/internal/service/refresh_token.go +++ b/internal/service/refresh_token.go @@ -42,8 +42,19 @@ type RefreshTokenParams struct { IdentityID *string Scopes string TTL int // seconds, 0 = use default (90 days) + // DPoPKeyThumbprint binds the refresh token to a DPoP key (RFC 9449 §5). + // Set non-empty when the issuing /oauth2/token call carried a valid DPoP + // proof; every later rotation must present a proof signed by the same + // key. Empty ⇒ unbound (Bearer). + DPoPKeyThumbprint string } +// ErrDPoPBindingMismatch is returned when a refresh-token rotation presents a +// DPoP proof whose key thumbprint differs from the one persisted with the +// token. Callers MUST map this to invalid_dpop_proof / 4xx — it is the proof +// that failed, not the refresh token. The token itself is NOT consumed. +var ErrDPoPBindingMismatch = errors.New("refresh token's DPoP binding does not match the presented proof") + // RefreshTokenResult contains both the raw token (returned to client) and stored metadata. type RefreshTokenResult struct { RawToken string // Returned to client once — never stored. @@ -63,16 +74,17 @@ func (s *RefreshTokenService) IssueRefreshToken(ctx context.Context, params *Ref expiresAt := time.Now().Add(refreshTokenTTL(params.TTL)) record := &domain.RefreshToken{ - TokenHash: tokenHash, - ClientID: params.ClientID, - AccountID: params.AccountID, - ProjectID: params.ProjectID, - UserID: params.UserID, - IdentityID: params.IdentityID, - Scopes: params.Scopes, - FamilyID: familyID, - State: domain.RefreshTokenStateActive, - ExpiresAt: expiresAt, + TokenHash: tokenHash, + ClientID: params.ClientID, + AccountID: params.AccountID, + ProjectID: params.ProjectID, + UserID: params.UserID, + IdentityID: params.IdentityID, + Scopes: params.Scopes, + FamilyID: familyID, + State: domain.RefreshTokenStateActive, + ExpiresAt: expiresAt, + DPoPKeyThumbprint: params.DPoPKeyThumbprint, } if err := s.repo.Create(ctx, s.db, record); err != nil { @@ -100,7 +112,7 @@ func (s *RefreshTokenService) IssueRefreshToken(ctx context.Context, params *Ref // leave the original token revoked with no successor, and the client's // retry would trip reuse detection and nuke the whole family — turning a // transient glitch into a forced re-auth across all sessions. -func (s *RefreshTokenService) RotateRefreshToken(ctx context.Context, rawToken string, ttl int) (*domain.RefreshToken, *RefreshTokenResult, error) { +func (s *RefreshTokenService) RotateRefreshToken(ctx context.Context, rawToken string, ttl int, presentedDPoPThumbprint string) (*domain.RefreshToken, *RefreshTokenResult, error) { tokenHash := hashRefreshToken(rawToken) newRawToken, err := generateRefreshToken() @@ -116,6 +128,14 @@ func (s *RefreshTokenService) RotateRefreshToken(ctx context.Context, rawToken s if err != nil { return err } + // DPoP binding check (RFC 9449 §5). A refresh token issued under + // DPoP must rotate only when the presented proof carries the same + // public key. Mismatch rolls back the transaction — the original + // row stays active, so a failed-proof attempt does NOT consume + // the token (no DoS via spamming bad proofs). + if c.DPoPKeyThumbprint != "" && c.DPoPKeyThumbprint != presentedDPoPThumbprint { + return ErrDPoPBindingMismatch + } claimed = c successor := &domain.RefreshToken{ @@ -129,10 +149,21 @@ func (s *RefreshTokenService) RotateRefreshToken(ctx context.Context, rawToken s FamilyID: c.FamilyID, // Same family — rotation chain. State: domain.RefreshTokenStateActive, ExpiresAt: expiresAt, + // Bound tokens stay bound; UNBOUND tokens stay unbound, even if + // the new rotation request carried a DPoP proof. Retroactive + // binding-on-first-proof is a deliberate non-decision today — + // the consequence is that a stolen unbound refresh can be + // rotated with any DPoP key, but binding requires explicit + // opt-in at original issuance. See docs/dpop-and-dcr.md + // "Refresh-token binding" for the full rationale. + DPoPKeyThumbprint: c.DPoPKeyThumbprint, } return s.repo.Create(ctx, tx, successor) }) if txErr != nil { + if errors.Is(txErr, ErrDPoPBindingMismatch) { + return nil, nil, ErrDPoPBindingMismatch + } if errors.Is(txErr, sql.ErrNoRows) { return nil, nil, s.handleFailedClaim(ctx, tokenHash) } diff --git a/internal/store/postgres/oauth_client.go b/internal/store/postgres/oauth_client.go index dda5d6c3..870c0037 100644 --- a/internal/store/postgres/oauth_client.go +++ b/internal/store/postgres/oauth_client.go @@ -98,3 +98,21 @@ func (r *OAuthClientRepository) Delete(ctx context.Context, id string) error { } return nil } + +// DeleteByClientID removes a dynamically registered client by its OAuth2 client_id. +// Used by RFC 7592 DELETE /oauth2/register/{client_id} where auth is the +// registration_access_token, not an admin UUID. +// The registration_source = 'dynamic' guard is defense-in-depth — the service layer +// also checks this before calling, but the repo must never delete internal clients +// regardless of how it is called. +func (r *OAuthClientRepository) DeleteByClientID(ctx context.Context, clientID string) error { + _, err := r.db.NewDelete(). + TableExpr("oauth_clients"). + Where("client_id = ?", clientID). + Where("registration_source = 'dynamic'"). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to delete oauth client: %w", err) + } + return nil +} diff --git a/internal/worker/cleanup.go b/internal/worker/cleanup.go index 7b3a35b3..b813271a 100644 --- a/internal/worker/cleanup.go +++ b/internal/worker/cleanup.go @@ -106,6 +106,19 @@ func (w *CleanupWorker) RunOnce(ctx context.Context) { log.Info().Int64("count", n).Msg("Cleanup: deleted expired auth codes") } + // DPoP JTIs are only needed within the freshness window (RFC 9449 §4.2). + // Purge expired rows to prevent unbounded table growth under high + // token-request volume. + dpopRes, err := w.db.NewDelete(). + TableExpr("dpop_jti"). + Where("expires_at < ?", now). + Exec(ctx) + if err != nil { + log.Error().Err(err).Msg("Cleanup: failed to delete expired dpop jti records") + } else if n, err := dpopRes.RowsAffected(); err == nil && n > 0 { + log.Info().Int64("count", n).Msg("Cleanup: deleted expired dpop jti records") + } + // CIBA backchannel requests: // 1. Flip pending → expired so an in-flight poll sees expired_token. // 2. Reap rows in a resolved terminal state past expires_at. diff --git a/migrations/024_dynamic_client_registration.down.sql b/migrations/024_dynamic_client_registration.down.sql new file mode 100644 index 00000000..c606ebb3 --- /dev/null +++ b/migrations/024_dynamic_client_registration.down.sql @@ -0,0 +1,13 @@ +-- IRREVERSIBLE FOR DCR-REGISTERED CLIENTS: dropping registration_access_token +-- destroys the bcrypt-hashed management bearers. After this down + a future +-- re-apply of the up, RFC 7592 GET/PUT/DELETE against any pre-existing +-- dynamic client will fail because the stored hash is gone. Dropping +-- registration_source also erases the trust boundary between admin- and +-- self-registered clients (all look 'internal' again). Treat this down +-- migration as emergency-only. + +SET LOCAL lock_timeout = '3s'; + +ALTER TABLE oauth_clients + DROP COLUMN IF EXISTS registration_access_token, + DROP COLUMN IF EXISTS registration_source; diff --git a/migrations/024_dynamic_client_registration.up.sql b/migrations/024_dynamic_client_registration.up.sql new file mode 100644 index 00000000..e770fdaf --- /dev/null +++ b/migrations/024_dynamic_client_registration.up.sql @@ -0,0 +1,24 @@ +-- 024_dynamic_client_registration.up.sql +-- Adds dynamic client registration support per RFC 7591/7592. +-- +-- registration_source: 'internal' for clients registered via the admin/internal +-- API path, 'dynamic' for clients registered via POST /oauth2/register (RFC 7591). +-- +-- registration_access_token: bcrypt hash of the management bearer token returned +-- at RFC 7591 registration time. NULL for internal clients. Used by RFC 7592 +-- GET/PUT/DELETE /oauth2/register/{client_id} to authenticate the registrant. +-- +-- token_endpoint_auth_method already exists from migration 003 (default 'none'); +-- DCR-registered clients persist their declared method on registration. +-- +-- Lock posture: PG 11+ fast-path for ADD COLUMN with a constant scalar default +-- (no full table rewrite, metadata-only catalog update). AccessExclusive lock +-- held for sub-millisecond duration regardless of row count. lock_timeout +-- below makes the migration fail fast if the lock can't be acquired quickly, +-- so a startup auto-apply doesn't wedge behind a long-running token query. + +SET LOCAL lock_timeout = '3s'; + +ALTER TABLE oauth_clients + ADD COLUMN IF NOT EXISTS registration_source VARCHAR(50) NOT NULL DEFAULT 'internal', + ADD COLUMN IF NOT EXISTS registration_access_token VARCHAR(255); diff --git a/migrations/025_dpop.down.sql b/migrations/025_dpop.down.sql new file mode 100644 index 00000000..3086add8 --- /dev/null +++ b/migrations/025_dpop.down.sql @@ -0,0 +1,12 @@ +-- IRREVERSIBLE FOR LIVE DPoP-BOUND TOKENS: dropping dpop_key_thumbprint +-- erases the cnf.jkt binding metadata for every active DPoP-bound credential. +-- After this down runs, resource servers introspecting those tokens no +-- longer see a cnf claim and will refuse the DPoP-bound presentation. +-- Treat as emergency-only. + +SET LOCAL lock_timeout = '3s'; + +ALTER TABLE issued_credentials DROP COLUMN IF EXISTS dpop_key_thumbprint; + +DROP INDEX IF EXISTS idx_dpop_jti_expires_at; +DROP TABLE IF EXISTS dpop_jti; diff --git a/migrations/025_dpop.up.sql b/migrations/025_dpop.up.sql new file mode 100644 index 00000000..3e209754 --- /dev/null +++ b/migrations/025_dpop.up.sql @@ -0,0 +1,33 @@ +-- 025_dpop.up.sql +-- DPoP — Demonstrating Proof of Possession (RFC 9449). +-- +-- dpop_jti: proof JTI replay-prevention store. +-- INSERT fails on duplicate primary key → replay detected without a pre-check query. +-- expires_at drives cleanup; rows outside the freshness window are purged by the +-- cleanup worker since a proof that old would fail the iat check before JTI lookup. +-- +-- Storage parameters: lowered autovacuum_vacuum_scale_factor (default 0.2) to +-- keep dead tuples reaped at 5% rather than waiting for 20% bloat — under +-- DPoP-heavy load this table sees a high INSERT-then-DELETE churn rate with +-- no UPDATEs, which is exactly the workload where 0.05 helps the most. +-- fillfactor=90 leaves some page headroom for the rare row-version update. +-- +-- issued_credentials.dpop_key_thumbprint: base64url JWK thumbprint (RFC 7638 SHA-256) +-- of the DPoP key bound to this credential (RFC 9449 §6.1). NULL for Bearer tokens. +-- +-- Lock posture: ALTER TABLE on issued_credentials is metadata-only on PG 11+ +-- (nullable column, no default, no rewrite). CREATE TABLE / CREATE INDEX are +-- on a brand-new empty table so no concurrent-rebuild concern. lock_timeout +-- below scopes any blocking-acquire to a safe failure path. + +SET LOCAL lock_timeout = '3s'; + +CREATE TABLE IF NOT EXISTS dpop_jti ( + jti VARCHAR(512) PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL +) WITH (fillfactor = 90, autovacuum_vacuum_scale_factor = 0.05); + +CREATE INDEX IF NOT EXISTS idx_dpop_jti_expires_at ON dpop_jti (expires_at); + +ALTER TABLE issued_credentials + ADD COLUMN IF NOT EXISTS dpop_key_thumbprint TEXT; diff --git a/migrations/026_refresh_token_dpop_binding.down.sql b/migrations/026_refresh_token_dpop_binding.down.sql new file mode 100644 index 00000000..b175c12d --- /dev/null +++ b/migrations/026_refresh_token_dpop_binding.down.sql @@ -0,0 +1,8 @@ +-- IRREVERSIBLE FOR BOUND REFRESH TOKENS: dropping dpop_key_thumbprint erases the +-- binding for every active refresh token issued under DPoP. After down + re-apply, +-- a previously-bound refresh token will rotate as if it had never been bound. +-- Treat as emergency-only. + +SET LOCAL lock_timeout = '3s'; + +ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS dpop_key_thumbprint; diff --git a/migrations/026_refresh_token_dpop_binding.up.sql b/migrations/026_refresh_token_dpop_binding.up.sql new file mode 100644 index 00000000..bd914744 --- /dev/null +++ b/migrations/026_refresh_token_dpop_binding.up.sql @@ -0,0 +1,22 @@ +-- 026_refresh_token_dpop_binding.up.sql +-- Refresh-token DPoP binding (RFC 9449 §5). +-- +-- A refresh token issued in conjunction with a DPoP-bound access token must +-- itself be bound to the same public key — and every subsequent refresh +-- request must present a proof signed by the same key. Without this, an +-- attacker who steals a refresh token (the persistent half of a long-running +-- session) could redeem it with a different key and unbox an unbound access +-- token, undoing the entire DPoP guarantee for the chain. +-- +-- The column is populated from the request-time DPoP proof when the original +-- /oauth2/token call that minted the refresh was DPoP-bound, copied to the +-- successor row on every rotation, and validated against the presented proof +-- inside RotateRefreshToken's transaction. NULL ⇒ unbound (Bearer) — rotation +-- in that case accepts any proof or none. +-- +-- Lock posture: metadata-only ADD COLUMN on PG 11+ (nullable, no default). + +SET LOCAL lock_timeout = '3s'; + +ALTER TABLE refresh_tokens + ADD COLUMN IF NOT EXISTS dpop_key_thumbprint TEXT; diff --git a/server.go b/server.go index 1b8b89af..819f0297 100644 --- a/server.go +++ b/server.go @@ -223,11 +223,15 @@ func NewServer(cfg Config) (*Server, error) { backchannelSvc := service.NewBackchannelService(backchannelRepo, oauthClientSvc, credentialSvc, backchannelCfg) oauthSvc.SetBackchannelService(backchannelSvc) + // DPoP service — validates RFC 9449 proofs and enforces JTI replay protection + // via the dpop_jti table. Stateless beyond the DB it reads/writes. + dpopSvc := service.NewDPoPService(db) + // Create shared API handler. apiHandler := handler.NewAPI( identitySvc, credentialSvc, credentialPolicySvc, attestationSvc, attestationPolicySvc, proofSvc, oauthSvc, oauthClientSvc, - signalSvc, apiKeySvc, agentSvc, auditSvc, backchannelSvc, jwksSvc, + signalSvc, apiKeySvc, agentSvc, auditSvc, backchannelSvc, dpopSvc, jwksSvc, signingCredSvc, db, cfg.Token.Issuer, cfg.Token.BaseURL, ) @@ -271,6 +275,10 @@ func NewServer(cfg Config) (*Server, error) { // Public routes — no auth. // /health, /ready, /.well-known/*, /oauth2/token, /oauth2/token/introspect, /oauth2/token/revoke, /oauth2/token/verify + // RequestURLMiddleware records the request's effective URL on context.Context + // so DPoP htu validation (RFC 9449 §4.3) compares against what the client + // actually hit, not against the static config value. + r.Use(internalMiddleware.RequestURLMiddleware(cfg.Server.TrustForwardedHeaders)) humaPublic := handler.NewHumaAPI(r) apiHandler.RegisterPublic(humaPublic, r) diff --git a/tests/integration/COMPLIANCE.md b/tests/integration/COMPLIANCE.md new file mode 100644 index 00000000..89da5abb --- /dev/null +++ b/tests/integration/COMPLIANCE.md @@ -0,0 +1,33 @@ +# RFC compliance test pattern + +ZeroID implements a handful of OAuth / OIDC RFCs and adds others as new features land. Each RFC gets a dedicated `_compliance_test.go` file in this directory whose role is **explicit conformance** to the spec's normative clauses — separate from the happy-path / feature-behaviour tests that live in `_test.go`. + +## Conventions + +1. **One file per RFC.** Name: `_compliance_test.go` (e.g. `dpop_compliance_test.go`, `dcr_compliance_test.go`). + +2. **Test name carries the citation.** `TestRFC_S
_` (e.g. `TestRFC9449_S4_2_TypHeaderMustBeDpopJwt`). The number is the RFC, the section is dotted-then-underscored, and the assertion is a short imperative. + +3. **One MUST per test.** Each test asserts exactly one normative clause (`MUST`, `MUST NOT`, `SHALL`, `REQUIRED`). Don't conflate two MUSTs into one test, even when they share setup. + +4. **Comment cites the paragraph.** The first non-blank line of every test body is a comment that quotes or paraphrases the RFC clause being asserted, in the form: + ```go + // RFC 9449 §4.2: "There is not more than one signature in the JWS Compact Serialization." + ``` + +5. **Negative-space coverage.** Compliance suites are mostly negative-path: "if the client violates X, the server MUST reject with Y." Happy paths are presumed proven by the feature tests. + +6. **No happy-path duplication.** If a clause is already exercised by a feature test (e.g. `TestDPoPClientCredentialsFlow` proves `cnf.jkt` is set), the compliance suite need only assert any negative-space invariant the feature test doesn't already cover. + +7. **Group by section.** Tests within a file appear in RFC order (§3 before §4 before §5). + +## When to add a compliance file + +Add one when introducing a feature that implements an RFC the project advertises as supported. The bar is "we tell users we conform to this RFC" — if the feature touches a spec, it gets a compliance suite. The first three landed alongside the feature itself: + +- [`dpop_compliance_test.go`](./dpop_compliance_test.go) — RFC 9449 +- [`dcr_compliance_test.go`](./dcr_compliance_test.go) — RFC 7591 / RFC 7592 + +## Maintenance + +When the RFC is revised (e.g. an erratum, a successor RFC), search by RFC number to find every assertion and revisit. The `RFC9449` / `RFC7591` prefix is deliberately greppable. diff --git a/tests/integration/dcr_compliance_test.go b/tests/integration/dcr_compliance_test.go new file mode 100644 index 00000000..847e2ca6 --- /dev/null +++ b/tests/integration/dcr_compliance_test.go @@ -0,0 +1,301 @@ +// RFC 7591 (Dynamic Client Registration) and RFC 7592 (Client Configuration +// Endpoint) compliance suite. +// +// See COMPLIANCE.md for the conventions this file follows: one MUST per test, +// test name carries the RFC + section citation, first comment quotes the +// clause, and the file groups tests in RFC order. +// +// Happy-path coverage (full register → GET → PUT → DELETE lifecycle) lives in +// dynamic_registration_test.go. This file is the negative-space proof that +// each normative clause is enforced. + +package integration_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dcrRegisterWithIAT performs a successful registration so the management- +// endpoint compliance tests have a real client + registration_access_token to +// exercise. Reuses the IAT-minting helper from dynamic_registration_test.go. +func dcrRegisterWithIAT(t *testing.T, clientName string) (clientID, regToken string) { + t.Helper() + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": clientName, + "grant_types": []string{"client_credentials"}, + "scope": "data:read", + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + body := decode(t, resp) + clientID, _ = body["client_id"].(string) + regToken, _ = body["registration_access_token"].(string) + require.NotEmpty(t, clientID) + require.NotEmpty(t, regToken) + return +} + +// ── RFC 7591 §2 — Client metadata ──────────────────────────────────────────── + +func TestRFC7591_S2_DefaultAuthMethodIsClientSecretBasic(t *testing.T) { + // RFC 7591 §2: "token_endpoint_auth_method ... If unspecified or omitted, + // the default is client_secret_basic, denoting the HTTP Basic + // authentication scheme as specified in Section 2.3.1 of OAuth 2.0." + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-default-auth-method", + "grant_types": []string{"client_credentials"}, + "scope": "data:read", + // token_endpoint_auth_method deliberately omitted + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + body := decode(t, resp) + assert.Equal(t, "client_secret_basic", body["token_endpoint_auth_method"], + "omitted token_endpoint_auth_method must default to client_secret_basic per RFC 7591 §2") +} + +func TestRFC7591_S2_TokenEndpointAuthMethodNoneRejected(t *testing.T) { + // Not strictly an RFC MUST — RFC 7591 §2 enumerates "none" as a valid + // value. ZeroID specialises: machine-to-machine deployments require + // client authentication, so "none" is explicitly rejected with + // invalid_client_metadata. The test pins this deployment policy. + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-none-auth-method", + "grant_types": []string{"client_credentials"}, + "token_endpoint_auth_method": "none", + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body := decode(t, resp) + assert.Equal(t, "invalid_client_metadata", body["error"]) +} + +func TestRFC7591_S2_GrantTypesValidatedAgainstAllowList(t *testing.T) { + // RFC 7591 §2: grant_types is OPTIONAL; servers MAY reject unsupported + // values. ZeroID's DCR allow-list is {client_credentials, jwt-bearer}; + // any other grant_type returns invalid_client_metadata. + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-bad-grant", + "grant_types": []string{"password"}, // not allowed for DCR + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body := decode(t, resp) + assert.Equal(t, "invalid_client_metadata", body["error"]) +} + +// ── RFC 7591 §3.1 — Client Registration Request ───────────────────────────── + +func TestRFC7591_S3_1_InitialAccessTokenRequired(t *testing.T) { + // RFC 7591 §3.1: "If the authorization server supports the use of OAuth + // 2.0 [RFC6749] access tokens to authenticate to the client + // registration endpoint, the client developer MUST present an initial + // access token (...) in the Authorization HTTP header field." + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-no-iat", + "grant_types": []string{"client_credentials"}, + }, nil) + // Huma may reject the missing required header at 422 or the handler at 401; + // either is conformant — both signal "you didn't authenticate." + require.Contains(t, []int{http.StatusUnauthorized, http.StatusUnprocessableEntity}, resp.StatusCode, + "missing Authorization on POST /oauth2/register MUST be rejected; got %d", resp.StatusCode) +} + +func TestRFC7591_S3_1_InsufficientScopeRejected(t *testing.T) { + // RFC 7591 §3.1: the initial access token is "issued to the developer + // ... to authenticate or authorize the registration request." ZeroID + // ties that authorization to the `client:register` scope; a token + // without it MUST be rejected with insufficient_scope. + agentID := uid("compliance-wrong-scope") + registerIdentity(t, agentID, []string{"data:read"}) + client := registerOAuthClient(t, agentID, []string{"data:read"}) + tokenResp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "data:read", + }, nil) + require.Equal(t, http.StatusOK, tokenResp.StatusCode) + token, _ := decode(t, tokenResp)["access_token"].(string) + + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-wrong-scope-tool", + "grant_types": []string{"client_credentials"}, + }, map[string]string{"Authorization": "Bearer " + token}) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + body := decode(t, resp) + assert.Equal(t, "insufficient_scope", body["error"]) +} + +// ── RFC 7591 §3.2.1 — Client Information Response ─────────────────────────── + +func TestRFC7591_S3_2_1_ResponseContainsRequiredFields(t *testing.T) { + // RFC 7591 §3.2.1: "client_id REQUIRED. ... client_secret OPTIONAL. ... + // client_id_issued_at OPTIONAL. ... client_secret_expires_at REQUIRED + // if client_secret is issued." + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-response-shape", + "grant_types": []string{"client_credentials"}, + "scope": "data:read", + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + body := decode(t, resp) + + assert.NotEmpty(t, body["client_id"], "client_id REQUIRED") + assert.NotEmpty(t, body["client_secret"], "DCR-issued clients are confidential; client_secret returned once") + _, hasExpires := body["client_secret_expires_at"] + assert.True(t, hasExpires, "client_secret_expires_at REQUIRED when client_secret is issued (RFC 7591 §3.2.1)") +} + +func TestRFC7591_S3_2_1_ClientIdIssuedAtIsUnixSeconds(t *testing.T) { + // RFC 7591 §3.2.1: "client_id_issued_at ... Time at which the client + // identifier was issued. The time is represented as the number of + // seconds from 1970-01-01T00:00:00Z." + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-issued-at", + "grant_types": []string{"client_credentials"}, + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + body := decode(t, resp) + issued, ok := body["client_id_issued_at"].(float64) + require.True(t, ok, "client_id_issued_at must be numeric (Unix seconds)") + // Sanity: not before 2020-01-01 and not after 2200-01-01. + assert.Greater(t, issued, 1577836800.0, "client_id_issued_at must look like a real Unix timestamp") + assert.Less(t, issued, 7258118400.0, "client_id_issued_at must look like a real Unix timestamp") +} + +func TestRFC7591_S3_2_1_RegistrationClientUriReturned(t *testing.T) { + // RFC 7592 §1: "The OAuth 2.0 client registration response includes + // ... registration_client_uri: the URL at which the client can + // access its registration." + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-reg-client-uri", + "grant_types": []string{"client_credentials"}, + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + body := decode(t, resp) + clientID, _ := body["client_id"].(string) + uri, _ := body["registration_client_uri"].(string) + assert.Contains(t, uri, "/oauth2/register/"+clientID, + "registration_client_uri MUST point at the per-client management endpoint") +} + +// ── RFC 7591 §3.2.2 — Client Registration Error Response ──────────────────── + +func TestRFC7591_S3_2_2_ErrorResponseShape(t *testing.T) { + // RFC 7591 §3.2.2: "the authorization server SHALL include an HTTP 400 + // Bad Request status code (...) and include the following parameters + // with the response: error (REQUIRED), error_description (OPTIONAL)." + // + // We trip the handler's own metadata validation (an unsupported + // token_endpoint_auth_method) rather than Huma's framework-level + // required-field check — the latter responds with RFC 7807 Problem + // Details + 422, which is correct for a request-validation layer but + // distinct from RFC 7591's wire shape for an *accepted-and-validated* + // registration that fails metadata rules. + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "compliance-7591-error-shape", + "grant_types": []string{"client_credentials"}, + "token_endpoint_auth_method": "private_key_jwt", // not in DCR allow-list + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body := decode(t, resp) + assert.Equal(t, "invalid_client_metadata", body["error"], "error field is REQUIRED and must be a registered code") + assert.NotEmpty(t, body["error_description"], "error_description is OPTIONAL but recommended; ours provides one") +} + +// ── RFC 7592 §2 — Client Configuration Endpoint ───────────────────────────── + +func TestRFC7592_S2_1_ReadRequiresRegistrationAccessToken(t *testing.T) { + // RFC 7592 §2.1: "The client MUST authenticate to the configuration + // endpoint using the registration_access_token (...) in the + // Authorization HTTP header." + clientID, _ := dcrRegisterWithIAT(t, "compliance-7592-read-no-auth") + resp := get(t, "/oauth2/register/"+clientID, nil) + require.Contains(t, []int{http.StatusUnauthorized, http.StatusUnprocessableEntity}, resp.StatusCode, + "GET without Authorization MUST be rejected; got %d", resp.StatusCode) +} + +func TestRFC7592_S2_1_WrongRegistrationAccessTokenRejected(t *testing.T) { + // RFC 7592 §2.1 / §3: an unknown or wrong registration_access_token + // MUST result in an unauthenticated response. + clientID, _ := dcrRegisterWithIAT(t, "compliance-7592-bad-token") + resp := get(t, "/oauth2/register/"+clientID, + map[string]string{"Authorization": "Bearer not-the-real-token"}) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + body := decode(t, resp) + assert.Equal(t, "invalid_token", body["error"]) +} + +func TestRFC7592_S2_2_ReadDoesNotRevealClientSecret(t *testing.T) { + // RFC 7592 §2.2: GET responses MUST NOT include secret credentials. + // (Spec wording: "Note that the values returned in this response can + // be values other than the values that were originally sent (...)"; + // our implementation strictly omits client_secret + reg token on GET.) + clientID, regToken := dcrRegisterWithIAT(t, "compliance-7592-no-secret-in-get") + resp := get(t, "/oauth2/register/"+clientID, + map[string]string{"Authorization": "Bearer " + regToken}) + require.Equal(t, http.StatusOK, resp.StatusCode) + body := decode(t, resp) + _, hasSecret := body["client_secret"] + assert.False(t, hasSecret, "GET MUST NOT re-reveal client_secret") + _, hasRegToken := body["registration_access_token"] + assert.False(t, hasRegToken, "GET MUST NOT re-reveal registration_access_token") +} + +func TestRFC7592_S2_3_PutIsFullReplacement(t *testing.T) { + // RFC 7592 §2.3: "the values of the entire client metadata MUST be + // replaced. Fields not present in the request MUST be treated as + // removed (or returned to defaults)." + clientID, regToken := dcrRegisterWithIAT(t, "compliance-7592-put-replace") + + // PUT a body that omits `scope` → server must clear it / apply default + // (empty slice). + putResp := doRequest(t, http.MethodPut, "/oauth2/register/"+clientID, map[string]any{ + "client_name": "compliance-7592-put-replace", + "grant_types": []string{"client_credentials"}, + // scope deliberately omitted + }, map[string]string{"Authorization": "Bearer " + regToken}) + require.Equal(t, http.StatusOK, putResp.StatusCode) + body := decode(t, putResp) + // scope on the response should be the empty string (default), NOT the + // `data:read` value the client was registered with. + assert.Equal(t, "", body["scope"], "omitted scope on PUT MUST clear the previous value, not preserve it") +} + +func TestRFC7592_S2_4_DeleteReturnsNoContent(t *testing.T) { + // RFC 7592 §2.4: "The authorization server responds with HTTP 204 No + // Content if the deletion is successful." + clientID, regToken := dcrRegisterWithIAT(t, "compliance-7592-delete-204") + resp := doRequest(t, http.MethodDelete, "/oauth2/register/"+clientID, nil, + map[string]string{"Authorization": "Bearer " + regToken}) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + _ = resp.Body.Close() +} + +func TestRFC7592_S2_4_DeleteRemovesClient(t *testing.T) { + // RFC 7592 §2.4: after successful deletion, the client_id is no longer + // usable. Per ZeroID's semantics this manifests as a 401 invalid_token + // when the registration_access_token's lookup fails. + clientID, regToken := dcrRegisterWithIAT(t, "compliance-7592-delete-then-get") + + delResp := doRequest(t, http.MethodDelete, "/oauth2/register/"+clientID, nil, + map[string]string{"Authorization": "Bearer " + regToken}) + require.Equal(t, http.StatusNoContent, delResp.StatusCode) + _ = delResp.Body.Close() + + getResp := get(t, "/oauth2/register/"+clientID, + map[string]string{"Authorization": "Bearer " + regToken}) + assert.Equal(t, http.StatusUnauthorized, getResp.StatusCode, + "after delete, GET against the dead client_id MUST fail authentication") +} diff --git a/tests/integration/dpop_compliance_test.go b/tests/integration/dpop_compliance_test.go new file mode 100644 index 00000000..9d856b9f --- /dev/null +++ b/tests/integration/dpop_compliance_test.go @@ -0,0 +1,373 @@ +// RFC 9449 (DPoP) compliance suite. +// +// See COMPLIANCE.md for the conventions this file follows: one MUST per test, +// test name carries the RFC + section citation, first comment quotes the +// clause, and the file groups tests in RFC order. +// +// Happy-path coverage (cnf.jkt set, token_type=DPoP, jti replay) lives in +// dpop_test.go and dpop_refresh_test.go. This file is the negative-space +// proof that each normative clause is enforced. + +package integration_test + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v4/jwa" + "github.com/lestrrat-go/jwx/v4/jwk" + "github.com/lestrrat-go/jwx/v4/jws" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildCustomDPoPProof signs a DPoP proof whose headers and payload come +// directly from the caller — used to construct spec violations the high-level +// `buildDPoPProof` helper deliberately can't produce (omitted claims, wrong +// `typ`, wrong `alg`, private-key-in-jwk-header, etc.). +// +// `extraHeaders` and `payload` are merged with the canonical fields. Pass nil +// for headers to use the defaults (`typ=dpop+jwt`, embedded public jwk, ES256); +// pass nil for payload to use canonical `{htm, htu, iat, jti}`. Pass an explicit +// non-nil map to override. +func buildCustomDPoPProof(t *testing.T, privKey *ecdsa.PrivateKey, headers, payload map[string]any) string { + t.Helper() + privJWK, err := jwk.Import[jwk.Key](privKey) + require.NoError(t, err) + pubJWK, err := jwk.Import[jwk.Key](&privKey.PublicKey) + require.NoError(t, err) + + hdrs := jws.NewHeaders() + defaultHeaders := map[string]any{ + "typ": "dpop+jwt", + "jwk": pubJWK, + } + for k, v := range defaultHeaders { + if _, ok := headers[k]; ok { + continue + } + require.NoError(t, hdrs.Set(k, v)) + } + for k, v := range headers { + if v == nil { + continue // explicit nil ⇒ omit header + } + require.NoError(t, hdrs.Set(k, v)) + } + + if payload == nil { + payload = map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token", + "iat": time.Now().Unix(), + "jti": uuid.New().String(), + } + } + payloadBytes, err := json.Marshal(payload) + require.NoError(t, err) + + signed, err := jws.Sign(payloadBytes, jws.WithKey(jwa.ES256(), privJWK, jws.WithProtectedHeaders(hdrs))) + require.NoError(t, err) + return string(signed) +} + +// dpopRegisterAndTokenRequest sets up the minimum environment a DPoP proof can +// be presented against — registers a confidential client and returns the +// canonical token-request body. Each test reuses this and only varies the +// DPoP header. +func dpopRegisterAndTokenRequest(t *testing.T, namePrefix string) map[string]any { + t.Helper() + agentID := uid(namePrefix) + registerIdentity(t, agentID, []string{"data:read"}) + client := registerOAuthClient(t, agentID, []string{"data:read"}) + return map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "data:read", + } +} + +// assertInvalidDPoPProof posts a /oauth2/token request with the given proof +// and asserts the response is RFC 9449 §5 / RFC 6749 §5.2 shape: +// HTTP 400 with `error=invalid_dpop_proof`. +func assertInvalidDPoPProof(t *testing.T, body map[string]any, proof string) { + t.Helper() + resp := post(t, "/oauth2/token", body, map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "expected 400 for malformed proof") + errBody := decode(t, resp) + assert.Equal(t, "invalid_dpop_proof", errBody["error"], "error field must be invalid_dpop_proof per RFC 9449 §5") +} + +// ── RFC 9449 §4.2 — DPoP Proof JWT ─────────────────────────────────────────── + +func TestRFC9449_S4_2_TypHeaderMustBeDpopJwt(t *testing.T) { + // RFC 9449 §4.2: "The JOSE Header MUST contain at least the following + // parameters: ... typ: with value dpop+jwt" + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildCustomDPoPProof(t, key, map[string]any{"typ": "JWT"}, nil) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-typ"), proof) +} + +func TestRFC9449_S4_2_AlgMustBeAsymmetric(t *testing.T) { + // RFC 9449 §4.2: "alg: An identifier for an asymmetric digital signature + // algorithm ... MUST NOT be none or an identifier for a symmetric + // algorithm (MAC)." + // + // We assemble an HS256-signed proof manually because jws.Sign refuses to + // embed a symmetric key in the jwk header; the verifier must reject it + // at the alg-allowlist step (step 3 in DPoPService.validate). + hdrJSON, err := json.Marshal(map[string]any{"typ": "dpop+jwt", "alg": "HS256"}) + require.NoError(t, err) + payloadJSON, err := json.Marshal(map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token", + "iat": time.Now().Unix(), + "jti": uuid.New().String(), + }) + require.NoError(t, err) + signingInput := base64.RawURLEncoding.EncodeToString(hdrJSON) + "." + base64.RawURLEncoding.EncodeToString(payloadJSON) + // Signature bytes are irrelevant — we expect rejection at the alg check + // before signature verification. Pad with zeros so the JWS parses. + proof := signingInput + "." + base64.RawURLEncoding.EncodeToString(make([]byte, 32)) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-alg"), proof) +} + +func TestRFC9449_S4_2_JwkHeaderMustBePresent(t *testing.T) { + // RFC 9449 §4.2: "jwk: The public key chosen by the client, in JSON Web + // Key (JWK) format, as defined in Section 4 of [RFC7517]" + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + // nil header value ⇒ omit + proof := buildCustomDPoPProof(t, key, map[string]any{"jwk": nil}, nil) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-no-jwk"), proof) +} + +func TestRFC9449_S4_2_JwkMustNotContainPrivateKey(t *testing.T) { + // RFC 9449 §4.2: "The jwk header value SHOULD NOT contain a private key." + // We treat this as a MUST NOT — the validator rejects any private-key JWK + // type (ECDSAPrivateKey / RSAPrivateKey / OKPPrivateKey) at step 4. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + privJWK, err := jwk.Import[jwk.Key](key) + require.NoError(t, err) + // Override with the *private* JWK in the jwk header — must be rejected. + proof := buildCustomDPoPProof(t, key, map[string]any{"jwk": privJWK}, nil) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-priv-jwk"), proof) +} + +func TestRFC9449_S4_2_IatMustBePresent(t *testing.T) { + // RFC 9449 §4.2: "iat: Creation timestamp of the JWT (Section 4.1.6 of + // [RFC7519]); REQUIRED" + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token", + "jti": uuid.New().String(), + // iat deliberately omitted + }) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-no-iat"), proof) +} + +func TestRFC9449_S4_2_JtiMustBePresent(t *testing.T) { + // RFC 9449 §4.2: "jti: Unique identifier for the DPoP proof JWT. + // ... REQUIRED" + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token", + "iat": time.Now().Unix(), + // jti deliberately omitted + }) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-no-jti"), proof) +} + +func TestRFC9449_S4_2_IatOutsideFreshnessWindowRejected(t *testing.T) { + // RFC 9449 §4.3: "the iat claim of the JWT is within an acceptable + // timeframe (...)". 60 s freshness window + 5 s skew (per + // internal/service/dpop.go). + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token", + "iat": time.Now().Add(-2 * time.Minute).Unix(), + "jti": uuid.New().String(), + }) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-old-iat"), proof) +} + +func TestRFC9449_S4_2_IatFarInFutureRejected(t *testing.T) { + // RFC 9449 §4.3 (server validation): future iat beyond the skew tolerance + // must be rejected; otherwise a client could pre-mint long-lived proofs. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token", + "iat": time.Now().Add(time.Hour).Unix(), + "jti": uuid.New().String(), + }) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-future-iat"), proof) +} + +func TestRFC9449_S4_2_ExpInPastRejected(t *testing.T) { + // RFC 9449 §4.2: exp is OPTIONAL but if present must be honoured. + // Without this, an explicitly-expired proof could ride on the iat + // freshness check alone. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + now := time.Now().Unix() + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token", + "iat": now, + "jti": uuid.New().String(), + "exp": now - 600, // expired ten minutes ago + }) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-past-exp"), proof) +} + +func TestRFC9449_S4_2_NbfInFutureRejected(t *testing.T) { + // RFC 9449 §4.2: nbf is OPTIONAL but if present must be honoured. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + now := time.Now().Unix() + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token", + "iat": now, + "jti": uuid.New().String(), + "nbf": now + 600, // not valid for another ten minutes + }) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-future-nbf"), proof) +} + +// ── RFC 9449 §4.3 — Server validation ──────────────────────────────────────── + +func TestRFC9449_S4_3_SingleSignatureRequired(t *testing.T) { + // RFC 9449 §4.2: a DPoP proof is a JWT — implicitly compact JWS (one sig). + // JWS JSON Serialization with >1 signature must be rejected so that an + // attacker can't attach a second signature at index 0 with benign + // protected headers while the attack signature sits elsewhere. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Build a perfectly normal compact proof, then re-serialize it as JWS + // JSON with the same signature duplicated. Both signature slots will + // pass crypto, but the validator must reject before reaching either. + compact := buildCustomDPoPProof(t, key, nil, nil) + parts := strings.Split(compact, ".") + require.Len(t, parts, 3, "compact JWS has three parts") + jsonSerialized := map[string]any{ + "payload": parts[1], + "signatures": []map[string]any{ + {"protected": parts[0], "signature": parts[2]}, + {"protected": parts[0], "signature": parts[2]}, + }, + } + jsonBytes, err := json.Marshal(jsonSerialized) + require.NoError(t, err) + + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-multi-sig"), string(jsonBytes)) +} + +func TestRFC9449_S4_3_HtmCaseSensitive(t *testing.T) { + // RFC 9449 §4.2 inherits RFC 9110 §9.1: HTTP method names are + // case-sensitive uppercase. Lowercase "post" must NOT match POST. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": "post", // lowercase + "htu": testServer.URL + "/oauth2/token", + "iat": time.Now().Unix(), + "jti": uuid.New().String(), + }) + assertInvalidDPoPProof(t, dpopRegisterAndTokenRequest(t, "dpop-htm-case"), proof) +} + +func TestRFC9449_S4_3_HtuStripsQueryAndFragment(t *testing.T) { + // RFC 9449 §4.2: "The htu claim ... matching is performed ... but + // excluding any query and fragment parts." + // Positive assertion: a proof whose htu carries a query string MUST be + // accepted because the comparator strips it. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": http.MethodPost, + "htu": testServer.URL + "/oauth2/token?stray=value#fragment", + "iat": time.Now().Unix(), + "jti": uuid.New().String(), + }) + resp := post(t, "/oauth2/token", dpopRegisterAndTokenRequest(t, "dpop-htu-query"), map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusOK, resp.StatusCode, "htu with query/fragment must still match after normalisation") + tok := decode(t, resp) + assert.Equal(t, "DPoP", tok["token_type"], "token_type must reflect successful proof acceptance") +} + +func TestRFC9449_S4_3_HtuHostCaseInsensitive(t *testing.T) { + // RFC 3986 §3.2.2: the host component of a URI is case-insensitive. + // A proof signed with `EXAMPLE.com` MUST verify against `example.com` + // (and vice-versa). Build a proof whose htu uppercases the host portion + // of testServer.URL. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // testServer.URL is like http://127.0.0.1:NNNN; uppercase the scheme. + upperURL := strings.ToUpper(testServer.URL[:4]) + testServer.URL[4:] + proof := buildCustomDPoPProof(t, key, nil, map[string]any{ + "htm": http.MethodPost, + "htu": upperURL + "/oauth2/token", + "iat": time.Now().Unix(), + "jti": uuid.New().String(), + }) + resp := post(t, "/oauth2/token", dpopRegisterAndTokenRequest(t, "dpop-htu-case"), map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusOK, resp.StatusCode, "host/scheme case-difference must still validate") + tok := decode(t, resp) + assert.Equal(t, "DPoP", tok["token_type"]) +} + +// ── RFC 9449 §6.1 — Confirmation claim ─────────────────────────────────────── + +func TestRFC9449_S6_1_CnfJktEqualsRfc7638Thumbprint(t *testing.T) { + // RFC 9449 §6.1: "When access tokens are represented as JWTs ... a public + // key confirmation MUST be made using a JWK SHA-256 Thumbprint + // confirmation method as defined in [RFC7638]." + // + // We compute the thumbprint independently (via jwk.Thumbprint with + // crypto.SHA256, base64url-encoded with no padding) and assert byte-equal + // to the cnf.jkt the server published on a successfully-bound token. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + pubJWK, err := jwk.Import[jwk.Key](&key.PublicKey) + require.NoError(t, err) + expectedBytes, err := pubJWK.Thumbprint(crypto.SHA256) + require.NoError(t, err) + expected := base64.RawURLEncoding.EncodeToString(expectedBytes) + + body := dpopRegisterAndTokenRequest(t, "dpop-jkt") + proof := buildCustomDPoPProof(t, key, nil, nil) + resp := post(t, "/oauth2/token", body, map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusOK, resp.StatusCode) + accessToken, _ := decode(t, resp)["access_token"].(string) + + result := introspect(t, accessToken) + cnf, ok := result["cnf"].(map[string]any) + require.True(t, ok) + jkt, _ := cnf["jkt"].(string) + assert.Equal(t, expected, jkt, "cnf.jkt MUST equal base64url(SHA-256(JWK)) per RFC 7638") +} diff --git a/tests/integration/dpop_dcr_cross_test.go b/tests/integration/dpop_dcr_cross_test.go new file mode 100644 index 00000000..7f2523de --- /dev/null +++ b/tests/integration/dpop_dcr_cross_test.go @@ -0,0 +1,230 @@ +// Cross-feature tests for DPoP × DCR + the boundaries where prior bugs hid. +// The complementary suites (`dpop_*_test.go`, `dcr_compliance_test.go`, +// `dynamic_registration_test.go`) cover each feature in isolation; this file +// covers the *compositions* — historically where regressions land. + +package integration_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// registerDCRClient performs a complete RFC 7591 registration and returns the +// freshly-minted client_id and client_secret ready to use in /oauth2/token. +func registerDCRClient(t *testing.T, clientName string, grantTypes []string, scope string) (clientID, clientSecret string) { + t.Helper() + iat := issueClientRegisterToken(t) + body := map[string]any{ + "client_name": clientName, + "grant_types": grantTypes, + } + if scope != "" { + body["scope"] = scope + } + resp := post(t, "/oauth2/register", body, + map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + r := decode(t, resp) + clientID, _ = r["client_id"].(string) + clientSecret, _ = r["client_secret"].(string) + require.NotEmpty(t, clientID) + require.NotEmpty(t, clientSecret) + return +} + +// TestDCRClient_ClientCredentialsRoundTrip is the regression guard for the +// pre-merge blocker: a DCR-registered client was 500-ing on +// /oauth2/register because the OAuthClient row violated the +// backchannel_token_delivery_mode CHECK constraint. The full POST → +// register → /oauth2/token chain proves the row is valid AND surfaces the +// specific OAuth error that today distinguishes "DCR client exists, ZeroID +// can't issue tokens to it" from infrastructure failures. +// +// Current behaviour (pinned by this test): DCR-registered clients are NOT +// linked to a zeroid Identity at registration time (the platform issues +// no identity-binding contract through DCR), so /oauth2/token rejects +// with HTTP 401 and `error: invalid_client` carrying an error_description +// that names the missing-identity cause. If the platform ever decides to +// support identity-less DCR clients (or to require identity binding at +// registration), this test pins the contract that today's behaviour is. +func TestDCRClient_ClientCredentialsRoundTrip(t *testing.T) { + clientID, clientSecret := registerDCRClient(t, + "cross-dcr-cc-roundtrip", []string{"client_credentials"}, "data:read") + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": clientID, + "client_secret": clientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "data:read", + }, nil) + + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, + "DCR client without identity binding gets 401 invalid_client, not 5xx (regression guard for the migration-021 CHECK-constraint blocker)") + body := decode(t, resp) + assert.Equal(t, "invalid_client", body["error"], + "OAuth error code for identity-less DCR client must be invalid_client") + desc, _ := body["error_description"].(string) + assert.Contains(t, desc, "no identity found", + "error_description must name the cause — proves the dispatcher reached the identity-resolution step, i.e. the registration row is valid") +} + +// TestDCRClient_TokenExchangeRejected verifies the policy boundary end-to-end: +// the DCR allow-list excludes token_exchange, and a DCR-registered client +// must not be able to even register with it (the register call rejects). +// This is the regression guard for the security-review finding that DCR +// clients can't legitimately be delegation actors. +func TestDCRClient_TokenExchangeRejected(t *testing.T) { + iat := issueClientRegisterToken(t) + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "cross-dcr-token-exchange", + "grant_types": []string{"urn:ietf:params:oauth:grant-type:token-exchange"}, + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "DCR registration with token_exchange MUST be rejected — DCR clients have no IdentityID and cannot act as delegation actors") + body := decode(t, resp) + assert.Equal(t, "invalid_client_metadata", body["error"]) +} + +// TestDCRClient_DPoPBoundTokenIssuance exercises the full DPoP × DCR loop: +// self-register, then immediately use that client to obtain a token while +// presenting a DPoP proof. The DCR client_id and client_secret are the only +// state the caller persists; the DPoP key is held only in process memory. +// +// Pins the cross-feature property: DPoP validation runs cleanly first +// (proof is well-formed, jti recorded, thumbprint computed) and only then +// does the request reach the grant dispatcher. Because today's +// DCR-registered clients have no identity binding, the dispatcher returns +// invalid_client — same as TestDCRClient_ClientCredentialsRoundTrip. The +// presence of a valid DPoP proof must not change THAT outcome (a stolen +// secret presenting a perfect DPoP proof still can't unlock an +// identity-less client) and must not 5xx through the proof-validation path +// either (regression guard for any path that might have leaked DPoP +// failures as 500s). +func TestDCRClient_DPoPBoundTokenIssuance(t *testing.T) { + clientID, clientSecret := registerDCRClient(t, + "cross-dcr-dpop", []string{"client_credentials"}, "data:read") + + dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildDPoPProof(t, dpopKey, http.MethodPost, + testServer.URL+"/oauth2/token", uuid.New().String()) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": clientID, + "client_secret": clientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "data:read", + }, map[string]string{"DPoP": proof}) + + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, + "DCR+DPoP composition must reach the identity-resolution step (401 invalid_client), not 5xx through DPoP validation") + body := decode(t, resp) + assert.Equal(t, "invalid_client", body["error"], + "a valid DPoP proof does not unlock an identity-less client; the underlying invalid_client error stands") + desc, _ := body["error_description"].(string) + assert.Contains(t, desc, "no identity found", + "error_description must come from the identity-resolution step — proves DPoP validation completed without short-circuiting") +} + +// TestDPoPTokenExchange_PropagatesBindingToSubAgent walks the full RFC 8693 +// token_exchange delegation hop end-to-end and verifies that the binding +// follows the *per-call* DPoP proof — not anything persisted from upstream: +// +// 1. Orchestrator gets a client_credentials token under DPoP key K1. +// Issued JWT carries cnf.jkt = thumbprint(K1). +// 2. Sub-agent (distinct identity, ECDSA keypair K2 for its actor assertion) +// and the orchestrator together call /oauth2/token grant=token-exchange. +// The DPoP proof on *this* request is signed by a fresh key K3 (which is +// intentionally different from both K1 and K2 to prove the binding is +// independent of both upstream parties). +// 3. The delegated token returned to the sub-agent MUST carry +// cnf.jkt = thumbprint(K3) — the proof from THIS request, not the +// orchestrator's K1 and not the actor's signing key K2. +// +// This is the cross-feature property the security review highlighted: +// each IssueCredential call site has its own DPoPKeyThumbprint, and the +// binding cannot leak across hops. +func TestDPoPTokenExchange_PropagatesBindingToSubAgent(t *testing.T) { + // Orchestrator: NHI identity, DPoP-bound token under K1. + orchID := uid("cross-orch") + registerIdentity(t, orchID, []string{"data:read"}) + orchClient := registerOAuthClient(t, orchID, []string{"data:read"}) + + orchKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + orchProof := buildDPoPProof(t, orchKey, http.MethodPost, + testServer.URL+"/oauth2/token", uuid.New().String()) + + orchResp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": orchClient.ClientID, + "client_secret": orchClient.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "data:read", + }, map[string]string{"DPoP": orchProof}) + require.Equal(t, http.StatusOK, orchResp.StatusCode) + orchToken, _ := decode(t, orchResp)["access_token"].(string) + require.NotEmpty(t, orchToken) + // Sanity: orchestrator's token IS bound to K1. + orchIntrospect := introspect(t, orchToken) + orchCnf, ok := orchIntrospect["cnf"].(map[string]any) + require.True(t, ok) + require.Equal(t, dpopKeyThumbprint(t, &orchKey.PublicKey), orchCnf["jkt"]) + + // Sub-agent: distinct identity with its own ECDSA keypair K2 (signs the + // actor assertion required for jwt_bearer-style delegation). + subKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + subID := uid("cross-sub") + subIdentity := registerIdentity(t, subID, []string{"data:read"}, ecPublicKeyPEM(t, subKey)) + actorAssertion := buildAssertion(t, subKey, subIdentity.WIMSEURI) + + // K3: the proof key on THIS token_exchange request. Distinct from K1 + // (orchestrator's binding) and K2 (sub-agent's actor signing key) so + // the assertion below is unambiguous. + hopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + hopProof := buildDPoPProof(t, hopKey, http.MethodPost, + testServer.URL+"/oauth2/token", uuid.New().String()) + + exchResp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": orchToken, + "actor_token": actorAssertion, + "scope": "data:read", + }, map[string]string{"DPoP": hopProof}) + require.Equal(t, http.StatusOK, exchResp.StatusCode) + exchBody := decode(t, exchResp) + assert.Equal(t, "DPoP", exchBody["token_type"], + "delegated token must report token_type=DPoP because the hop carried a valid proof") + + delegatedToken, _ := exchBody["access_token"].(string) + require.NotEmpty(t, delegatedToken) + + // Introspect the delegated token: cnf.jkt MUST be K3's thumbprint, + // proving the binding follows the per-call proof and is not inherited + // from the orchestrator's K1 or the actor's K2. + delResult := introspect(t, delegatedToken) + cnf, ok := delResult["cnf"].(map[string]any) + require.True(t, ok, "delegated token MUST carry cnf when the exchange call presented DPoP") + jkt, _ := cnf["jkt"].(string) + assert.Equal(t, dpopKeyThumbprint(t, &hopKey.PublicKey), jkt, + "binding MUST track THIS request's proof key (K3), not the orchestrator's K1 or the actor's K2") + assert.NotEqual(t, dpopKeyThumbprint(t, &orchKey.PublicKey), jkt, + "explicit anti-leak assertion: the orchestrator's binding K1 must NOT propagate downstream") + assert.NotEqual(t, dpopKeyThumbprint(t, &subKey.PublicKey), jkt, + "explicit anti-leak assertion: the sub-agent's signing key K2 must NOT become the cnf binding") +} diff --git a/tests/integration/dpop_refresh_test.go b/tests/integration/dpop_refresh_test.go new file mode 100644 index 00000000..fabc4eb0 --- /dev/null +++ b/tests/integration/dpop_refresh_test.go @@ -0,0 +1,124 @@ +package integration_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authCodeWithDPoP exchanges a freshly-built auth code for an access + refresh +// token while presenting a DPoP proof. Returns the refresh_token and the +// thumbprint of the proof key (which both halves of the token chain must be +// bound to). +func authCodeWithDPoP(t *testing.T, userID string, dpopKey *ecdsa.PrivateKey) (refreshToken, thumbprint string) { + t.Helper() + verifier, challenge := buildPKCEPair(t) + code := buildAuthCode(t, testMCPClientID, userID, testRedirectURI, challenge, []string{"data:read"}) + + proof := buildDPoPProof(t, dpopKey, http.MethodPost, testServer.URL+"/oauth2/token", uuid.New().String()) + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "authorization_code", + "client_id": testMCPClientID, + "code": code, + "code_verifier": verifier, + "redirect_uri": testRedirectURI, + }, map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusOK, resp.StatusCode, "auth_code+DPoP exchange must succeed") + body := decode(t, resp) + assert.Equal(t, "DPoP", body["token_type"], "token_type must be DPoP when a proof is present") + refreshToken, _ = body["refresh_token"].(string) + require.NotEmpty(t, refreshToken, "auth-code exchange with refresh_token grant must return a refresh_token") + thumbprint = dpopKeyThumbprint(t, &dpopKey.PublicKey) + return +} + +// TestDPoPRefreshBoundOriginalKeySucceeds verifies the happy path: a refresh +// token minted under DPoP can be redeemed by presenting a proof signed by the +// same key, and the rotated successor stays bound to that key. +func TestDPoPRefreshBoundOriginalKeySucceeds(t *testing.T) { + dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + refreshToken, _ := authCodeWithDPoP(t, "user-dpop-refresh-ok", dpopKey) + + refreshProof := buildDPoPProof(t, dpopKey, http.MethodPost, testServer.URL+"/oauth2/token", uuid.New().String()) + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "refresh_token", + "client_id": testMCPClientID, + "refresh_token": refreshToken, + }, map[string]string{"DPoP": refreshProof}) + require.Equal(t, http.StatusOK, resp.StatusCode, "refresh with the original DPoP key must succeed") + body := decode(t, resp) + assert.Equal(t, "DPoP", body["token_type"], "rotated access token must remain DPoP-bound") + successorRT, _ := body["refresh_token"].(string) + require.NotEmpty(t, successorRT) + + // Successor refresh token is itself bound — replaying the original is forbidden + // (single-use, RFC 6749 §6) but the successor must rotate again with the same + // key. + successorProof := buildDPoPProof(t, dpopKey, http.MethodPost, testServer.URL+"/oauth2/token", uuid.New().String()) + resp2 := post(t, "/oauth2/token", map[string]any{ + "grant_type": "refresh_token", + "client_id": testMCPClientID, + "refresh_token": successorRT, + }, map[string]string{"DPoP": successorProof}) + require.Equal(t, http.StatusOK, resp2.StatusCode, "successor refresh must stay bound across the rotation chain") + _ = resp2.Body.Close() +} + +// TestDPoPRefreshBoundWithDifferentKeyRejected verifies that a refresh request +// signed by a different DPoP key is rejected with invalid_dpop_proof, and the +// underlying refresh token is NOT consumed by the failed attempt (so the +// legitimate client can still rotate it with the original key). +func TestDPoPRefreshBoundWithDifferentKeyRejected(t *testing.T) { + originalKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + refreshToken, _ := authCodeWithDPoP(t, "user-dpop-refresh-wrong-key", originalKey) + + attackerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + require.NotEqual(t, dpopKeyThumbprint(t, &originalKey.PublicKey), dpopKeyThumbprint(t, &attackerKey.PublicKey)) + + attackerProof := buildDPoPProof(t, attackerKey, http.MethodPost, testServer.URL+"/oauth2/token", uuid.New().String()) + bad := post(t, "/oauth2/token", map[string]any{ + "grant_type": "refresh_token", + "client_id": testMCPClientID, + "refresh_token": refreshToken, + }, map[string]string{"DPoP": attackerProof}) + require.Equal(t, http.StatusBadRequest, bad.StatusCode, "different-key DPoP proof must be rejected") + errBody := decode(t, bad) + assert.Equal(t, "invalid_dpop_proof", errBody["error"]) + + // Original key still works — the failed attempt did NOT consume the refresh token. + goodProof := buildDPoPProof(t, originalKey, http.MethodPost, testServer.URL+"/oauth2/token", uuid.New().String()) + good := post(t, "/oauth2/token", map[string]any{ + "grant_type": "refresh_token", + "client_id": testMCPClientID, + "refresh_token": refreshToken, + }, map[string]string{"DPoP": goodProof}) + require.Equal(t, http.StatusOK, good.StatusCode, "after a rejected wrong-key proof, the original key must still rotate the same refresh token") + _ = good.Body.Close() +} + +// TestDPoPRefreshBoundWithoutProofRejected verifies that a refresh request +// against a DPoP-bound refresh token without any DPoP header is rejected. +func TestDPoPRefreshBoundWithoutProofRejected(t *testing.T) { + dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + refreshToken, _ := authCodeWithDPoP(t, "user-dpop-refresh-no-proof", dpopKey) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "refresh_token", + "client_id": testMCPClientID, + "refresh_token": refreshToken, + }, nil) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "DPoP-bound refresh without proof must be rejected") + errBody := decode(t, resp) + assert.Equal(t, "invalid_dpop_proof", errBody["error"]) +} diff --git a/tests/integration/dpop_test.go b/tests/integration/dpop_test.go new file mode 100644 index 00000000..2aae616f --- /dev/null +++ b/tests/integration/dpop_test.go @@ -0,0 +1,211 @@ +package integration_test + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v4/jwa" + "github.com/lestrrat-go/jwx/v4/jwk" + "github.com/lestrrat-go/jwx/v4/jws" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildDPoPProof creates a valid DPoP proof JWT (RFC 9449) signed with privKey. +// method is the HTTP method (e.g. "POST") and htu is the full target URI. +func buildDPoPProof(t *testing.T, privKey *ecdsa.PrivateKey, method, htu, jti string) string { + t.Helper() + + privJWK, err := jwk.Import[jwk.Key](privKey) + require.NoError(t, err) + + pubJWK, err := jwk.Import[jwk.Key](&privKey.PublicKey) + require.NoError(t, err) + + payloadBytes, err := json.Marshal(map[string]any{ + "htm": method, + "htu": htu, + "iat": time.Now().Unix(), + "jti": jti, + }) + require.NoError(t, err) + + hdrs := jws.NewHeaders() + require.NoError(t, hdrs.Set("typ", "dpop+jwt")) + require.NoError(t, hdrs.Set("jwk", pubJWK)) + + signed, err := jws.Sign(payloadBytes, + jws.WithKey(jwa.ES256(), privJWK, jws.WithProtectedHeaders(hdrs)), + ) + require.NoError(t, err) + return string(signed) +} + +// dpopKeyThumbprint computes the base64url SHA-256 JWK thumbprint (RFC 7638) of an ECDSA public key. +func dpopKeyThumbprint(t *testing.T, pubKey *ecdsa.PublicKey) string { + t.Helper() + k, err := jwk.Import[jwk.Key](pubKey) + require.NoError(t, err) + tb, err := k.Thumbprint(crypto.SHA256) + require.NoError(t, err) + return base64.RawURLEncoding.EncodeToString(tb) +} + +// TestDPoPClientCredentialsFlow verifies the full DPoP happy path: +// proof present → token_type "DPoP" → introspection surfaces cnf.jkt bound to the proof key. +func TestDPoPClientCredentialsFlow(t *testing.T) { + agentID := uid("dpop-agent") + registerIdentity(t, agentID, []string{"billing:read"}) + client := registerOAuthClient(t, agentID, []string{"billing:read"}) + + dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // htu must match the URL the request actually reaches — + // testServer.URL (httptest's loopback listener), not testIssuer (the + // configured iss/baseURL value used only for JWT iss claims). + proof := buildDPoPProof(t, dpopKey, http.MethodPost, testServer.URL+"/oauth2/token", uuid.New().String()) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "billing:read", + }, map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusOK, resp.StatusCode) + + token := decode(t, resp) + assert.Equal(t, "DPoP", token["token_type"], "token_type must be DPoP when proof is present") + accessToken, _ := token["access_token"].(string) + require.NotEmpty(t, accessToken) + + // Introspect: cnf.jkt must match the DPoP key thumbprint. + result := introspect(t, accessToken) + assert.Equal(t, true, result["active"], "introspected DPoP-bound token should be active") + + cnf, ok := result["cnf"].(map[string]any) + require.True(t, ok, "cnf claim must be present in introspection for DPoP-bound token") + jkt, _ := cnf["jkt"].(string) + assert.Equal(t, dpopKeyThumbprint(t, &dpopKey.PublicKey), jkt, "cnf.jkt must match the DPoP proof key thumbprint") +} + +// TestDPoPBearerFallback verifies that omitting the DPoP header preserves the +// existing Bearer-token behaviour: token_type is "Bearer" and cnf is absent. +func TestDPoPBearerFallback(t *testing.T) { + agentID := uid("dpop-fallback-agent") + registerIdentity(t, agentID, []string{"billing:read"}) + client := registerOAuthClient(t, agentID, []string{"billing:read"}) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "billing:read", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + token := decode(t, resp) + assert.Equal(t, "Bearer", token["token_type"], "no DPoP header → token_type stays Bearer") + + accessToken, _ := token["access_token"].(string) + require.NotEmpty(t, accessToken) + result := introspect(t, accessToken) + _, hasCnf := result["cnf"] + assert.False(t, hasCnf, "Bearer tokens must not carry cnf in introspection") +} + +// TestDPoPReplayRejected verifies that the second use of the same DPoP proof +// (same jti) is rejected by the JTI replay-prevention store. +func TestDPoPReplayRejected(t *testing.T) { + agentID := uid("dpop-replay-agent") + registerIdentity(t, agentID, []string{"billing:read"}) + client := registerOAuthClient(t, agentID, []string{"billing:read"}) + + dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + jti := uuid.New().String() + proof := buildDPoPProof(t, dpopKey, http.MethodPost, testServer.URL+"/oauth2/token", jti) + + body := map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "billing:read", + } + + resp1 := post(t, "/oauth2/token", body, map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusOK, resp1.StatusCode, "first DPoP use must succeed") + _ = resp1.Body.Close() + + resp2 := post(t, "/oauth2/token", body, map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusBadRequest, resp2.StatusCode, "replay of same DPoP jti must be rejected") + errBody := decode(t, resp2) + assert.Equal(t, "invalid_dpop_proof", errBody["error"]) +} + +// TestDPoPRejectsHTMMismatch verifies that a proof whose htm claim doesn't match +// the request method is rejected. +func TestDPoPRejectsHTMMismatch(t *testing.T) { + agentID := uid("dpop-htm-agent") + registerIdentity(t, agentID, []string{"billing:read"}) + client := registerOAuthClient(t, agentID, []string{"billing:read"}) + + dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + // Build a proof claiming GET, then send POST. + proof := buildDPoPProof(t, dpopKey, http.MethodGet, testServer.URL+"/oauth2/token", uuid.New().String()) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "billing:read", + }, map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "htm mismatch must be rejected") + errBody := decode(t, resp) + assert.Equal(t, "invalid_dpop_proof", errBody["error"]) +} + +// TestDPoPRejectsBadHTU verifies that a proof whose htu points at a different +// endpoint is rejected. +func TestDPoPRejectsBadHTU(t *testing.T) { + agentID := uid("dpop-htu-agent") + registerIdentity(t, agentID, []string{"billing:read"}) + client := registerOAuthClient(t, agentID, []string{"billing:read"}) + + dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + proof := buildDPoPProof(t, dpopKey, http.MethodPost, "https://attacker.example/oauth2/token", uuid.New().String()) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "billing:read", + }, map[string]string{"DPoP": proof}) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, "htu mismatch must be rejected") + errBody := decode(t, resp) + assert.Equal(t, "invalid_dpop_proof", errBody["error"]) + // Pin the cause — if a future regression made a different check fire + // (e.g. iat or jti), the test would still see invalid_dpop_proof and + // silently lose its grip. The error_description carries the reason. + desc, _ := errBody["error_description"].(string) + assert.Contains(t, desc, "htu", "error_description must identify htu as the mismatch cause") +} diff --git a/tests/integration/dynamic_registration_test.go b/tests/integration/dynamic_registration_test.go new file mode 100644 index 00000000..6e555c38 --- /dev/null +++ b/tests/integration/dynamic_registration_test.go @@ -0,0 +1,136 @@ +package integration_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// issueClientRegisterToken provisions a confidential client whose only allowed +// scope is `client:register`, then runs the client_credentials grant against it +// to mint an initial access token suitable for POST /oauth2/register. +func issueClientRegisterToken(t *testing.T) string { + t.Helper() + agentID := uid("dcr-registrant") + registerIdentity(t, agentID, []string{"client:register"}) + client := registerOAuthClient(t, agentID, []string{"client:register"}) + + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "client:register", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode, "client_credentials for client:register must succeed") + body := decode(t, resp) + token, _ := body["access_token"].(string) + require.NotEmpty(t, token, "expected access_token in response") + return token +} + +// TestDCRRegisterCreateReadUpdateDelete walks the RFC 7591 → 7592 lifecycle: +// register → GET → PUT → DELETE, with auth switching from the initial access +// token (registration) to the registration_access_token (management). +func TestDCRRegisterCreateReadUpdateDelete(t *testing.T) { + iat := issueClientRegisterToken(t) + + // POST /oauth2/register (RFC 7591) + regResp := post(t, "/oauth2/register", map[string]any{ + "client_name": "Acme M2M Integration", + "grant_types": []string{"client_credentials"}, + "scope": "billing:read billing:write", + "token_endpoint_auth_method": "client_secret_post", + "software_id": "com.acme.integration", + "software_version": "1.0.0", + "contacts": []string{"ops@acme.example"}, + }, map[string]string{"Authorization": "Bearer " + iat}) + require.Equal(t, http.StatusCreated, regResp.StatusCode) + registered := decode(t, regResp) + clientID, _ := registered["client_id"].(string) + clientSecret, _ := registered["client_secret"].(string) + regToken, _ := registered["registration_access_token"].(string) + require.NotEmpty(t, clientID, "registration must return client_id") + require.NotEmpty(t, clientSecret, "registration must return plain client_secret once") + require.NotEmpty(t, regToken, "registration must return registration_access_token once") + assert.Equal(t, "Acme M2M Integration", registered["client_name"]) + + mgmtHeaders := map[string]string{"Authorization": "Bearer " + regToken} + + // GET /oauth2/register/{client_id} (RFC 7592) + getResp := get(t, "/oauth2/register/"+clientID, mgmtHeaders) + require.Equal(t, http.StatusOK, getResp.StatusCode) + fetched := decode(t, getResp) + assert.Equal(t, clientID, fetched["client_id"]) + assert.Equal(t, "Acme M2M Integration", fetched["client_name"]) + _, hasSecretInGet := fetched["client_secret"] + assert.False(t, hasSecretInGet, "GET must NOT re-reveal client_secret (RFC 7592)") + _, hasRegTokenInGet := fetched["registration_access_token"] + assert.False(t, hasRegTokenInGet, "GET must NOT re-reveal registration_access_token") + + // PUT /oauth2/register/{client_id} (RFC 7592 — full replacement) + putResp := doRequest(t, http.MethodPut, "/oauth2/register/"+clientID, map[string]any{ + "client_name": "Acme M2M Integration (renamed)", + "grant_types": []string{"client_credentials"}, + "scope": "billing:read", + "token_endpoint_auth_method": "client_secret_post", + }, mgmtHeaders) + require.Equal(t, http.StatusOK, putResp.StatusCode) + updated := decode(t, putResp) + assert.Equal(t, "Acme M2M Integration (renamed)", updated["client_name"]) + assert.Equal(t, "billing:read", updated["scope"], "PUT must perform full replacement (scope narrowed)") + + // DELETE /oauth2/register/{client_id} (RFC 7592) + delResp := doRequest(t, http.MethodDelete, "/oauth2/register/"+clientID, nil, mgmtHeaders) + require.Equal(t, http.StatusNoContent, delResp.StatusCode) + _ = delResp.Body.Close() + + // Subsequent GET must 401 — the row is gone, so the registration_access_token check fails. + missingResp := get(t, "/oauth2/register/"+clientID, mgmtHeaders) + assert.Equal(t, http.StatusUnauthorized, missingResp.StatusCode) + _ = missingResp.Body.Close() +} + +// TestDCRWithoutInitialAccessTokenRejected verifies that POST /oauth2/register +// requires an initial access token in the Authorization header. +func TestDCRWithoutInitialAccessTokenRejected(t *testing.T) { + resp := post(t, "/oauth2/register", map[string]any{ + "client_name": "Should Not Register", + "grant_types": []string{"client_credentials"}, + }, nil) + // Huma rejects with 422 for missing required header; either 401/422 is acceptable + // shape (the body always carries an OAuth error code in our handler path). + require.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusUnprocessableEntity, + "missing Authorization on POST /oauth2/register should be rejected; got %d", resp.StatusCode) +} + +// TestDCRInsufficientScopeRejected verifies that an access token without the +// `client:register` scope cannot register clients even with a valid signature. +func TestDCRInsufficientScopeRejected(t *testing.T) { + // Mint a token whose only scope is something unrelated. + agentID := uid("dcr-wrongscope") + registerIdentity(t, agentID, []string{"billing:read"}) + client := registerOAuthClient(t, agentID, []string{"billing:read"}) + resp := post(t, "/oauth2/token", map[string]any{ + "grant_type": "client_credentials", + "client_id": client.ClientID, + "client_secret": client.ClientSecret, + "account_id": testAccountID, + "project_id": testProjectID, + "scope": "billing:read", + }, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + token, _ := decode(t, resp)["access_token"].(string) + require.NotEmpty(t, token) + + regResp := post(t, "/oauth2/register", map[string]any{ + "client_name": "Should Not Register", + "grant_types": []string{"client_credentials"}, + }, map[string]string{"Authorization": "Bearer " + token}) + assert.Equal(t, http.StatusForbidden, regResp.StatusCode, "token without client:register scope must be 403") + errBody := decode(t, regResp) + assert.Equal(t, "insufficient_scope", errBody["error"]) +} diff --git a/tests/integration/wellknown_test.go b/tests/integration/wellknown_test.go index e038b51a..8cc780ba 100644 --- a/tests/integration/wellknown_test.go +++ b/tests/integration/wellknown_test.go @@ -115,6 +115,21 @@ func TestOAuthServerMetadata(t *testing.T) { assert.True(t, modeSet["poll"], "must advertise poll delivery mode") assert.True(t, modeSet["ping"], "must advertise ping delivery mode") assert.True(t, modeSet["push"], "must advertise push delivery mode") + + // RFC 7591 dynamic client registration endpoint. + assert.NotEmpty(t, body["registration_endpoint"], "must advertise registration_endpoint for RFC 7591") + + // RFC 9449 DPoP signing alg advertisement. + dpopAlgs, ok := body["dpop_signing_alg_values_supported"].([]any) + require.True(t, ok, "must declare dpop_signing_alg_values_supported for RFC 9449") + algSet := make(map[string]bool, len(dpopAlgs)) + for _, a := range dpopAlgs { + s, ok := a.(string) + require.True(t, ok, "dpop_signing_alg_values_supported entries must be strings") + algSet[s] = true + } + assert.True(t, algSet["ES256"], "DPoP must advertise ES256") + assert.True(t, algSet["RS256"], "DPoP must advertise RS256") } // TestHealthEndpoint verifies that /health returns 200.