feat(sdk): add DPoP client support with HTTP RoundTripper (DSPX-3397)#3581
feat(sdk): add DPoP client support with HTTP RoundTripper (DSPX-3397)#3581dmihalcik-virtru wants to merge 35 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds DPoP configuration, transport, and SDK/client wiring for token-bound requests, plus CLI flag plumbing and host-origin normalization in service auth. ChangesDPoP Support
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request implements RFC 9449 DPoP (Demonstrating Proof-of-Possession) client support for the OpenTDF Go SDK. By introducing a custom HTTP RoundTripper, the SDK can now generate and attach DPoP proofs to HTTP requests, handle server-side nonce challenges, and perform URI normalization. This work is a key component of the broader Keycloak v26 upgrade, ensuring secure, proof-of-possession-based authentication for HTTP-based interactions within the platform. Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. The proof is shown in token light, With DPoP we do it right. No replay here, the nonce is set, A secure path for the internet. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces RFC 9449 DPoP (Demonstrating Proof-of-Possession) support to the SDK by adding a new DPoPTransport and integrating it into the client setup. The code review identified several critical and high-severity issues in the transport implementation, including a potential bug where request bodies are consumed and not reset on retry, concurrency data races on shared fields like t.Base and t.nonceCache, and the bypass of custom transport configurations when retrieving access tokens. Additionally, optimizations were suggested to cache parsed token endpoint URLs and normalize URL origins to lowercase to prevent cache misses.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
441af7b to
61316ef
Compare
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
ebc3e40 to
37ed377
Compare
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
37ed377 to
b9dd8d8
Compare
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
5c4f57a to
4cec258
Compare
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
normalizeURI stripped default ports with strings.HasSuffix on the raw host, which is brittle around IPv6 literals (e.g. [fe80::443]). Use u.Hostname()/u.Port() compared against the scheme default instead, via a shared normalizedHostPort helper reused by getOrigin so the nonce-cache key and htu claim normalize consistently. isTokenEndpointRequest now compares normalized URIs so an explicit :443 matches a portless endpoint. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Parse the request Host with url.Hostname()/Port() instead of trimming a ":443"/":80" suffix, so IPv6 literals and hosts that merely end in the default port are handled correctly. Lowercase the host to match the SDK's htu normalization, since the DPoP htu match is an exact string comparison. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Wrap the caller-supplied oauth2.TokenSource in oauth2.ReuseTokenSource so a valid token is reused across calls instead of re-fetched. AccessToken is on the request hot path (once per gRPC/Connect call, plus once more to compute the DPoP ath claim), so an uncached source risked an IdP round-trip on every request. Wrapping an already-caching source is harmless. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
- transport: fall through on successful nonce retry so a rotated DPoP-Nonce is cached instead of dropped by the early return - transport: normalize htu with EscapedPath to preserve percent-encoded bytes - dpop_key: reject public-only keys, ES*/RS* family mismatches, and EC curve/alg mismatches at resolution time via validateDPoPKey - sdk: surface conflicting exchange config before the uncredentialed fast-path - sdk/otdfctl: apply DPoP options during credential validation via NewDPoPValidationHTTPClient so pre-flight token requests carry a proof Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
The condition logic is intentionally written to be easily understood by readers: don't append the port if it matches the default for its scheme. De Morgan transformation would make this less readable. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
- dpop_transport: don't retry nonce challenge when body is non-replayable (streaming/unknown-length body consumed on first attempt); cache the nonce and return the original 401 instead of resending an empty body - sdk: default to an ephemeral ES256/P-256 DPoP key instead of RSA when none is configured, matching documented behavior - dpop_key_test: add regression case for RSA PEM overridden to ES256 Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Replace the raw string parameter in WithDPoPAlgorithm with a named SigningAlgorithm type (RFC 7518 §3.1) and export the six supported constants (ES256, ES384, ES512, RS256, RS384, RS512) from the sdk package. This guides correct use via IDE autocomplete and prevents untyped string variables from being passed without an explicit cast. Runtime validation via generateDPoPKeyForAlg's default error case is unchanged. Remove the private dpopAlgES*/RS* string constants from dpop_key.go in favour of the exported type. Update the otdfctl CLI caller to cast the flag string. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
bufferRequestBody replaced io.ReadAll with a ContentLength-pre-sized bytes.Buffer read, collapsing io.ReadAll's grow-by-doubling into a single exact allocation. Behavior is unchanged (ReadFrom still reads to EOF); this only trims allocation churn on the DPoP replay-buffering path. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…X-3397) The SDK already enables DPoP by default for any authenticated client, so encrypt, decrypt, and all policy CRUD commands are sender-constrained without a flag. Remove the redundant --dpop/--dpop-key flags and their plumbing (dpopSDKOpts, NewHandlerWith, WithExtraSDKOpts, the extra-opts credential-validation path) and the flag docs. Align the pre-flight credential validation with the real client: when no DPoP key is configured, NewDPoPValidationHTTPClient now defaults to an ephemeral ES256 key so the validation token request is DPoP-bound against a DPoP-enforcing token endpoint. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…PX-3397) Surface sdk.SupportedFeatures() in the otdfctl `--version --json` output as `supported_features`, and add the `dpop_nonce_challenge` feature (the DPoP transport handles RFC 9449 §8 server-issued nonce challenges). This gives the xtest harness a machine-readable capability signal instead of grepping `--dpop` from help text. The human `--version` string is unchanged. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
The default DPoP key is an ephemeral ES256 (P-256) key, not an auto-generated RSA key as the comment previously stated.
- retry DPoP-Nonce challenges on token-endpoint 400, not just 401, so the otdfctl validation client works against a nonce-required token endpoint - normalize the Host header in the Connect interceptor via originFromHost so the server's htu comparison matches the SDK's normalized signing input - preserve the configured HTTP client timeout on the transport's internal access-token fetch so a hung IdP cannot stall a request indefinitely - return an error (was: warn) when DPoP is configured without credentials, instead of silently building an unbound client - otdfctl: print-access-token returns a non-sender-constrained bearer token for backward compatibility; credential validation keeps the DPoP-bound path. Reusable DPoP export tracked in DSPX-3998 Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…o-creds - token-endpoint 400+DPoP-Nonce triggers a single retry; plain 400 does not - NewDPoPHTTPClient propagates the base client timeout to the token fetch - Connect interceptor normalizes a mixed-case host with :443 for htu matching - buildIDPTokenSource errors when DPoP is set without credentials Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
- NewDPoPHTTPClient validates the token endpoint and returns an error for an unparseable value instead of silently misclassifying token requests - guard a nil DPoPKey in RoundTrip so the zero value returns an error rather than nil-panicking - initialize the nonce cache in the constructor and guard direct construction with sync.Once, removing the per-request write-lock - tests for the nil-key guard and invalid-endpoint rejection Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
- add SigningAlgorithm.Valid backed by a single validDPoPAlgs slice; derive the dpopAllowedAlgs error string from it so the enforced set cannot drift - validateDPoPKey and the PEM algorithm override now consult Valid; the override rejects non-DPoP algorithms (e.g. HS256, none) instead of only jwa.Accept - drop stale 'auto-generated' note from resolveDPoPKey; clarify PEM inference is only when unset; remove redundant WithDPoPKeyPEM line and rot-prone otdfctl ref Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Log at debug when originFromHost/normalizeURL fall back to an unnormalized value so an otherwise inexplicable htu mismatch is traceable. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…y PEM - parameterize the proof happy-path over RS256/ES256/ES384 so the default EC signing path is covered (was RSA-only) - parseDPoPProof verifies with the key's own algorithm and uses assert+return instead of require, which is unsafe off the test goroutine - assert the OAuth token source dpopPEM is public key material, guarding the private-to-public fix against regression Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…riant Restore the exported GetTokenWithClientCreds to its original 6-parameter signature (plain bearer token, matching main) so the change is not a breaking Go API change. Add GetTokenWithClientCredsDPoP for the DPoP-bound credential-validation path; both delegate to an unexported getTokenWithClientCreds helper. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
b63168c to
a3e9d1e
Compare
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Benchmark results, click to expandBenchmark authorization.GetDecisions Results:
Benchmark authorization.v2.GetMultiResourceDecision Results:
Benchmark Statistics
Bulk Benchmark Results
TDF3 Benchmark Results:
|
|
Summary
Implements RFC 9449 DPoP (Demonstrating Proof-of-Possession) client support for the OpenTDF Go SDK.
This PR is part of the larger Keycloak v26 upgrade and comprehensive DPoP support feature tracked in DSPX-3397.
e2e Test
Changes
DPoP RoundTripper Implementation
sdk/auth/dpop_transport.go: NewDPoPTransportthat implementshttp.RoundTripperjti,htm,htu,iat(always);ath(resource calls only);nonce(when challenged)Server-Issued Nonce Support
DPoP-Noncechallenges per RFC 9449 §8401withDPoP-Nonceheader: cache nonce, regenerate proof, retry once2xxresponsesSDK Integration
sdk/sdk.go: Wrap HTTP client with DPoP transport during SDK constructiongetDPoPJWK()helper to convertocrypto.RsaKeyPairtojwk.KeyNewDPoPHTTPClient()factory for wrapping clients with DPoP supportFeature Detection
sdk/version.go: AddSupportedFeatures()function returning["dpop", "connectrpc"]supports dpopprobeTesting
sdk/auth/dpop_transport_test.go: Comprehensive unit testsath) verificationRelated Work
This PR implements the Go SDK cell of the DPoP feature. Related PRs:
xtest/scenarios/DSPX-3397.yaml)Testing
All tests pass:
Linting clean:
Notes
oauth.goalready handles DPoP for token endpoint requeststoken_adding_interceptor.goalready handles DPoP for gRPC/Connecthttp.ClientJira: DSPX-3397
Test Scenario:
xtest/scenarios/DSPX-3397.yamlSummary by CodeRabbit
--dpopand--dpop-keyflags to encrypt and decrypt commands.