Skip to content

feat(sdk): comprehensive DPoP nonce handling and verification - #939

Draft
dmihalcik-virtru wants to merge 69 commits into
mainfrom
DSPX-3397-web-sdk
Draft

feat(sdk): comprehensive DPoP nonce handling and verification#939
dmihalcik-virtru wants to merge 69 commits into
mainfrom
DSPX-3397-web-sdk

Conversation

@dmihalcik-virtru

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

Copy link
Copy Markdown
Member

Summary

Implements comprehensive DPoP (RFC 9449) support for the web-sdk as part of the Keycloak v26 upgrade and platform-wide DPoP feature.

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

Changes

DPoP-Nonce Support (RFC 9449 §8)

  • New: lib/src/auth/dpop-nonce.ts - Per-origin nonce cache manager
  • Updated: lib/src/auth/interceptors.ts - Auto-retry on 401 with DPoP-Nonce challenge
  • Updated: lib/src/auth/oidc.ts - Token endpoint and userinfo nonce handling

Verification & Testing

  • ✅ Existing DPoP implementation already includes ath claim on resource calls
  • ✅ JWK thumbprint flows through to cnf.jkt via existing proof generation
  • ✅ All KAS-facing clients (rewrap, policy, kasregistry) use interceptors with DPoP support

xtest Integration

  • Updated: cli/src/cli.ts - Added supports dpop command for feature detection
    • Usage: opentdf supports dpop → exit 0 if supported
    • Activates integration tests when this PR's CI runs

Implementation Details

Per RFC 9449 §8, the SDK now:

  1. Caches server-issued nonces by origin
  2. Includes cached nonce in DPoP proofs when available
  3. On 401 with DPoP-Nonce response header:
    • Caches the server nonce
    • Regenerates proof with nonce claim
    • Retries request once
  4. Updates nonce cache from successful responses

Works across:

  • Token endpoint requests (client credentials, token exchange, refresh)
  • Resource requests (KAS rewrap, policy queries, kasregistry)
  • gRPC/Connect-RPC interceptors
  • Legacy fetch-based paths

Related PRs

  • opentdf/tests#DSPX-3397-kc26-dpop - Integration tests & otdf-local KC26 bump
  • opentdf/platform#DSPX-3397-platform-service - Platform service DPoP validation
  • opentdf/platform#DSPX-3397-platform-go-sdk - Go SDK DPoP client
  • opentdf/java-sdk#DSPX-3397-java-sdk - Java SDK DPoP client

Testing

Local builds and lints pass. Integration tests will activate once the tests-cell KC26 bump lands and this PR's CI exposes supports dpop.


🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

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

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4308eb21-39d7-49f4-b021-1cdcc13a8a09

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch DSPX-3397-web-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

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 DPoP-Nonce caching per RFC 9449 §8, implementing a DPoPNonceCache to store and reuse server-issued nonces by origin, and updating both the authentication interceptor and OIDC client to handle nonce-based retries on 401 challenges. It also adds a CLI command to check for DPoP support. The review feedback highlights several critical improvements: ensuring that 401 retries occur when a new or different nonce is received (rather than only when no nonce was cached), using optional chaining on error metadata to prevent runtime crashes, avoiding direct process.exit calls in the CLI handler, and adding defensive checks when extracting nonces from headers.

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 lib/src/auth/interceptors.ts Outdated
Comment thread lib/src/auth/interceptors.ts Outdated
Comment thread cli/src/cli.ts Outdated
Comment thread lib/src/auth/oidc.ts Outdated
Comment thread lib/src/auth/dpop-nonce.ts Outdated
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

If these changes look good, signoff on them with:

git pull && git commit --amend --signoff && git push --force-with-lease origin

If they aren't any good, please remove them with:

git pull && git reset --hard HEAD~1 && git push --force-with-lease origin

@github-actions

Copy link
Copy Markdown

If these changes look good, signoff on them with:

git pull && git commit --amend --signoff && git push --force-with-lease origin

If they aren't any good, please remove them with:

git pull && git reset --hard HEAD~1 && git push --force-with-lease origin

@github-actions

Copy link
Copy Markdown

If these changes look good, signoff on them with:

git pull && git commit --amend --signoff && git push --force-with-lease origin

If they aren't any good, please remove them with:

