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)

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 command to list supported features or return machine-readable JSON results.
    • Added verbose CLI output with -v/--verbose.
    • Added support for custom DPoP keys and algorithms.
    • Improved DPoP authentication with nonce handling, automatic retries, and Bearer token compatibility.
    • Added validation and clearer errors for missing CLI credentials and incompatible DPoP configurations.
  • Documentation

    • Updated build instructions with guidance for configuring Java through Homebrew.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The CLI adds feature-support 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 tests for authentication flows.

Changes

CLI behavior and build setup

Layer / File(s) Summary
CLI commands and validation
cmdline/src/main/java/io/opentdf/platform/Command.java
Adds the supports command, 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 configuration, documents Java setup, and tests CLI output and option behavior.

SDK DPoP authentication

Layer / File(s) Summary
DPoP validation and SDK configuration
sdk/src/main/java/io/opentdf/platform/sdk/DpopKeyValidation.java, sdk/src/main/java/io/opentdf/platform/sdk/SDKBuilder.java, sdk/src/test/java/io/opentdf/platform/sdk/SDKBuilderTest.java, sdk/pom.xml
Adds DPoP key and algorithm validation, configurable builder methods, SRT key resolution, issuer validation, and Kotlin test-source configuration.
Token schemes and nonce-aware token source
sdk/src/main/java/io/opentdf/platform/sdk/TokenSource.java, sdk/src/test/java/io/opentdf/platform/sdk/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 cleanup, and propagation of retry interceptors to SDK clients.

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

Sequence Diagram(s)

sequenceDiagram
  participant SDKBuilder
  participant TokenSource
  participant AuthInterceptor
  participant ResourceServer
  SDKBuilder->>TokenSource: configure DPoP credentials
  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 PRs

  • opentdf/java-sdk#367: Both changes modify SDK authentication and TLS-related setup around TokenSource and SDKBuilder.

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 0.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: comprehensive RFC 9449 DPoP support in the SDK.
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.
✨ 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>
Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…ithm

Add a retry test asserting that when the token refreshed on a DPoP nonce
challenge comes back as a plain Bearer token, the retried request sends
Authorization: Bearer with no DPoP header (guards AuthInterceptor's
removeHeader("DPoP")). Add a TokenSource test that an explicit RS512
override reaches the proof factory instead of defaulting to RS256.
Clarify coverage-boundary and pre-network-failure comments in
SDKBuilderTest and CommandTest.

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

Copy link
Copy Markdown
Contributor

The SDK already builds a DPoP-capable key by default without any CLI
input (SDKBuilder auto-generates a key when none is configured), and
capability detection is already exposed via `tdf supports dpop` /
`tdf supports dpop_nonce_challenge`. Mirrors the go-sdk direction in
opentdf/platform#3581, which dropped otdfctl's --dpop/--dpop-key flags
in favor of always-on DPoP plus capability detection.

Removes CliDpopOptions (and its test) and the --dpop/--dpop-key
options from Command.java.
@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
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).
- TokenSource: cache the DPoP-Nonce from every settled token response, not
  just the use_dpop_nonce retry path, so a nonce the AS rotates on a
  successful (200) response is honored on the next request (RFC 9449 §8.2).
  Collapses the duplicate retry-path caching into one call.
- cmdline supports --json: emit the canonical feature name for recognized
  features (echo raw input only for unknown ones) so output is stable for
  automation.
- cmdline/pom.xml: drop the bouncycastle bcpkix dependency this PR added; it
  was orphaned when the DPoP key CLI flags (its only caller) were removed.
- Tests: cover nonce caching on a successful response and canonical-name JSON
  output. (Also carries the TokenSourceTest SonarCloud test-hygiene edits.)
@sonarqubecloud

Copy link
Copy Markdown

@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

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'javax\.annotation\.Nullable' --type=java -g '!**/generated-sources/**' sdk/src
rg -n 'jsr305|findbugs' sdk/pom.xml

Repository: opentdf/java-sdk

