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 6 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 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.
| if t.Base == nil { | ||
| t.Base = http.DefaultTransport | ||
| } | ||
|
|
||
| if t.nonceCache == nil { | ||
| t.nonceMu.Lock() | ||
| if t.nonceCache == nil { | ||
| t.nonceCache = make(map[string]string) | ||
| } | ||
| t.nonceMu.Unlock() | ||
| } |
There was a problem hiding this comment.
Concurrency Data Race on t.Base and t.nonceCache
Concurrently modifying t.Base and performing a double-checked lock read on t.nonceCache without synchronization can lead to data races when multiple goroutines use the same DPoPTransport instance.
To resolve this:
- Use a local variable
baseinstead of modifying the struct fieldt.Base. - Perform the initialization of
t.nonceCacheunder an unconditional lock to avoid the data race on the initial read.
base := t.Base
if base == nil {
base = http.DefaultTransport
}
t.nonceMu.Lock()
if t.nonceCache == nil {
t.nonceCache = make(map[string]string)
}
t.nonceMu.Unlock()| nonce := t.getCachedNonce(origin) | ||
|
|
||
| // Generate and add DPoP proof | ||
| if err := t.addDPoPProof(req2, nonce, isTokenRequest); err != nil { |
| } | ||
|
|
||
| // Make the request | ||
| resp, err := t.Base.RoundTrip(req2) |
| req3 := cloneRequest(req) | ||
|
|
||
| // Regenerate proof with nonce | ||
| if err := t.addDPoPProof(req3, newNonce, isTokenRequest); err != nil { |
There was a problem hiding this comment.
| } | ||
|
|
||
| // addDPoPProof generates and adds DPoP proof to the request headers. | ||
| func (t *DPoPTransport) addDPoPProof(req *http.Request, nonce string, isTokenRequest bool) error { |
There was a problem hiding this comment.
Update the signature of addDPoPProof to accept the base transport.
| func (t *DPoPTransport) addDPoPProof(req *http.Request, nonce string, isTokenRequest bool) error { | |
| func (t *DPoPTransport) addDPoPProof(req *http.Request, base http.RoundTripper, nonce string, isTokenRequest bool) error { |
| nonceMu sync.RWMutex | ||
| // nonceCache stores server-issued nonces by origin (scheme://host:port) | ||
| nonceCache map[string]string |
There was a problem hiding this comment.
Optimize Token Endpoint URL Parsing (Struct Fields)
Add cached fields for the parsed token endpoint URL to avoid parsing it on every request.
nonceMu sync.RWMutex
// nonceCache stores server-issued nonces by origin (scheme://host:port)
nonceCache map[string]string
cachedTokenURL *url.URL
cachedTokenURLStr string
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:
|
Implements RFC 9449 DPoP (Demonstrating Proof-of-Possession) for the Go SDK: - Add DPoPTransport as an http.RoundTripper that wraps any transport - Generate ES256/RS256 proofs with jti, htm, htu, iat claims for all requests - Add ath claim (access token hash) for resource endpoint calls - Handle server-issued DPoP-Nonce challenges with automatic retry - Cache nonces per-origin and refresh from successful responses - Normalize URIs per RFC 9449 (lowercase scheme/host, strip default ports) - Integrate into SDK's HTTP client construction via NewDPoPHTTPClient - Add SupportedFeatures() function for xtest feature detection All requests through the SDK now include DPoP proofs when credentials are configured. Token endpoint requests omit the ath claim; resource requests include both Authorization: DPoP <token> header and the DPoP proof header. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…PX-3397) Exposes DPoP algorithm/key selection via CLI flags on `otdfctl encrypt` and `otdfctl decrypt`, supporting ES256 (default), ES384, ES512, RS256, RS384, and RS512. Bare `--dpop` defaults to ES256 per RFC 9449 §4.2. `--dpop-key <path>` loads a PEM private key (algorithm inferred from key type). Both flags can be combined to override the inferred algorithm. SDK changes: - Add sdk/dpop_key.go: generateDPoPKeyForAlg, loadDPoPKeyFromPEM, resolveDPoPKey helpers - Add WithDPoPAlgorithm, WithDPoPKeyPEM, WithDPoPJWK SDK options - Thread custom JWK through buildIDPTokenSource and DPoPTransport setup; falls back to auto-generated RSA when no custom key is configured - Add JWK-accepting token source constructors for all four source types otdfctl changes: - Register --dpop (NoOptDefVal="ES256") and --dpop-key flags on encrypt/decrypt; update man docs accordingly - handlers.WithExtraSDKOpts appends (not replaces) SDK options - common.NewHandler accepts variadic extraSDKOpts (backward compatible) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Fixes critical and high-priority issues identified in PR review: - Fix request body consumed on retry: reset body using GetBody() before retrying - Fix data races: use local base variable instead of modifying t.Base - Fix nonce cache initialization: unconditional lock instead of double-checked lock - Fix missing HTTP client for token source: pass client with base transport to preserve custom configs - Optimize token endpoint URL parsing: cache parsed URL to avoid parsing on every request - Normalize origin casing: lowercase origin in cache to ensure consistent hits on uppercase URLs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
- Fix errcheck: use comma-ok for type assertion in dpop_transport_test.go - Fix govet shadow: rename inner ok vars (isStr, athOK, jtiOK) to avoid shadowing outer ok declaration in TestDPoPTransport_AddsProofToRequests - Fix nestif in RoundTrip: extract 401 nonce-retry into retryWithNonce method - Fix nestif in sdk.go New: extract DPoP key selection into pickDPoPKey helper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
ConnectRPC/gRPC clients set req.Body and ContentLength but not req.GetBody. After the initial round trip consumed the body, the DPoP-Nonce retry path would clone the original request, inherit the exhausted reader, and net/http would abort with "ContentLength=N with Body length 0". Buffer the body on the request clone and install GetBody so the retry path can replay it. Mutates only the clone, preserving the http.RoundTripper contract. Adds a regression test that exercises the retry path with a POST body and no GetBody — the scenario every otdfctl ConnectRPC call hits when the platform has server.auth.dpop.require_nonce enabled. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Covers the production path that broke every body-bearing otdfctl call when the platform enables the DPoP-Nonce challenge (RFC 9449 §8): Connect-go's payloadCloser must replay the request body on the nonce retry, otherwise net/http aborts with 'ContentLength=N with Body length 0'. The existing TestDPoPTransport_NonceRetryReplaysBodyWithoutGetBody exercises the raw http.Request path. This new test drives a real kasconnect unary client through DPoPTransport against a stub that returns 401 + DPoP-Nonce, then asserts the two captured request bodies are byte-for-byte equal — the production failure mode that the raw-http test cannot reach. Confirmed it fails (with the exact 'ContentLength=15 with Body length 0' error) when the body-replay branch in retryWithNonce is reverted. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
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:
|
|
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.
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.yaml