Skip to content

feat(sdk): add DPoP client support with HTTP RoundTripper (DSPX-3397)#3581

Draft
dmihalcik-virtru wants to merge 12 commits into
mainfrom
DSPX-3397-platform-go-sdk
Draft

feat(sdk): add DPoP client support with HTTP RoundTripper (DSPX-3397)#3581
dmihalcik-virtru wants to merge 12 commits into
mainfrom
DSPX-3397-platform-go-sdk

Conversation

@dmihalcik-virtru

@dmihalcik-virtru dmihalcik-virtru commented Jun 8, 2026

Copy link
Copy Markdown
Member

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: New DPoPTransport that implements http.RoundTripper
    • Wraps any underlying transport (composable with existing HTTP clients)
    • Generates DPoP proofs for both token endpoint and resource requests
    • Proof claims: jti, htm, htu, iat (always); ath (resource calls only); nonce (when challenged)
    • URI normalization per RFC 9449 (lowercase scheme/host, strip default ports, no query/fragment)

Server-Issued Nonce Support

  • Handles DPoP-Nonce challenges per RFC 9449 §8
  • On 401 with DPoP-Nonce header: cache nonce, regenerate proof, retry once
  • Refresh cached nonces from successful 2xx responses
  • Per-origin nonce cache with thread-safe access

SDK Integration

  • sdk/sdk.go: Wrap HTTP client with DPoP transport during SDK construction
  • New getDPoPJWK() helper to convert ocrypto.RsaKeyPair to jwk.Key
  • NewDPoPHTTPClient() factory for wrapping clients with DPoP support
  • Automatically uses ephemeral EC P-256 key (ES256) when no key provided

Feature Detection

  • sdk/version.go: Add SupportedFeatures() function returning ["dpop", "connectrpc"]
  • Enables xtest integration harness to detect DPoP capability via supports dpop probe

Testing

  • sdk/auth/dpop_transport_test.go: Comprehensive unit tests
    • DPoP proof generation and validation
    • Nonce challenge/retry flow
    • URI normalization edge cases
    • Token endpoint vs resource endpoint behavior
    • Access token hash (ath) verification

Related Work

This PR implements the Go SDK cell of the DPoP feature. Related PRs:

  • tests: xtest integration tests and KC26 upgrade (see xtest/scenarios/DSPX-3397.yaml)
  • platform-service: Server-side DPoP validation middleware
  • java-sdk: Java SDK DPoP client support
  • web-sdk: Web SDK DPoP integration verification

Testing

All tests pass:

go test ./...
go test -run TestREADMECodeBlocks

Linting clean:

golangci-lint run ./...

Notes

  • The existing oauth.go already handles DPoP for token endpoint requests
  • The existing token_adding_interceptor.go already handles DPoP for gRPC/Connect
  • This PR adds the missing piece: DPoP for plain HTTP calls (KAS rewrap, etc.)
  • The RoundTripper pattern is idiomatic Go and composes with any http.Client

Jira: DSPX-3397
Test Scenario: xtest/scenarios/DSPX-3397.yaml

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 273055e1-4dba-47a1-9f3e-2f1a012c1990

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch DSPX-3397-platform-go-sdk

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, 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

  • DPoP Transport Implementation: Introduced DPoPTransport, a new http.RoundTripper implementation that adds RFC 9449 DPoP proof tokens to HTTP requests, including support for server-issued nonce challenges and automatic retries.
  • SDK Integration: Updated the SDK to automatically wrap HTTP clients with DPoP support during construction, ensuring that resource requests are properly signed with DPoP proofs.
  • Feature Detection: Added a SupportedFeatures() function to sdk/version.go to allow integration harnesses to programmatically detect DPoP capability.
  • Testing: Added comprehensive unit tests for DPoP proof generation, nonce challenge flows, and URI normalization to ensure RFC 9449 compliance.
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 Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions github-actions Bot added comp:sdk A software development kit, including library, for client applications and inter-service communicati size/m labels Jun 8, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread sdk/auth/dpop_transport.go Outdated
Comment thread sdk/auth/dpop_transport.go Outdated
Comment thread sdk/auth/dpop_transport.go Outdated
Comment thread sdk/auth/dpop_transport.go Outdated
Comment thread sdk/auth/dpop_transport.go Outdated
Comment thread sdk/auth/dpop_transport.go Outdated
Comment thread sdk/auth/dpop_transport.go
Comment thread sdk/auth/dpop_transport.go Outdated
Comment thread sdk/auth/dpop_transport.go
Comment thread sdk/auth/dpop_transport.go
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 203.008006ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 103.232466ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 423.703616ms
Throughput 236.01 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 50.220444104s
Average Latency 497.588923ms
Throughput 99.56 requests/second

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 270.01327ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 144.386555ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 443.855047ms
Throughput 225.30 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 49.03497246s
Average Latency 487.426932ms
Throughput 101.97 requests/second