Length of output: 439


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n 'jsr305|findbugs|javax\.annotation|annotation-api' -g 'pom.xml' -g '!**/target/**' .
printf '\n--- sdk/pom.xml ---\n'
cat -n sdk/pom.xml | sed -n '1,260p'
printf '\n--- root pom.xml ---\n'
cat -n pom.xml | sed -n '1,260p'

Repository: opentdf/java-sdk

Length of output: 25277


🌐 Web query:

org.apache.tomcat annotations-api javax.annotation.Nullable

💡 Result:

The class Nullable is provided by the Common Annotations API, but its package name depends on the version being used [1][2]. In modern versions of Apache Tomcat (specifically Tomcat 10 and later), the package has migrated from javax.annotation to jakarta.annotation due to the transition from Java EE to Jakarta EE [1][3][4][2]. Therefore, if you are using Tomcat 10, 11, or newer, you should use jakarta.annotation.Nullable [1][3]. For older versions of Tomcat (such as Tomcat 9 and earlier), common nullability annotations like Nullable often originated from the JSR-305 library (often found as com.google.code.findbugs:jsr305) [5][6][7]. While early versions of the Common Annotations API included a javax.annotation package, it was primarily focused on annotations for generated code and resources (like @Generated or @Resource) rather than nullability checks [8][9]. In summary: - For Tomcat 10/11+: Use jakarta.annotation.Nullable [1][3]. - For older environments or legacy dependencies: Nullable is typically found in javax.annotation provided by the JSR-305 library [5][10][6]. If you are seeing errors related to javax.annotation.Nullable in a project migrating to Jakarta EE, you should update your imports to use the jakarta.annotation namespace [1][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

pom = Path('sdk/pom.xml').read_text()
for pat in [
    r'<groupId>org\.apache\.tomcat</groupId>\s*<artifactId>annotations-api</artifactId>',
    r'<groupId>javax\.annotation</groupId>\s*<artifactId>javax\.annotation-api</artifactId>',
    r'<groupId>com\.google\.code\.findbugs</groupId>\s*<artifactId>jsr305</artifactId>',
]:
    print(pat, bool(re.search(pat, pom, re.S)))
PY

Repository: opentdf/java-sdk

Length of output: 420


🌐 Web query:

Maven org.apache.tomcat annotations-api Nullable javax.annotation.Nullable

💡 Result:

The dependency org.apache.tomcat:annotations-api (now provided via org.apache.tomcat:tomcat-annotations-api) [1][2] does not contain javax.annotation.Nullable. Historically, the javax.annotation.Nullable annotation was part of the JSR 305 project (commonly accessed via the com.google.code.findbugs:jsr305 artifact), which has been abandoned and is not part of the official Java or Jakarta EE specifications [3][4]. Modern versions of the Apache Tomcat annotations API (starting with Jakarta Annotations 2.1) now provide jakarta.annotation.Nullable within the jakarta.annotation package [5][6][7][8]. If you require nullable annotations, it is recommended to migrate from javax.annotation to the Jakarta-based annotations or use a widely supported library like the Checker Framework [3][9]. Key points: - Maven artifact: Use org.apache.tomcat:tomcat-annotations-api [1]. - Package shift: javax.annotation has been superseded by jakarta.annotation for modern Java environments [10][9]. - Status of @Nullable: javax.annotation.Nullable was never an official Java standard and is considered abandonware [3][4]. The jakarta.annotation.Nullable annotation is the current standard equivalent [3].

Citations:


🌐 Web query:

"org.apache.tomcat:annotations-api" "javax.annotation.Nullable"

💡 Result:

The annotation @Nullable is provided by the Common Annotations API, but its package namespace has changed in newer versions of Apache Tomcat. In modern versions of Apache Tomcat (Tomcat 10.1 and later, which use Jakarta EE 9+), the Nullable annotation is located in the jakarta.annotation package, not the javax.annotation package [1][2]. Specifically, it is identified as jakarta.annotation.Nullable [1][3]. In older versions of Apache Tomcat (Tomcat 9.0 and earlier, which use Java EE 8 and below), the Common Annotations API used the javax.annotation package [4]. However, the Nullable annotation was not part of the standard javax.annotation package found in those older Tomcat releases [4]. If you are working with a project that requires javax.annotation.Nullable, you are likely looking for a different library, such as JSR-305 (com.google.code.findbugs:jsr305) or the JetBrains annotations library (org.jetbrains:annotations), as these common libraries provide a Nullable annotation within the javax.annotation namespace. Tomcat's annotations-api artifact is specifically intended to provide the Jakarta/Java EE Common Annotations API [5].

Citations:


Add a dependency that provides javax.annotation.Nullable. The only annotation-related dependency in sdk/pom.xml is org.apache.tomcat:annotations-api, and it does not supply this package; add com.google.code.findbugs:jsr305 or switch the import to jakarta.annotation.Nullable so the SDK still compiles.

Comment on lines +213 to +324
private synchronized TokenSnapshot getToken() {
try {
// If the token is expired or initially null, get a new token
if (token == null || isTokenExpired()) {

logger.trace("The current access token is expired or empty, getting a new one");

// Make the token request
TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI,
clientAuth, authzGrant, null);
DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(dpopJwk, dpopAlg);

// Proactively use any cached nonce for the token endpoint origin (RFC 9449 §8.2)
URL tokenEndpointUrl = tokenEndpointURI.toURL();
String cachedNonce = nonceCache.get(getOrigin(tokenEndpointUrl));

TokenRequest tokenRequest = new TokenRequest(this.tokenEndpointURI, clientAuth, authzGrant, null);
HTTPRequest httpRequest = tokenRequest.toHTTPRequest();
if (sslSocketFactory != null) {
httpRequest.setSSLSocketFactory(sslSocketFactory);
}

DPoPProofFactory dpopFactory = new DefaultDPoPProofFactory(rsaKey, JWSAlgorithm.RS256);

SignedJWT proof = dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), httpRequest.getURI());

URI tokenHtu = htuOf(httpRequest.getURI());
SignedJWT proof = (cachedNonce != null)
? dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), tokenHtu, new Nonce(cachedNonce))
: dpopFactory.createDPoPJWT(httpRequest.getMethod().name(), tokenHtu);
httpRequest.setDPoP(proof);
TokenResponse tokenResponse;