git pull && git reset --hard HEAD~1 && git push --force-with-lease origin

@dmihalcik-virtru dmihalcik-virtru changed the title feat(web-sdk): comprehensive DPoP nonce handling and verification (DSPX-3397) feat(sdk): comprehensive DPoP nonce handling and verification Jun 10, 2026
@github-actions

Copy link
Copy Markdown

If these changes look good, signoff on them with:

git pull && git commit --amend --signoff && git push --force-with-lease origin

If they aren't any good, please remove them with:

git pull && git reset --hard HEAD~1 && git push --force-with-lease origin

@github-actions

Copy link
Copy Markdown

If these changes look good, signoff on them with:

git pull && git commit --amend --signoff && git push --force-with-lease origin

If they aren't any good, please remove them with:

git pull && git reset --hard HEAD~1 && git push --force-with-lease origin

@github-actions

Copy link
Copy Markdown

If these changes look good, signoff on them with:

git pull && git commit --amend --signoff && git push --force-with-lease origin

If they aren't any good, please remove them with:

git pull && git reset --hard HEAD~1 && git push --force-with-lease origin

@github-actions

Copy link
Copy Markdown

If these changes look good, signoff on them with:

git pull && git commit --amend --signoff && git push --force-with-lease origin

If they aren't any good, please remove them with:

git pull && git reset --hard HEAD~1 && git push --force-with-lease origin

Comment thread lib/tests/server.ts Fixed
Comment thread lib/tests/server.ts Fixed
console.assert only prints to stderr and never sets a non-zero exit code, so
`inspect` could report success even when a requested DPoP proof or cnf.jkt
binding never took effect. Throw CLIError for those security-outcome checks.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
fetchKasBasePubKey threw NetworkError for a malformed platform config and the
surrounding catch re-wrapped it under a [PublicKey] network banner, pointing
operators at KAS connectivity for a config problem. Throw ConfigurationError
and re-throw it unchanged; only genuine RPC failures become NetworkError.

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

fetchKasPubKey dumped the raw base-key error via console.log(e) with no
context. Use the existing errBrief helper and a descriptive prefix so the log
is a one-liner and never dumps a Connect error object (which may carry DPoP
nonce metadata), consistent with the rest of the auth path.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
…ers (DSPX-3397)

signJwt/verifyJwt cast header.alg to AsymmetricSigningAlgorithm unchecked,
while dpop.ts validated with a local type guard — sibling signers enforcing
the same invariant with different rigor. Promote the guard and its backing
list to declarations.ts (next to the type), reuse it in dpop.ts, and replace
both jwt.ts casts so an unsupported alg fails fast with a clear error.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
- DER↔P1363 comments in dpop.ts/jwt.ts said RSA/EdDSA are passed through raw,
  but EdDSA is rejected upstream and never reaches those branches.
- fetchWrappedKey JSDoc documented `requestBody`/`clientVersion` params that
  don't exist; align with the actual (url, signedRequestToken, auth, ...) signature.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
Keycloak 25+ serves health on a separate management interface (port 9000)
which inherits KC_HTTP_RELATIVE_PATH, so the old 8888/auth/health/live probe
404s against the 26.2 image and the container never reports healthy.

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

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

config-demo-idp.sh downloaded the Keycloak release zip and ran kcadm.sh on the
runner JRE. Keycloak 26.2 kcadm is compiled for Java 17 (class file 61.0) but
ubuntu-22.04 defaults to Java 11 (max 55.0), so every call died with
UnsupportedClassVersionError and no clients or users were created:

  Error: LinkageError occurred while loading main class
  org.keycloak.client.admin.cli.KcAdmMain

Run kcadm inside the keycloak container instead. It ships a JRE matching its
own Keycloak version, which removes both the JRE-skew failure and a ~155MB
download. Resolve the compose file from the script directory so the caller
cwd does not matter, and target the container-internal port since the 65432
vite proxy is only reachable from the host.

Also give the config-demo-idp.sh failure branch in wait-and-test.sh its own
error string; it previously printed the same text as the provision step,
making the failing stage ambiguous in CI logs.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
@dmihalcik-virtru
dmihalcik-virtru force-pushed the DSPX-3397-web-sdk branch 2 times, most recently from eb63dd5 to 9f45ab0 Compare August 4, 2026 17:51
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

X-Test Failure Report