@dmihalcik-virtru dmihalcik-virtru force-pushed the DSPX-3397-platform-go-sdk branch from 441af7b to 61316ef Compare June 10, 2026 12:27
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 183.335287ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 101.378765ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 435.240452ms
Throughput 229.76 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 50.575675367s
Average Latency 503.106935ms
Throughput 98.86 requests/second

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 173.859448ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 89.451113ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 443.962682ms
Throughput 225.24 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 49.774071866s
Average Latency 493.399511ms
Throughput 100.45 requests/second

@dmihalcik-virtru dmihalcik-virtru force-pushed the DSPX-3397-platform-go-sdk branch from ebc3e40 to 37ed377 Compare June 11, 2026 17:47
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 189.365241ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 92.807389ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 458.733975ms
Throughput 217.99 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 50.627558901s
Average Latency 502.758805ms
Throughput 98.76 requests/second

@dmihalcik-virtru dmihalcik-virtru force-pushed the DSPX-3397-platform-go-sdk branch from 37ed377 to b9dd8d8 Compare June 15, 2026 18:23
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 174.336781ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 87.925096ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 451.175969ms
Throughput 221.64 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 49.075520706s
Average Latency 488.0126ms
Throughput 101.88 requests/second

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 190.019238ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 100.783852ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 436.996106ms
Throughput 228.83 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 50.277491194s
Average Latency 498.407616ms
Throughput 99.45 requests/second

@dmihalcik-virtru dmihalcik-virtru force-pushed the DSPX-3397-platform-go-sdk branch from 5c4f57a to 4cec258 Compare June 18, 2026 13:08
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 175.765414ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 93.303736ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 446.045553ms
Throughput 224.19 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 49.725004344s
Average Latency 495.004232ms
Throughput 100.55 requests/second

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 193.716094ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 106.047078ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 418.095248ms
Throughput 239.18 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 50.244870901s
Average Latency 499.255302ms
Throughput 99.51 requests/second

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 181.707776ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 99.144481ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 676.855143ms
Throughput 147.74 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 51.8725193s
Average Latency 515.398407ms
Throughput 96.39 requests/second

dmihalcik-virtru and others added 6 commits June 23, 2026 15:19
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>
@dmihalcik-virtru dmihalcik-virtru force-pushed the DSPX-3397-platform-go-sdk branch from eb823ac to a78a68f Compare June 23, 2026 19:19
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 204.207666ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 105.340164ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 424.628912ms
Throughput 235.50 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 55.161611536s
Average Latency 548.394413ms
Throughput 90.64 requests/second

@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 196.01329ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 105.113176ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 423.056849ms
Throughput 236.37 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 52.37219149s
Average Latency 521.104914ms
Throughput 95.47 requests/second

bufferRequestBody previously skipped buffering whenever req.GetBody was
already set, which is always true for ConnectRPC unary calls. That left
the DPoP-nonce retry (and net/http's own connection-reuse retry) relying
on ConnectRPC's GetBody, which hands back a single shared *payloadCloser
with a mutable read offset.

When net/http reuses a keep-alive connection the server has since closed
(the auth interceptor returns the 401 DPoP-Nonce challenge without
draining the request body), its internal rewind-and-retry races on that
shared offset and presents an empty body, surfacing intermittently under
load as 'net/http: HTTP/1.x transport connection broken: http:
ContentLength=N with Body length 0'.

Buffer unary bodies (ContentLength >= 0) into an SDK-owned immutable
[]byte and install a GetBody factory that returns an independent
bytes.Reader per call, removing the shared mutable state. Streaming /
unknown-length requests (ContentLength < 0) are left untouched so
io.Pipe bodies are not drained.

Confirmed by a behavior-preserving probe: ConnectRPC's GetBody returned
the full payload on every retry (2880/2880, including the failing one)
while net/http read 0 bytes from the handed-off body on the failure;
no Go data race. ~13.7k retries at 48-64x concurrency: zero drops
(reliably 1-5 per 2880 before).

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru dmihalcik-virtru force-pushed the DSPX-3397-platform-go-sdk branch from 53958c4 to cece089 Compare June 24, 2026 17:14
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 210.306956ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 105.372832ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 441.691773ms
Throughput 226.40 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 52.593351039s
Average Latency 522.520518ms
Throughput 95.07 requests/second

… (DSPX-3397)

Server-side DPoP validation only attached a WWW-Authenticate header for
DPoPNonceError (use_dpop_nonce). Every other proof rejection (tampered htu,
replayed jti, malformed nonce, bad ath, wrong htm) returned a bare 401 with
no challenge header, so RFC 9449 §7.1-compliant clients/tests could not tell
a DPoP failure from an unrelated 401.

This made the opentdf/tests xtest negative cases unreliable: tampered_htu
failed always (htu is checked before nonce), replayed_jti was flaky (gated on
whether the cached nonce had rotated, deciding whether the nonce or jti check
fired first), and tampered_nonce failed whenever a non-nonce check tripped first.

Add a DPoPProofError marker type (delegating Error, Unwrap), wrap all non-nonce
validateDPoP errors with it in checkToken, and have both MuxHandler and
ConnectAuthNInterceptor emit `WWW-Authenticate: DPoP error="invalid_dpop_proof"`
(plus a fresh DPoP-Nonce when require_nonce is on). The use_dpop_nonce path is
unchanged.

Add unit tests covering the error type, the malformed-nonce wrap contract, and
the challenge headers for both handlers across nonce-on/off.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 199.867681ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 103.99002ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 631.897472ms
Throughput 158.25 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 51.390015195s
Average Latency 511.424211ms
Throughput 97.30 requests/second

…SPX-3397)