HTTPResponse httpResponse = httpRequest.send();

tokenResponse = TokenResponse.parse(httpResponse);
TokenResponse tokenResponse = TokenResponse.parse(httpResponse);

// RFC 9449 §8.2: if AS requires a nonce, cache it and retry once
if (!tokenResponse.indicatesSuccess()) {
ErrorObject error = tokenResponse.toErrorResponse().getErrorObject();
throw new SDKException("failure to get token. description = [" + error.getDescription() + "] error code = [" + error.getCode() + "] error uri = [" + error.getURI() + "]");
if ("use_dpop_nonce".equals(error.getCode())) {
String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce");
if (dpopNonce != null) {
cacheNonce(tokenEndpointUrl, dpopNonce);
TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null);
HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest();
if (sslSocketFactory != null) {
retryHttpRequest.setSSLSocketFactory(sslSocketFactory);
}
SignedJWT retryProof = dpopFactory.createDPoPJWT(
retryHttpRequest.getMethod().name(),
htuOf(retryHttpRequest.getURI()),
new Nonce(dpopNonce));
retryHttpRequest.setDPoP(retryProof);
httpResponse = retryHttpRequest.send();
tokenResponse = TokenResponse.parse(httpResponse);
} else {
logger.warn("token endpoint {} returned use_dpop_nonce but did not supply a DPoP-Nonce response header",
tokenEndpointURI);
}
}
if (!tokenResponse.indicatesSuccess()) {
ErrorObject finalError = tokenResponse.toErrorResponse().getErrorObject();
throw new SDKException("failure to get token. description = [" + finalError.getDescription()
+ "] error code = [" + finalError.getCode()
+ "] error uri = [" + finalError.getURI() + "]");
}
}

// RFC 9449 §8.2: the AS may supply/rotate a DPoP-Nonce on any response,
// including a successful one. Cache the nonce from the final settled response
// so the next request uses the freshest value (cacheNonce ignores null/empty).
cacheNonce(tokenEndpointUrl, httpResponse.getHeaderValue("DPoP-Nonce"));

var tokens = tokenResponse.toSuccessResponse().getTokens();
if (tokens.getDPoPAccessToken() != null) {
boolean asAssertsDpop = tokens.getDPoPAccessToken() != null;
if (asAssertsDpop) {
logger.trace("retrieved a new DPoP access token");
} else if (tokens.getAccessToken() != null) {
logger.trace("retrieved a new access token");
} else {
logger.trace("got an access token of unknown type");
logger.warn("token endpoint {} returned a success response with an unknown access token type",
tokenEndpointURI);
}

this.token = tokens.getAccessToken();
if (this.token == null) {
throw new SDKException("token endpoint " + tokenEndpointURI
+ " returned a success response with no access token");
}
this.tokenScheme = asAssertsDpop ? TokenScheme.DPOP : TokenScheme.BEARER;
if (!asAssertsDpop) {
logger.warn("token endpoint {} returned a non-DPoP-bound access token (token_type=Bearer) despite"
+ " DPoP proof — falling back to Bearer scheme. Check the IdP DPoP configuration.",
tokenEndpointURI);
}

if (token.getLifetime() != 0) {
// Need some type of leeway but not sure whats best
this.tokenExpiryTime = Instant.now().plusSeconds(token.getLifetime() / 3);
}

} else {
// If the token is still valid or not initially null, return the cached token
return this.token;
return new TokenSnapshot(this.token, this.tokenScheme);
}

} catch (Exception e) {
throw new SDKException("failed to get token", e);
} catch (SDKException e) {
// Already shaped for the caller — don't double-wrap.
throw e;
} catch (MalformedURLException e) {
throw new SDKException("invalid token endpoint URL: " + tokenEndpointURI, e);
} catch (IOException e) {
throw new SDKException("network error contacting token endpoint " + tokenEndpointURI, e);
} catch (JOSEException e) {
throw new SDKException("DPoP proof generation failed for token endpoint " + tokenEndpointURI, e);
} catch (com.nimbusds.oauth2.sdk.ParseException e) {
throw new SDKException("malformed token response from " + tokenEndpointURI, e);
} catch (RuntimeException e) {
throw new SDKException("unexpected error fetching token from " + tokenEndpointURI, e);
}
return this.token;
return new TokenSnapshot(this.token, this.tokenScheme);

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.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

getToken() trips SonarCloud's Cognitive Complexity gate (43 vs 15 allowed).

Static analysis flags this as a failure (LOC 92→64, complexity 16→14, cognitive complexity 43→15, nesting 6→2), which can block the quality gate. The retry/nonce logic, request building, and response-to-field application are all inlined into one method. Consider extracting:

  • sendTokenRequest(String cachedNonce) — build+send the initial request.
  • retryWithNonce(String dpopNonce) — the use_dpop_nonce retry path.
  • applyTokenResponse(TokenResponse, URL tokenEndpointUrl) — assign token/tokenScheme/tokenExpiryTime from a settled success response.

This keeps getToken() as a short orchestrator and each helper independently testable.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 236-236: SMTP server identity must be enforced
Context: httpRequest.send()
Note: [CWE-297] Improper Validation of Certificate with Host Mismatch.

(smtp-insecure-connection)


[warning] 257-257: SMTP server identity must be enforced
Context: retryHttpRequest.send()
Note: [CWE-297] Improper Validation of Certificate with Host Mismatch.

(smtp-insecure-connection)

🪛 GitHub Check: SonarCloud Code Analysis

[warning] 213-213: A "Brain Method" was detected. Refactor it to reduce at least one of the following metrics: LOC from 92 to 64, Complexity from 16 to 14, Nesting Level from 6 to 2, Number of Variables from 23 to 6.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AZ8jmA0U6cfkjqEx7cVO&open=AZ8jmA0U6cfkjqEx7cVO&pullRequest=374


[failure] 213-213: Refactor this method to reduce its Cognitive Complexity from 43 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AZ8jmA0U6cfkjqEx7cVN&open=AZ8jmA0U6cfkjqEx7cVN&pullRequest=374

🤖 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 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.

Source: Linters/SAST tools

Comment on lines +241 to 271
// RFC 9449 §8.2: if AS requires a nonce, cache it and retry once
if (!tokenResponse.indicatesSuccess()) {
ErrorObject error = tokenResponse.toErrorResponse().getErrorObject();
throw new SDKException("failure to get token. description = [" + error.getDescription() + "] error code = [" + error.getCode() + "] error uri = [" + error.getURI() + "]");
if ("use_dpop_nonce".equals(error.getCode())) {
String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce");
if (dpopNonce != null) {
cacheNonce(tokenEndpointUrl, dpopNonce);
TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null);
HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest();
if (sslSocketFactory != null) {
retryHttpRequest.setSSLSocketFactory(sslSocketFactory);
}
SignedJWT retryProof = dpopFactory.createDPoPJWT(
retryHttpRequest.getMethod().name(),
htuOf(retryHttpRequest.getURI()),
new Nonce(dpopNonce));
retryHttpRequest.setDPoP(retryProof);
httpResponse = retryHttpRequest.send();
tokenResponse = TokenResponse.parse(httpResponse);
} else {
logger.warn("token endpoint {} returned use_dpop_nonce but did not supply a DPoP-Nonce response header",
tokenEndpointURI);
}
}
if (!tokenResponse.indicatesSuccess()) {
ErrorObject finalError = tokenResponse.toErrorResponse().getErrorObject();
throw new SDKException("failure to get token. description = [" + finalError.getDescription()
+ "] error code = [" + finalError.getCode()
+ "] error uri = [" + finalError.getURI() + "]");
}
}

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

Nonce rotated on a failed retry response is never cached, risking a repeated-failure loop.

If the AS returns use_dpop_nonce again on the retried request (a second rotation), the code throws at Line 267-269 before the unconditional cacheNonce at Line 276 ever runs — only the nonce from the first 401 gets cached (Line 247). The next call to getToken() will retry with that stale, already-rejected nonce and fail again, since nothing else refreshes the token-endpoint's cached nonce.

🛡️ Proposed fix
                             retryHttpRequest.setDPoP(retryProof);
                             httpResponse = retryHttpRequest.send();
                             tokenResponse = TokenResponse.parse(httpResponse);
