Skip to content

feat(sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397)#374

Open
dmihalcik-virtru wants to merge 43 commits into
mainfrom
DSPX-3397-java-sdk
Open

feat(sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397)#374
dmihalcik-virtru wants to merge 43 commits into
mainfrom
DSPX-3397-java-sdk

Conversation

@dmihalcik-virtru

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

Copy link
Copy Markdown
Member

Summary

Adds comprehensive DPoP (RFC 9449) support to the Java SDK as part of the Keycloak v26 upgrade and DPoP implementation effort.

Related Jira: https://virtru.atlassian.net/browse/DSPX-3397
Test Scenario: xtest/scenarios/DSPX-3397.yaml (in tests repo)
Green e2e Test Run: https://github.com/opentdf/tests/actions/runs/29624686479

Changes

Core DPoP proof generation

  • TokenSource: Generates RFC 9449-compliant DPoP proofs via Nimbus DefaultDPoPProofFactory, with claims jti, htm, htu, iat (plus ath for resource endpoints).
  • htu claim strips query and fragment per RFC 9449 §4.2.
  • Token scheme (DPoP vs Bearer) modeled as an enum; token + scheme read atomically to avoid a refresh-time data race.

Server-issued nonce handling (full retry — implemented)

  • Token endpoint (§8.2): On use_dpop_nonce, retries the token request once with the AS-provided nonce.
  • Resource server (§9): okhttp-level interceptor retries once with the DPoP-Nonce from a WWW-Authenticate challenge; drops the stale proof header on the retry.
  • Per-origin nonce cache (keyed by scheme/host/port, with default-port normalization); nonces cached from responses regardless of status.

Key configuration & validation

  • SDKBuilder: dpopKey(...) accepts a caller-supplied RSA or EC key; auto-generates an ephemeral key if none provided. An EC DPoP key triggers a separate RSA-2048 SRT key.
  • DpopKeyValidation (new): central validation — rejects public-only JWKs, enforces key-type/algorithm compatibility, infers the EC algorithm from the curve.
  • Connect-GET disabled on the authenticated client to protect the htm claim.

Bearer fallback

  • Falls back to Bearer scheme when the AS returns a non-DPoP-bound token, with a warning; stale DPoP proof is stripped so a Bearer request never carries a DPoP header.
  • Fails loudly when DPoP is requested but the well-known omits platform_issuer.

CLI (cmdline)

DPoP is always-on for any authenticated client, so the CLI needs no DPoP flags:

  • The SDK auto-generates a DPoP key when none is configured, so encrypt, decrypt, and metadata commands are sender-constrained without any opt-in.
  • No --dpop / --dpop-key flags are exposed (an earlier revision added them; they were removed together with CliDpopOptions since they served no purpose once DPoP is unconditional). This mirrors the go-sdk direction in feat(sdk): add DPoP client support with HTTP RoundTripper (DSPX-3397) platform#3581, which likewise dropped otdfctl's --dpop parameters.
  • Feature detection via the supports subcommand (no help-text scraping required):
    • tdf supports <feature> exits 0 if supported, 1 otherwise.
    • tdf supports with no argument lists all supported feature names, one per line.
    • --json emits a JSON object mapping feature name → boolean, e.g. {"dpop":true} or, with no argument, {"dpop":true,"dpop_nonce_challenge":true}.

Feature Detection

tdf supports dpop                   # exits 0 if supported
tdf supports dpop_nonce_challenge   # exits 0 if supported
tdf supports                        # lists supported features, one per line
tdf supports --json                 # {"dpop":true,"dpop_nonce_challenge":true}
tdf supports dpop --json            # {"dpop":true}

Testing

New coverage: TokenSourceTest, DPoPRetryInterceptorTest, AuthInterceptorConnectPathTest (Kotlin), and CommandTest — covering nonce caching/origin isolation, both retry paths, EC/RSA key handling, and the supports subcommand (bare exit codes, no-arg listing, and --json output).

Related PRs