✅ java@main-v0.9.0
opentdf-ctl
opentdf-sdk-lib

The roundtrip already runs every CLI and browser flow with DPoP, but only
proves plain proof-of-possession: KAS never challenges, so the
server-issued nonce retry this PR adds is never walked.

xtest can't fill the gap. Its nonce cases are gated on opentdf/tests'
shared `dpop-challenge` input, which also turns on require_nonce for
every SDK in the matrix -- including ones with no nonce support -- so it
stays off. Without this, the feature ships with no CI coverage.

Set server.auth.dpop.require_nonce so KAS answers the first proofed
request with 401 + DPoP-Nonce. `enforce` stays off, so bearer tokens are
still accepted and the non-DPoP paths in this job are unchanged.

Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
dmihalcik-virtru added a commit to opentdf/tests that referenced this pull request Aug 6, 2026
)

## What

Bump the `start-up-with-containers` pin to `18b8070f`
(opentdf/platform#3792) and pass `bootstrap-ref: main` on the `main`
platform lane only.

## Why

The action overlays `docker-compose.yaml` from `bootstrap-ref`,
**independent of `platform-ref`**. That input defaults to the
`pqc-enabled` tag, which is still pinned to `keycloak/keycloak:25.0` — a
Keycloak that cannot issue DPoP-bound access tokens. So every lane ran
KC 25 even though platform `main`'s own compose has been
`ghcr.io/opentdf/keycloak-standard:26.4.0` with `KC_FEATURES: dpop`
since #3792, and `service/cmd/keycloak_data.yaml` provisions
`opentdf-dpop` with `dpop.bound.access.tokens: "true"`.

The visible symptom: `test_dpop.py::test_dpop_happy_path_roundtrip`
fails for any SDK that checks token binding. The token comes back
`typ=Bearer` with no `cnf.jkt`, and the js CLI rejects it:

```
[CRITICAL] DPoP requested but the access token is not bound (missing cnf.jkt)
```

go and java pass only because they don't assert `cnf.jkt`. The other
`test_dpop.py` cases already self-skip on `token_type != DPoP`; the
SDK-level roundtrips gate on the platform well-known and SDK features,
not on IdP capability, so they run and fail. This is what keeps
opentdf/web-sdk#939 red.

Released platform tags keep the 25.0 bootstrap — they predate the move
to standard Keycloak token exchange (opentdf/platform#3754).

## Testing

Dispatched against this branch with `platform-ref=main`,
`js-ref=refs/pull/939/merge`, `focus-sdk=js` —
https://github.com/opentdf/tests/actions/runs/31016132383 — all three
lanes green (`go@main`, `java@main`, `js@pull-939`).

Confirmed Keycloak 26.4 came up, and these ran rather than skipped:

```
PASSED test_dpop.py::test_dpop_happy_path_roundtrip[small-js@pull-939-js@pull-939-in_focus0]
PASSED test_dpop.py::test_dpop_happy_path_roundtrip[small-js@pull-939-java@main-in_focus0]
PASSED test_dpop.py::test_dpop_rejects_replayed_jti[small-js@pull-939-in_focus0]
PASSED test_dpop.py::test_dpop_rejects_tampered_proof_htu[small-js@pull-939-in_focus0]
PASSED test_dpop.py::test_dpop_bearer_scheme_warns_but_accepted_for_dpop_token[small-js@pull-939-in_focus0]
```

The nonce-dependent cases still skip, correctly — `dpop-challenge` is
off by default, so `require_nonce` is unset.

## Notes

- The new action's input set is a strict superset of the old pin's; no
call-site changes beyond `bootstrap-ref`.
- `start-additional-kas` is left at `6dd5f649` — it downloads no
compose.
- Overlaps #568 (DSPX-4190), which took the broader
approach of moving every lane to 26.4 via a platform PR branch. This is
the narrow slice needed now; #568's remaining checklist (re-point the
`pqc-enabled` tag, align `vulnerability.yml`) still stands.

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

## Summary by CodeRabbit

* **Chores**
* Updated platform startup workflows to use the Keycloak 26.4 revision.
* Adjusted bootstrap selection so the main platform lane uses the main
configuration while released tags retain the PQC-enabled configuration.

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

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

sonarqubecloud Bot commented Aug 6, 2026

Copy link
Copy Markdown

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.

2 participants