+                            // Cache the nonce from the retry response too, even if it also failed,
+                            // so the next getToken() call doesn't retry with a stale nonce.
+                            cacheNonce(tokenEndpointUrl, httpResponse.getHeaderValue("DPoP-Nonce"));
                         } else {
📝 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
// RFC 9449 §8.2: if AS requires a nonce, cache it and retry once
if (!tokenResponse.indicatesSuccess()) {
ErrorObject error = tokenResponse.toErrorResponse().getErrorObject();
throw new SDKException("failure to get token. description = [" + error.getDescription() + "] error code = [" + error.getCode() + "] error uri = [" + error.getURI() + "]");
if ("use_dpop_nonce".equals(error.getCode())) {
String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce");
if (dpopNonce != null) {
cacheNonce(tokenEndpointUrl, dpopNonce);
TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null);
HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest();
if (sslSocketFactory != null) {
retryHttpRequest.setSSLSocketFactory(sslSocketFactory);
}
SignedJWT retryProof = dpopFactory.createDPoPJWT(
retryHttpRequest.getMethod().name(),
htuOf(retryHttpRequest.getURI()),
new Nonce(dpopNonce));
retryHttpRequest.setDPoP(retryProof);
httpResponse = retryHttpRequest.send();
tokenResponse = TokenResponse.parse(httpResponse);
} else {
logger.warn("token endpoint {} returned use_dpop_nonce but did not supply a DPoP-Nonce response header",
tokenEndpointURI);
}
}
if (!tokenResponse.indicatesSuccess()) {
ErrorObject finalError = tokenResponse.toErrorResponse().getErrorObject();
throw new SDKException("failure to get token. description = [" + finalError.getDescription()
+ "] error code = [" + finalError.getCode()
+ "] error uri = [" + finalError.getURI() + "]");
}
}
// RFC 9449 §8.2: if AS requires a nonce, cache it and retry once
if (!tokenResponse.indicatesSuccess()) {
ErrorObject error = tokenResponse.toErrorResponse().getErrorObject();
if ("use_dpop_nonce".equals(error.getCode())) {
String dpopNonce = httpResponse.getHeaderValue("DPoP-Nonce");
if (dpopNonce != null) {
cacheNonce(tokenEndpointUrl, dpopNonce);
TokenRequest retryRequest = new TokenRequest(tokenEndpointURI, clientAuth, authzGrant, null);
HTTPRequest retryHttpRequest = retryRequest.toHTTPRequest();
if (sslSocketFactory != null) {
retryHttpRequest.setSSLSocketFactory(sslSocketFactory);
}
SignedJWT retryProof = dpopFactory.createDPoPJWT(
retryHttpRequest.getMethod().name(),
htuOf(retryHttpRequest.getURI()),
new Nonce(dpopNonce));
retryHttpRequest.setDPoP(retryProof);
httpResponse = retryHttpRequest.send();
tokenResponse = TokenResponse.parse(httpResponse);
cacheNonce(tokenEndpointUrl, httpResponse.getHeaderValue("DPoP-Nonce"));
} else {
logger.warn("token endpoint {} returned use_dpop_nonce but did not supply a DPoP-Nonce response header",
tokenEndpointURI);
}
}
if (!tokenResponse.indicatesSuccess()) {
ErrorObject finalError = tokenResponse.toErrorResponse().getErrorObject();
throw new SDKException("failure to get token. description = [" + finalError.getDescription()
"] error code = [" + finalError.getCode()
"] error uri = [" + finalError.getURI() + "]");
}
}
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 257-257: SMTP server identity must be enforced
Context: retryHttpRequest.send()
Note: [CWE-297] Improper Validation of Certificate with Host Mismatch.

(smtp-insecure-connection)

🤖 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 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.

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

}

private SDK buildSDK() {
// The picocli @Option annotations on platformEndpoint/clientId/clientSecret are

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.

this is really useful

* @param dpopKey JWK (RSA or EC) to use for DPoP proofs
* @return this builder instance for method chaining
*/
public SDKBuilder dpopKey(JWK dpopKey) {

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.

Not sure if I love imposing a compile dependency on com.nimbusds.jose on consumers.

The only other thing that I can think of is taking in a PEM or a java.security.Key as well as the algorithm.

Probably not a real issue since I think that this library is pretty standard and well supported.

* @param dpopAlg JWS algorithm (e.g. RS256, ES256)
* @return this builder instance for method chaining
*/
public SDKBuilder dpopAlgorithm(JWSAlgorithm dpopAlg) {

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.

maybe have one setter for both of these since you need both?

Comment on lines +14 to +20
// The connect-kotlin Interceptor API exposes no per-call context to thread the
// request URL into responseFunction. ThreadLocal is the workaround, relying on
// connect-kotlin's contract that requestFunction and responseFunction for a single
// unary call run synchronously on the same thread. If that assumption ever breaks,
// nonces could be cached against the wrong origin. The okhttp-level
// dpopRetryInterceptor below avoids the issue by reading the URL straight from
// chain.request().

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.

It looks like we might be able to use the fact that we should be creating the auth interceptor for each request/response to pair these and not rely on ThreadLocals. Not a big deal since this seems to work but we could wire in per-response creation here

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