Enforcement lived at the top of the auth block (server.auth.enforceDPoP) while
every other DPoP knob is nested under server.auth.dpop. Consolidate it: add
server.auth.dpop.enforce and deprecate the old top-level field.

The old field keeps working during the migration window. Both are defaulted
bools, so mapstructure cannot distinguish "explicitly false" from "unset";
enforcement therefore uses OR semantics via a new dpopEnforced() helper
(DPoP.Enforce || EnforceDPoP), and config validation warns when the deprecated
field is set.

- config.go: add DPoPConfig.Enforce, deprecate AuthNConfig.EnforceDPoP, add
  dpopEnforced(); update validateAuthNConfig warnings.
- authn.go: NewAuthenticator uses cfg.dpopEnforced().
- server.go: warning strings reference server.auth.dpop.enforce.
- example configs + docs/Configuring.md: use the nested dpop.enforce form;
  keep testdata/all-no-config.yaml on the legacy key for back-compat coverage.
- tests: migrate to DPoP.Enforce and add TestDPoPEnforcement_Migration.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 204.875519ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 107.176976ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 427.312364ms
Throughput 234.02 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 50.597664953s
Average Latency 503.256153ms
Throughput 98.82 requests/second

The DPoP nonce challenge only applies to DPoP-bound requests; without enforcement
a plain Bearer token bypasses DPoP validation and never sees a challenge. When
dpop-challenge-enabled is set, also set server.auth.dpop.enforce alongside
require_nonce in both start actions.

The flag only ever turns enforcement on: start-additional-kas uses
with(select(...)) so it never writes enforce: false (preserving any base value),
and start-up-with-containers sets it inside the step already gated on the flag.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 208.35099ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 102.447001ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 449.700471ms
Throughput 222.37 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 51.15554256s
Average Latency 507.661328ms
Throughput 97.74 requests/second

DPoP enforcement and the nonce-challenge flow are separate concerns. Replace the
coupling (where dpop-challenge-enabled also set server.auth.dpop.enforce) with a
dedicated dpop-enforce-required input (default false) that drives enforcement on
its own. dpop-challenge-enabled again sets only require_nonce.

The enforce knob only ever turns enforcement on: start-additional-kas uses
with(select(...)) keyed on DPOP_ENFORCE_REQUIRED, and start-up-with-containers
sets it in a new step gated on the flag, so enforce: false is never written.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@github-actions

Copy link
Copy Markdown
Contributor
Benchmark results, click to expand

Benchmark authorization.GetDecisions Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 173.807729ms

Benchmark authorization.v2.GetMultiResourceDecision Results:

Metric Value
Approved Decision Requests 1000
Denied Decision Requests 0
Total Time 93.150997ms

Benchmark Statistics

Name № Requests Avg Duration Min Duration Max Duration

Bulk Benchmark Results

Metric Value
Total Decrypts 100
Successful Decrypts 100
Failed Decrypts 0
Total Time 418.356802ms
Throughput 239.03 requests/second

TDF3 Benchmark Results:

Metric Value
Total Requests 5000
Successful Requests 5000
Failed Requests 0
Concurrent Requests 50
Total Time 49.481393443s
Average Latency 491.218671ms
Throughput 101.05 requests/second

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Govulncheck found vulnerabilities ⚠️

The following modules have known vulnerabilities:

  • examples
  • otdfctl
  • sdk
  • service
  • lib/fixtures
  • tests-bdd

See the workflow run for details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp:sdk A software development kit, including library, for client applications and inter-service communicati size/m

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant