|
| 1 | +## Summary |
| 2 | + |
| 3 | +When a token endpoint returns a DPoP nonce (401 with `DPoP-Nonce` header), `ProofTokenMessageHandler` retries the request using the **same request object**, which carries the **same `ClientAssertion`** (same `jti`, `iat`, etc.). Servers that enforce client assertion uniqueness (e.g. HelseID) reject the retry as a replay. |
| 4 | + |
| 5 | +Reported in: https://github.com/DuendeSoftware/foss/issues/323 |
| 6 | + |
| 7 | +## Root Cause |
| 8 | + |
| 9 | +The retry path in `ProofTokenMessageHandler.SendAsync()` reuses the original `HttpRequestMessage`. The `ClientAssertion` is set once at request creation time (e.g. in `ResponseProcessor.RedeemCodeAsync()`) and never regenerated for the retry. |
| 10 | + |
| 11 | +Additionally, `ProtocolRequest.Clone<T>()` copies `ClientAssertion` **by reference** (shallow copy), so even cloned requests share the same assertion object — though the real issue is the JWT string itself being identical. |
| 12 | + |
| 13 | +### Call chain |
| 14 | + |
| 15 | +``` |
| 16 | +ResponseProcessor.RedeemCodeAsync() |
| 17 | + → AuthorizationCodeTokenRequest created with ClientAssertion (fetched once) |
| 18 | + → HttpClientTokenRequestExtensions clones via ProtocolRequest.Clone<T>() |
| 19 | + → Clone() shallow-copies ClientAssertion |
| 20 | + → ProofTokenMessageHandler.SendAsync() retries on DPoP nonce |
| 21 | + → Same ClientAssertion reused on retry ← BUG |
| 22 | +``` |
| 23 | + |
| 24 | +## Affected Code |
| 25 | + |
| 26 | +| File | Package | Role | |
| 27 | +|------|---------|------| |
| 28 | +| `ProofTokenMessageHandler.cs` (L42-44) | oidc-client-extensions | Retries request with same object on DPoP nonce | |
| 29 | +| `ProtocolRequest.cs` (L103) | identity-model | `Clone()` shallow-copies `ClientAssertion` | |
| 30 | +| `ResponseProcessor.cs` (L184) | oidc-client | Sets client assertion once at request creation | |
| 31 | +| `HttpClientTokenRequestExtensions.cs` | identity-model | Calls `Clone()` + `Prepare()` on all token requests | |
| 32 | +| `AuthorizationServerDPoPHandler.cs` | access-token-management | Same retry pattern, same bug | |
| 33 | + |
| 34 | +## Scope |
| 35 | + |
| 36 | +Affects **all token request types** using client assertions + DPoP: |
| 37 | +- Authorization code exchange |
| 38 | +- Refresh token requests |
| 39 | +- Client credentials requests |
| 40 | +- PAR requests |
| 41 | +- Device authorization |
| 42 | + |
| 43 | +## Expected Behavior |
| 44 | + |
| 45 | +Each token request attempt (including DPoP nonce retries) should use a **unique client assertion** with a fresh `jti` and `iat`, per RFC 7521 §4.2. |
| 46 | + |
| 47 | +--- |
| 48 | + |
| 49 | +## Proposed Fix: Factory on `HttpRequestMessage.Options` (Strategy C) |
| 50 | + |
| 51 | +### Approach |
| 52 | + |
| 53 | +Add a `ClientAssertionFactory` (`Func<Task<ClientAssertion>>?`) property to `ProtocolRequest` that flows through `Clone()` → `Prepare()` → `HttpRequestMessage.Options` → handler chain. On DPoP nonce retry, `ProofTokenMessageHandler` checks for the factory, calls it to get a fresh assertion, and rebuilds the form body. |
| 54 | + |
| 55 | +### Why this strategy |
| 56 | + |
| 57 | +- **Backward compatible** — additive only, defaults to null, no behavioral change when unset |
| 58 | +- **Minimal coupling** — the handler only needs to know about `client_assertion` / `client_assertion_type` form fields |
| 59 | +- **Uses standard .NET patterns** — `HttpRequestMessage.Options` is the idiomatic way to pass data through handler chains |
| 60 | +- **Works at the right level** — the factory is set by callers who have access to key material, and consumed by the handler that performs the retry |
| 61 | + |
| 62 | +### Alternatives considered and rejected |
| 63 | + |
| 64 | +| Strategy | Why rejected | |
| 65 | +|----------|-------------| |
| 66 | +| A: Pass factory directly to handler constructor | Handler is a `DelegatingHandler` — can't easily access per-request assertion factories | |
| 67 | +| B: Call `Prepare()` again on retry | `Prepare()` is not idempotent (double-adds parameters) and is synchronous | |
| 68 | +| D: Move retry to callers | Major architectural change, duplicates retry logic across all callers | |
| 69 | + |
| 70 | +### Implementation tasks |
| 71 | + |
| 72 | +#### identity-model |
| 73 | + |
| 74 | +- [ ] Add `Func<Task<ClientAssertion>>? ClientAssertionFactory` property to `ProtocolRequest` |
| 75 | +- [ ] Copy `ClientAssertionFactory` in `Clone<T>()` |
| 76 | +- [ ] Define a public `HttpRequestOptionsKey` for the factory (e.g. `ProtocolRequestOptions.ClientAssertionFactory`) |
| 77 | +- [ ] Store the factory on `HttpRequestMessage.Options` in `RequestTokenAsync()` after `Prepare()` |
| 78 | +- [ ] Also store in `PushAuthorizationAsync()` for PAR flow |
| 79 | +- [ ] Update public API verification snapshot |
| 80 | + |
| 81 | +#### identity-model-oidc-client |
| 82 | + |
| 83 | +- [ ] Update `ProofTokenMessageHandler.SendAsync()` retry path to: |
| 84 | + 1. Check for factory in `request.Options` |
| 85 | + 2. Call factory to get fresh `ClientAssertion` |
| 86 | + 3. Parse existing form body as `IEnumerable<KeyValuePair<string, string>>` (preserving duplicate keys) |
| 87 | + 4. Replace `client_assertion` and `client_assertion_type` values |
| 88 | + 5. Rebuild `FormUrlEncodedContent` |
| 89 | +- [ ] Wire `ClientAssertionFactory` in `ResponseProcessor.RedeemCodeAsync()` |
| 90 | +- [ ] Wire `ClientAssertionFactory` in `OidcClient.RefreshTokenAsync()` |
| 91 | +- [ ] Wire `ClientAssertionFactory` in `AuthorizeClient.PushAuthorizationRequestAsync()` |
| 92 | + |
| 93 | +#### access-token-management |
| 94 | + |
| 95 | +- [ ] Fix `AuthorizationServerDPoPHandler` retry path (same pattern as `ProofTokenMessageHandler`) |
| 96 | +- [ ] Evaluate `ClientCredentialsTokenClient` and `OpenIdConnectUserTokenEndpoint` retry paths — they reuse the same `request.ClientAssertion` value across retries |
| 97 | + |
| 98 | +#### Tests |
| 99 | + |
| 100 | +- [ ] Unit test: `ProofTokenMessageHandler` uses fresh assertion on DPoP nonce retry |
| 101 | +- [ ] Unit test: `Clone<T>()` preserves `ClientAssertionFactory` |
| 102 | +- [ ] Unit test: backward compatibility — no factory set, behavior unchanged |
| 103 | +- [ ] Integration test: end-to-end DPoP + client assertion with nonce enforcement |
| 104 | + |
| 105 | +### Implementation notes |
| 106 | + |
| 107 | +- Form body parsing on retry **must** use `IEnumerable<KeyValuePair<string, string>>`, not a dictionary — dictionaries collapse duplicate keys like `resource` (identified during security review) |
| 108 | +- Both `client_assertion` and `client_assertion_type` should be replaced from the factory result to guard against type mismatches |
| 109 | +- `FormUrlEncodedContent` buffers internally, so `ReadAsStringAsync()` works after the first send |
0 commit comments