Checklist

  • DPoP proof generation (htm/htu/iat/jti/ath)
  • Per-origin nonce caching
  • Full 401/nonce retry — token endpoint (§8.2) and resource server (§9)
  • RSA and EC DPoP keys; caller-supplied or ephemeral
  • Central DPoP key validation
  • Bearer fallback for non-DPoP-bound tokens
  • Always-on DPoP; no CLI flags needed (removed --dpop/--dpop-key + CliDpopOptions)
  • supports feature detection: bare exit code, no-arg listing, --json
  • xtest integration tests (pending tests repo coordination — fix(xtest): detect java dpop support via supports subcommand tests#558)

Summary by CodeRabbit

  • New Features
    • Added a supports CLI subcommand (plain list or --json feature support results with canonical casing).
    • Added -v/--verbose for more detailed error output.
    • Improved DPoP handling: configurable DPoP keys/algorithms, conditional DPoP headers, nonce caching, and single-retry behavior for use_dpop_nonce (with correct Bearer downgrade).
    • Added stricter validation and clearer errors for missing CLI credentials and incompatible DPoP configuration.
  • Documentation
    • Updated build instructions for setting JAVA_HOME via Homebrew.
  • Testing / Build
    • Added/expanded CLI and SDK test coverage and explicit test dependencies for Java/Kotlin.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The CLI adds feature reporting, verbose diagnostics, and explicit credential validation. The SDK adds configurable DPoP keys and algorithms, nonce-aware token handling, bearer fallback, interceptor retries, and expanded authentication-flow tests.

Changes

CLI behavior and build setup

Layer / File(s) Summary
CLI commands and validation
cmdline/src/main/java/io/opentdf/platform/Command.java
Adds supports, verbose logging, explicit credential checks, and updated assertion-key parsing.
CLI execution and coverage
cmdline/src/main/java/io/opentdf/platform/TDF.java, cmdline/src/main/resources/log4j2.xml, cmdline/src/test/..., cmdline/pom.xml, AGENTS.md
Adds formatted execution errors, normalizes logging, documents Java setup, and tests CLI behavior.

SDK DPoP authentication

Layer / File(s) Summary
DPoP validation and SDK configuration
sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java, SDKBuilder.java, SDKBuilderTest.java, sdk/pom.xml
Adds DPoP key validation, configurable builder methods, SRT key resolution, issuer validation, client wiring, and build coverage.
Token schemes and nonce-aware token source
sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java, TokenSourceTest.java
Supports DPoP and Bearer authorization, origin-scoped nonce caching, normalized proofs, token nonce retries, atomic token state, and validation errors.
Interceptor retries and service wiring
sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt, sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java, sdk/src/test/...
Adds response nonce caching, one-shot DPoP nonce retries, conditional headers, Connect-path handling, and retry-interceptor propagation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SDKBuilder
  participant TokenSource
  participant AuthInterceptor
  participant ResourceServer
  SDKBuilder->>TokenSource: configure DPoP key and algorithm
  AuthInterceptor->>TokenSource: request authorization headers
  TokenSource-->>AuthInterceptor: Authorization and optional DPoP
  AuthInterceptor->>ResourceServer: send authenticated request
  ResourceServer-->>AuthInterceptor: 401 use_dpop_nonce with nonce
  AuthInterceptor->>TokenSource: cache nonce and refresh headers
  AuthInterceptor->>ResourceServer: retry request once
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: mkleene

Poem

I’m a rabbit with keys in my pack,
Sending nonce-bound proofs on the track.
Bearer or DPoP, headers align,
One retry hops through the design.
The CLI now speaks features with cheer—
And verbose logs make the path clear!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main SDK change: comprehensive DPoP support, and is specific enough for history scanning.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch DSPX-3397-java-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 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 support for custom DPoP keys in the SDKBuilder and implements DPoP nonce caching in TokenSource and AuthInterceptor to handle server-issued nonces. It also adds a new "supports" subcommand to the CLI to check for feature support (e.g., "dpop"). Regarding the feedback, the "supports" subcommand should avoid calling System.exit() directly, as this abruptly terminates the JVM and hinders testing; instead, it should implement Callable and return the exit code.

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 cmdline/src/main/java/io/opentdf/platform/Command.java Outdated
@github-actions

Copy link
Copy Markdown
Contributor

X-Test Failure Report

@github-actions

Copy link
Copy Markdown
Contributor

@dmihalcik-virtru dmihalcik-virtru changed the title feat(java-sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397) feat(java-sdk): DSPX-3397 Comprehensive DPoP (RFC 9449) support Jun 12, 2026
@dmihalcik-virtru dmihalcik-virtru changed the title feat(java-sdk): DSPX-3397 Comprehensive DPoP (RFC 9449) support feat(sdk): DSPX-3397 Comprehensive DPoP (RFC 9449) support Jun 12, 2026
@dmihalcik-virtru dmihalcik-virtru changed the title feat(sdk): DSPX-3397 Comprehensive DPoP (RFC 9449) support feat(sdk): add comprehensive DPoP (RFC 9449) support (DSPX-3397) Jun 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

X-Test Failure Report

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

X-Test Failure Report

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

dmihalcik-virtru and others added 3 commits July 13, 2026 13:34
- Add server-issued nonce caching infrastructure in TokenSource with per-origin storage
- Add dpopKey() method to SDKBuilder for caller-supplied RSA keys (defaults to auto-generated ephemeral key)
- Update AuthInterceptor to cache DPoP-Nonce from successful responses
- Add 'supports dpop' CLI command for xtest feature detection
- Extend TokenSource.getAuthHeaders() to accept optional nonce parameter for proof generation

Implementation uses Nimbus OAuth2 SDK's DefaultDPoPProofFactory for RFC 9449 compliant
DPoP proof generation with htm/htu/iat/jti claims (plus ath for resource endpoints).

Current implementation uses RSA-2048/RS256 for DPoP keys. The SDK already had DPoP proof
generation via Nimbus OAuth2 SDK; this PR adds nonce support infrastructure and makes
the DPoP key configurable.

Note: Full 401 retry logic with nonce challenges requires Connect RPC interceptor changes
and is deferred to future work. Nonce caching infrastructure is in place for when retry
logic is added.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
- AuthInterceptor.kt: fix resp.code→resp.status and resp.message.request()
  compilation errors; use ThreadLocal<URL> to thread request URL into
  responseFunction for nonce caching; change private→internal so
  SDKBuilder.java can access dpopRetryInterceptor(); add dpopRetryInterceptor()
  OkHttp interceptor that caches DPoP-Nonce and retries 401 once
- TokenSource.java: wrap nonce String as new Nonce(nonce) to match
  DefaultDPoPProofFactory.createDPoPJWT signature; generalize RSAKey to
  JWK+JWSAlgorithm to support EC keys for ES256/ES384/ES512
- SDKBuilder.java: update to JWK+JWSAlgorithm, separate SRT key from DPoP
  key (EC DPoP key auto-generates RSA for SRT), wire dpopRetryInterceptor
  into OkHttpClient for KAS and all platform services; add dpopAlgorithm()
  builder method
- Add TokenSourceTest and DPoPRetryInterceptorTest (6 new tests)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Add DPoP configuration flags to the tdf cmdline tool (shared by encrypt
and decrypt via buildSDK()):
- --dpop[=<alg>]: enable DPoP; optional algorithm (RS256, RS384, RS512,
  ES256, ES384, ES512); defaults to RS256; generates ephemeral key
- --dpop-key <path>: use PEM-encoded private key from file; algorithm
  inferred from key type (EC or RSA); combinable with --dpop=<alg>

Both flags work for encrypt and decrypt subcommands. Help text contains
"dpop" so the grep probe matches: encrypt --help | grep -i dpop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

Drops orphaned imports that SonarCloud (java:S1128) reported:
- SDKBuilder: com.nimbusds.jose.jwk.Curve, com.nimbusds.jose.jwk.gen.ECKeyGenerator
- TokenSource: com.nimbusds.jose.jwk.RSAKey
@github-actions

Copy link
Copy Markdown
Contributor

`tdf supports` with no argument now prints the supported feature names,
one per line. A new --json flag emits a JSON object mapping feature name
to a boolean (e.g. {"dpop":true}); with no argument it lists all
supported features as true. Exit codes are unchanged: 0 when a queried
feature is supported, 1 otherwise.
@github-actions

Copy link
Copy Markdown
Contributor

@dmihalcik-virtru
dmihalcik-virtru requested a review from Copilot July 14, 2026 16:38
dmihalcik-virtru added a commit to opentdf/tests that referenced this pull request Jul 14, 2026
## Problem

The java-sdk removed the user-facing `--dpop` / `--dpop-key` CLI flags
in opentdf/java-sdk#374. DPoP is now always-on by default, and
capability is exposed through the `supports` subcommand instead.

The Java wrapper's `supports dpop` case still detects DPoP by grepping
`help encrypt` for the `--dpop` flag:

```bash
dpop)
  set -o pipefail
  java -jar "$SCRIPT_DIR"/cmdline.jar help encrypt | grep -iE -- '--dpop'
  exit $?
  ;;
```

With the flags gone this grep finds nothing, exits 1, and xtest skips
java DPoP tests:

```
SKIPPED [2] tdfs.py:619: java@... sdk doesn't yet support [dpop]
```

The sibling `dpop_nonce_challenge` case already delegates to the
`supports` subcommand and is unaffected.

## Fix

Detect `dpop` the same way `dpop_nonce_challenge` already does — via the
`supports` subcommand, whose exit code is the capability contract (0 =
supported, 1 = not):

```bash
dpop)
  java -jar "$SCRIPT_DIR"/cmdline.jar supports dpop
  exit $?
  ;;
```

`set -o pipefail` is dropped since there's no longer a pipe.

## Verification

With a current java `cmdline.jar` installed: `./cli.sh supports dpop;
echo $?` prints `0`, and `dpop_nonce_challenge` still returns `0`. Only
`xtest/sdk/java/cli.sh` changes; other SDK wrappers are untouched.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved detection of DPoP support in the Java CLI feature probe.
* Prevents incorrect results caused by relying on help output and flag
matching.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

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

Copilot AI 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.

Pull request overview

Adds end-to-end DPoP (RFC 9449) support to the Java SDK (proof minting, nonce caching, and single-retry nonce challenges) alongside CLI changes to make DPoP always-on for authenticated usage and to provide stable feature detection via a supports subcommand.

Changes:

  • Implement DPoP proof generation and Bearer fallback in TokenSource, including per-origin nonce caching and token-endpoint retry on use_dpop_nonce.
  • Add OkHttp-level DPoP nonce-challenge retry and Connect unary-path nonce caching in AuthInterceptor, and disable Connect-GET for authenticated clients to preserve the htm binding.
  • Update CLI behavior: introduce tdf supports (plain / JSON), add --verbose plumbing, adjust logging defaults, and expand test coverage.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
sdk/src/test/kotlin/io/opentdf/platform/sdk/AuthInterceptorConnectPathTest.kt New Kotlin tests covering Connect unary ThreadLocal URL handoff + nonce caching behavior.
sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java New unit tests covering nonce caching, htu normalization, token endpoint retry, key/alg validation, and Bearer fallback.
sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java Adds tests for EC DPoP key behavior (separate RSA SRT signer) and missing platform_issuer failure when DPoP is explicitly requested.
sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java New tests for OkHttp 401 nonce-challenge retry, single-retry guarantee, concurrency, and Bearer downgrade behavior.
sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt Adds Connect unary response nonce caching + OkHttp DPoP nonce retry interceptor and debug logging summaries.
sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java Introduces DPoP/Bearer scheme snapshotting, JWK-based DPoP key support, nonce cache, token endpoint retry, and improved error shaping.
sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java Adds DPoP key/algorithm configuration, resolves EC-vs-RSA DPoP/SRT key material, disables Connect-GET for authenticated requests, wires in retry interceptor.
sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java New centralized validation for DPoP JWK + algorithm compatibility (RSA/EC) and EC curve→alg inference.
sdk/pom.xml Ensures Kotlin test sources are included in test compilation.
cmdline/src/test/java/io/opentdf/platform/CommandTest.java New CLI tests for supports behavior, JSON output, and ensuring supports does not require credentials.
cmdline/src/main/resources/log4j2.xml Lowers default CLI logging verbosity (root from trace → info).
cmdline/src/main/java/io/opentdf/platform/TDF.java Adds execution exception handler and verbose stack-trace behavior for the CLI entrypoint.
cmdline/src/main/java/io/opentdf/platform/Command.java Adds supports subcommand, makes credentials options non-required (enforced in buildSDK()), adds --verbose, and refactors some parsing.
cmdline/pom.xml Adds runtime BouncyCastle dependency and JUnit/AssertJ test dependencies.
AGENTS.md Documents local JAVA_HOME setup guidance for the agent environment.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java Outdated
Comment thread cmdline/src/main/java/io/opentdf/platform/Command.java
Comment thread cmdline/src/main/java/io/opentdf/platform/Command.java
Comment thread cmdline/pom.xml Outdated
Comment thread cmdline/src/main/java/io/opentdf/platform/TDF.java
- SDKBuilder: extract DPoP/SRT key resolution out of buildServices into
  a resolveDpopMaterial() helper + DpopMaterial holder, cutting the
  method's cognitive complexity below the threshold (S3776). No behavior
  change.
- Tests: drop a redundant 'public' modifier (S5786) and two unthrowable
  'throws Exception' declarations (S1130), document the intentionally
  empty catch (S108), and hoist URL/URI construction out of five
  assertThatThrownBy lambdas so each has a single throwing call (S5778).
@github-actions

Copy link
Copy Markdown
Contributor

@dmihalcik-virtru
dmihalcik-virtru marked this pull request as ready for review July 14, 2026 18:10
@dmihalcik-virtru
dmihalcik-virtru requested review from a team as code owners July 14, 2026 18:10

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🧹 Nitpick comments (3)
cmdline/src/main/java/io/opentdf/platform/Command.java (1)

384-385: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer Files.readString for reading text files.

Since the SDK targets Java 11, you can use Files.readString(Path) instead of new String(Files.readAllBytes(Path)). The latter relies on the platform's default charset, while Files.readString explicitly defaults to UTF-8 and is more idiomatic.

♻️ Proposed refactor
-                                String fileJson = new String(Files.readAllBytes(Paths.get(assertionVerificationInput)));
+                                String fileJson = Files.readString(Paths.get(assertionVerificationInput));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmdline/src/main/java/io/opentdf/platform/Command.java` around lines 384 -
385, Update the file-reading logic that supplies fileJson to gson.fromJson in
Command so it uses Files.readString(Path) instead of constructing a String from
Files.readAllBytes. Preserve UTF-8 text decoding and the existing
Config.AssertionVerificationKeys deserialization behavior.
sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java (1)

351-357: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate default-algorithm logic between resolveDpopMaterial() and resolveDpopAlgorithm().

The auto-generated-key branch reimplements a subset of resolveDpopAlgorithm's default logic inline instead of delegating to it. Functionally equivalent today (generated key is always RSA), but the two defaulting paths can silently diverge if the default policy changes later.

♻️ Proposed fix to reuse the single source of truth
     private DpopMaterial resolveDpopMaterial() {
         if (this.dpopKey == null) {
             // Auto-generate a single RSA-2048 key used for both DPoP and SRT.
             RSAKey generated = generateSrtRsaKey("Error generating DPoP key");
-            JWSAlgorithm alg = this.dpopAlg != null ? this.dpopAlg : JWSAlgorithm.RS256;
-            return new DpopMaterial(generated, alg, generated);
+            return new DpopMaterial(generated, resolveDpopAlgorithm(generated), generated);
         }

Also applies to: 366-371

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java` around lines 351 -
357, Update resolveDpopMaterial() to obtain the algorithm by delegating to
resolveDpopAlgorithm() instead of duplicating the dpopAlg-null fallback inline,
including the corresponding logic in the alternate branch around the referenced
lines. Preserve the generated RSA key handling while ensuring all DPoP algorithm
defaulting uses resolveDpopAlgorithm() as the single source of truth.
sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt (1)

31-32: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Debug-log arguments are evaluated on every call, not only when DEBUG is enabled.

SLF4J's logger.debug("...{}", arg) defers message formatting, but Kotlin/Java still evaluates the arguments eagerly before the call. dpopSummary(...) parses the DPoP JWT (SignedJWT.parse) and authScheme(...) runs on every stream/unary request, every unary/okhttp response, and every retry — regardless of whether DEBUG is actually enabled. In production (INFO/WARN), this is unconditional JWT-parsing overhead on the hot authenticated-request path for no benefit.

⚡ Proposed fix (guard with isDebugEnabled)
-                logger.debug("DPoP path=stream url={} method=POST authScheme={} {}",
-                        request.url, authScheme(authHeaders.authHeader), dpopSummary(authHeaders.dpopHeader))
+                if (logger.isDebugEnabled) {
+                    logger.debug("DPoP path=stream url={} method=POST authScheme={} {}",
+                            request.url, authScheme(authHeaders.authHeader), dpopSummary(authHeaders.dpopHeader))
+                }

Apply the same if (logger.isDebugEnabled) { ... } guard at the other three call sites (unary request, okhttp path, okhttp retry).

Also applies to: 60-63, 114-117, 133-136

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt` around lines
31 - 32, Guard the debug logging call sites in the interceptor—including the
shown stream path and the unary request, okhttp response, and okhttp retry
paths— with logger.isDebugEnabled before evaluating authScheme(...) and
dpopSummary(...). Keep the existing log messages and behavior unchanged when
DEBUG is enabled.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java`:
- Around line 213-324: Reduce cognitive complexity in getToken() by extracting
request construction/sending into sendTokenRequest(String cachedNonce), the
use_dpop_nonce retry flow into retryWithNonce(String dpopNonce), and settled
success-response field assignment into applyTokenResponse(TokenResponse, URL
tokenEndpointUrl). Keep getToken() as a short orchestration method while
preserving nonce caching, retry behavior, error handling, and token field
updates.
- Around line 241-271: Update the nonce-retry handling in the token request flow
around TokenRequest and tokenResponse so that a DPoP-Nonce returned on the
retried response is cached before the final error is thrown. Preserve the
existing single retry behavior, and ensure the latest nonce from either response
is stored for the next getToken() call.

---

Nitpick comments:
In `@cmdline/src/main/java/io/opentdf/platform/Command.java`:
- Around line 384-385: Update the file-reading logic that supplies fileJson to
gson.fromJson in Command so it uses Files.readString(Path) instead of
constructing a String from Files.readAllBytes. Preserve UTF-8 text decoding and
the existing Config.AssertionVerificationKeys deserialization behavior.

In `@sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java`:
- Around line 351-357: Update resolveDpopMaterial() to obtain the algorithm by
delegating to resolveDpopAlgorithm() instead of duplicating the dpopAlg-null
fallback inline, including the corresponding logic in the alternate branch
around the referenced lines. Preserve the generated RSA key handling while
ensuring all DPoP algorithm defaulting uses resolveDpopAlgorithm() as the single
source of truth.

In `@sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt`:
- Around line 31-32: Guard the debug logging call sites in the
interceptor—including the shown stream path and the unary request, okhttp
response, and okhttp retry paths— with logger.isDebugEnabled before evaluating
authScheme(...) and dpopSummary(...). Keep the existing log messages and
behavior unchanged when DEBUG is enabled.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 975d89e9-d720-42b9-87a1-bd004126e193

📥 Commits

Reviewing files that changed from the base of the PR and between f94f507 and f16063b.

📒 Files selected for processing (15)
  • AGENTS.md
  • cmdline/pom.xml
  • cmdline/src/main/java/io/opentdf/platform/Command.java
  • cmdline/src/main/java/io/opentdf/platform/TDF.java
  • cmdline/src/main/resources/log4j2.xml
  • cmdline/src/test/java/io/opentdf/platform/CommandTest.java
  • sdk/pom.xml
  • sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java
  • sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
  • sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java
  • sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt
  • sdk/src/test/java/io/opentdf/platform/sdk/DPoPRetryInterceptorTest.java
  • sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java
  • sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java
  • sdk/src/test/kotlin/io/opentdf/platform/sdk/AuthInterceptorConnectPathTest.kt

Comment thread sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java
Comment thread sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java Outdated
Comment thread sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java Outdated
mkleene
mkleene previously approved these changes Jul 16, 2026

@mkleene mkleene 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.

Looks great! Really thorough tests and worked around some tricky issues.

Comment thread cmdline/src/main/java/io/opentdf/platform/Command.java
Comment thread sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
Comment thread sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java Outdated
Comment thread sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt Outdated
- TokenSource.getToken: split into sendTokenRequest/retryWithNonce/
  applyTokenResponse/nonceForRetry to clear the SonarCloud cognitive-complexity
  gate; cache the DPoP-Nonce rotated on a failed retry response so a double
  rotation self-heals on the next call (RFC 9449 §8.2).
- SDKBuilder: replace the standalone dpopAlgorithm() setter with paired
  dpopKey(JWK) (infers algorithm) and dpopKey(JWK, JWSAlgorithm) overloads;
  resolveDpopMaterial delegates to resolveDpopAlgorithm.
- AuthInterceptor: drop the ThreadLocal URL handoff for a per-call closure-local
  and create a fresh interceptor per connect call (per-request contract);
  guard debug logging with isDebugEnabled.
- cmdline: use Files.readString for UTF-8 file reads.
- Tests: cover failed-retry nonce caching, per-call URL isolation, and the
  two-arg dpopKey overload.
@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
cmdline/src/main/java/io/opentdf/platform/Command.java (1)

327-344: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject whitespace-only credential values. isEmpty() still lets " " through for platformEndpoint, clientId, and clientSecret, so invalid input reaches SDK construction instead of the usage error. Use isBlank() or trim before checking all three values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmdline/src/main/java/io/opentdf/platform/Command.java` around lines 327 -
344, Update the credential validation in Command so platformEndpoint, clientId,
and clientSecret reject whitespace-only values by using isBlank() or equivalent
trimming checks. Preserve the existing ParameterException messages and normal
validation behavior for null and genuinely non-empty values.

Source: Coding guidelines

sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java (1)

188-196: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the raw path when stripping query and fragment.

getPath() decodes escaped reserved characters, so /kas/a%2Fb becomes /kas/a/b in the proof. The resource server then validates a different htu and rejects the DPoP proof.

Proposed fix
 private static URI htuOf(URI uri) {
     if (uri.getRawQuery() == null && uri.getRawFragment() == null) {
         return uri;
     }
-    try {
-        return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), null, null);
-    } catch (URISyntaxException e) {
-        throw new SDKException("failed to normalize URI for DPoP htu claim: " + uri, e);
-    }
+    String rawUri = uri.toASCIIString();
+    int query = rawUri.indexOf('?');
+    int fragment = rawUri.indexOf('#');
+    int end = rawUri.length();
+    if (query >= 0) {
+        end = Math.min(end, query);
+    }
+    if (fragment >= 0) {
+        end = Math.min(end, fragment);
+    }
+    return URI.create(rawUri.substring(0, end));
 }

Add coverage for a URL such as /kas/a%2Fb?x=1#fragment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java` around lines 188
- 196, Update htuOf(URI) to preserve the URI’s raw path when removing query and
fragment components; use getRawPath() rather than getPath() while constructing
the normalized URI, retaining escaped sequences such as %2F. Add coverage for
/kas/a%2Fb?x=1#fragment and verify the resulting htu excludes query and fragment
but preserves the raw path.
sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java (1)

239-263: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Verify that the supplied RSA key is actually reused.

Checking only alg() == "RS256" also passes if the builder silently generates another RSA key. Sign test data and verify the signature using rsaDpopKey.toRSAPublicKey() to pin the intended reuse behavior.

Suggested assertion
             assertThat(sdk.getSrtSigner()).isPresent();
             assertThat(sdk.getSrtSigner().get().alg()).isEqualTo("RS256");
+
+            byte[] payload = new byte[]{1, 2, 3};
+            byte[] signature = sdk.getSrtSigner().get().sign(payload);
+            java.security.Signature verifier = java.security.Signature.getInstance("SHA256withRSA");
+            verifier.initVerify(rsaDpopKey.toRSAPublicKey());
+            verifier.update(payload);
+            assertThat(verifier.verify(signature)).isTrue();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java` around lines
239 - 263, Strengthen rsaDpopKeyReusesSameKeyForSrt by signing representative
test data with sdk.getSrtSigner() and verifying that signature with
rsaDpopKey.toRSAPublicKey(). Keep the existing RS256 algorithm assertion, and
ensure the verification specifically proves the supplied RSA key, rather than a
newly generated key, was reused.
sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt (1)

27-31: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Preserve existing request headers.

Both streamFunction and unaryFunction initialize requestHeaders as an empty map, which silently drops any existing headers (such as connect-protocol-version, tracing identifiers, or user-supplied metadata) present in request.headers.

  • sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt#L27-L31: initialize requestHeaders with request.headers.toMutableMap() in streamFunction instead of an empty map.
  • sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt#L58-L62: initialize requestHeaders with request.headers.toMutableMap() in unaryFunction as well.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt` around lines
27 - 31, The streamFunction and unaryFunction paths discard existing request
headers by starting with empty maps. In
sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt lines 27-31 and
58-62, initialize each requestHeaders map from request.headers.toMutableMap(),
then preserve the existing Authorization and optional DPoP header assignments.
🧹 Nitpick comments (1)
sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt (1)

122-125: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant header fallback and duplicate nonce caching.

OkHttp and Connect-Kotlin natively handle header case-insensitivity (or lowercase them explicitly), making the explicit "DPoP-Nonce" fallbacks redundant. Additionally, the retry flow caches the exact same nonce twice.

  • sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt#L122-L125: remove the ?: response.header("DPoP-Nonce") fallback, and remove the redundant ts.cacheNonce(url, dpopNonce) since cacheNonceIfPresent at line 112 already cached it.
  • sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt#L83-L84: remove the ?: resp.headers["DPoP-Nonce"]?.firstOrNull() fallback in unaryFunction.
  • sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt#L166-L166: remove the ?: response.header("DPoP-Nonce") fallback in cacheNonceIfPresent.
  • sdk/src/test/kotlin/io/opentdf/platform/sdk/AuthInterceptorConnectPathTest.kt#L91-L91: update the manually constructed mock response in the test to use the lowercase "dpop-nonce" key so it correctly simulates the Connect-Kotlin parser and passes without the fallback.
  • sdk/src/test/kotlin/io/opentdf/platform/sdk/AuthInterceptorConnectPathTest.kt#L126-L127: similarly update the mock responses in the concurrent isolation test to use "dpop-nonce".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt` around lines
122 - 125, Remove redundant case-variant DPoP nonce header fallbacks in
AuthInterceptor.unaryFunction, the retry flow, and cacheNonceIfPresent, and
remove the duplicate ts.cacheNonce call after cacheNonceIfPresent has already
cached the nonce. In AuthInterceptorConnectPathTest, update the manually
constructed responses in both affected tests to use the lowercase dpop-nonce
header key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java`:
- Around line 350-352: Update the token expiry assignment in TokenSource so a
refreshed token with lifetime 0 clears or resets tokenExpiryTime instead of
retaining the previous expired value; preserve the existing one-third-lifetime
calculation for tokens with a nonzero lifetime.

---

Outside diff comments:
In `@cmdline/src/main/java/io/opentdf/platform/Command.java`:
- Around line 327-344: Update the credential validation in Command so
platformEndpoint, clientId, and clientSecret reject whitespace-only values by
using isBlank() or equivalent trimming checks. Preserve the existing
ParameterException messages and normal validation behavior for null and
genuinely non-empty values.

In `@sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java`:
- Around line 188-196: Update htuOf(URI) to preserve the URI’s raw path when
removing query and fragment components; use getRawPath() rather than getPath()
while constructing the normalized URI, retaining escaped sequences such as %2F.
Add coverage for /kas/a%2Fb?x=1#fragment and verify the resulting htu excludes
query and fragment but preserves the raw path.

In `@sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt`:
- Around line 27-31: The streamFunction and unaryFunction paths discard existing
request headers by starting with empty maps. In
sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt lines 27-31 and
58-62, initialize each requestHeaders map from request.headers.toMutableMap(),
then preserve the existing Authorization and optional DPoP header assignments.

In `@sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java`:
- Around line 239-263: Strengthen rsaDpopKeyReusesSameKeyForSrt by signing
representative test data with sdk.getSrtSigner() and verifying that signature
with rsaDpopKey.toRSAPublicKey(). Keep the existing RS256 algorithm assertion,
and ensure the verification specifically proves the supplied RSA key, rather
than a newly generated key, was reused.

---

Nitpick comments:
In `@sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt`:
- Around line 122-125: Remove redundant case-variant DPoP nonce header fallbacks
in AuthInterceptor.unaryFunction, the retry flow, and cacheNonceIfPresent, and
remove the duplicate ts.cacheNonce call after cacheNonceIfPresent has already
cached the nonce. In AuthInterceptorConnectPathTest, update the manually
constructed responses in both affected tests to use the lowercase dpop-nonce
header key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e3951f4b-9880-40f8-8ead-b5f2fd3a95a8

📥 Commits

Reviewing files that changed from the base of the PR and between f16063b and a47fc9f.

📒 Files selected for processing (9)
  • cmdline/pom.xml
  • cmdline/src/main/java/io/opentdf/platform/Command.java
  • cmdline/src/test/java/io/opentdf/platform/CommandTest.java
  • sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java
  • sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java
  • sdk/src/main/kotlin/io/opentdf/platform/sdk/AuthInterceptor.kt
  • sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java
  • sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java
  • sdk/src/test/kotlin/io/opentdf/platform/sdk/AuthInterceptorConnectPathTest.kt
💤 Files with no reviewable changes (1)
  • cmdline/pom.xml
🚧 Files skipped from review as they are similar to previous changes (2)
  • cmdline/src/test/java/io/opentdf/platform/CommandTest.java
  • sdk/src/test/java/io/opentdf/platform/sdk/TokenSourceTest.java

Comment on lines +350 to 352
if (token.getLifetime() != 0) {
this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3);
}

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reset expiry when the refreshed token omits a lifetime.

If the previous token expired and the replacement has lifetime 0, the old, expired tokenExpiryTime remains. Every subsequent request then fetches another token.

Proposed fix
-        if (token.getLifetime() != 0) {
-            this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3);
-        }
+        this.tokenExpiryTime = token.getLifetime() == 0
+                ? null
+                : Instant.now().plusSeconds(token.getLifetime() / 3);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (token.getLifetime() != 0) {
this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3);
}
this.tokenExpiryTime = token.getLifetime() == 0
? null
: Instant.now().plusSeconds(token.getLifetime() / 3);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java` around lines 350
- 352, Update the token expiry assignment in TokenSource so a refreshed token
with lifetime 0 clears or resets tokenExpiryTime instead of retaining the
previous expired value; preserve the existing one-third-lifetime calculation for
tokens with a nonzero lifetime.

@dmihalcik-virtru

Copy link
Copy Markdown
Member Author

@/tmp/r_nits.md

@github-actions

Copy link
Copy Markdown
Contributor

@mkleene mkleene 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.

LGTM

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants