From 856b21fddc23b7e3fd71b8766c75a03d03f00ad8 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 8 Jun 2026 11:12:55 -0400 Subject: [PATCH 01/45] feat(web-sdk): comprehensive DPoP nonce handling and verification (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements RFC 9449 DPoP-Nonce support across SDK and CLI: - Add DPoP-Nonce cache manager (dpop-nonce.ts) with per-origin nonce storage - Update authTokenDPoPInterceptor with automatic nonce retry on 401 challenges - Extend OIDC token endpoint and userinfo flows to handle nonce caching/refresh - Add 'supports dpop' CLI command for xtest integration testing detection - Refresh cached nonces from successful response headers per RFC 9449 §8 All DPoP proofs now include cached nonces when available and automatically retry with server-provided nonces on 401 use_dpop_nonce errors. Co-Authored-By: Claude Sonnet 4.5 Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 21 +++++++++++ lib/src/auth/dpop-nonce.ts | 44 ++++++++++++++++++++++ lib/src/auth/interceptors.ts | 64 ++++++++++++++++++++++++++++++-- lib/src/auth/oidc.ts | 71 ++++++++++++++++++++++++++++++++++-- 4 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 lib/src/auth/dpop-nonce.ts diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 241146d8a..69a283287 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -510,6 +510,27 @@ export const handleArgs = (args: string[]) => { description: 'output file', }) + .command( + 'supports ', + 'Check if a feature is supported', + (yargs) => { + yargs.strict().positional('feature', { + describe: 'feature name to check', + type: 'string', + choices: ['dpop'], + }); + }, + async (argv) => { + const feature = argv.feature as string; + if (feature === 'dpop') { + // DPoP is supported - exit 0 + process.exit(0); + } + // Unknown feature - exit 1 + process.exit(1); + } + ) + .command( 'inspect [file]', 'Inspect TDF and extract header information, without decrypting', diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts new file mode 100644 index 000000000..5b6762677 --- /dev/null +++ b/lib/src/auth/dpop-nonce.ts @@ -0,0 +1,44 @@ +/** + * DPoP-Nonce cache manager per RFC 9449 §8. + * Caches server-issued nonces by origin for use in subsequent DPoP proofs. + */ + +export class DPoPNonceCache { + private cache = new Map(); + + /** + * Get cached nonce for an origin. + */ + get(origin: string): string | undefined { + return this.cache.get(origin); + } + + /** + * Store a nonce for an origin. + * Overwrites any existing nonce for that origin. + */ + set(origin: string, nonce: string): void { + this.cache.set(origin, nonce); + } + + /** + * Clear nonce for an origin (e.g., when it's rejected by the server). + */ + clear(origin: string): void { + this.cache.delete(origin); + } + + /** + * Extract DPoP-Nonce from response headers (case-insensitive). + */ + static extractNonce(headers: Headers): string | undefined { + // Headers.get() is case-insensitive per spec + return headers.get('dpop-nonce') || undefined; + } +} + +/** + * Global nonce cache singleton. + * Shared across all instances to maintain nonce state per-origin. + */ +export const globalNonceCache = new DPoPNonceCache(); diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index c0a0f7971..c22ffae39 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -5,6 +5,7 @@ import * as DefaultCryptoService from '../../tdf3/src/crypto/index.js'; import DPoP from './dpop.js'; import { type AuthProvider } from './auth.js'; import { base64 } from '../encodings/index.js'; +import { globalNonceCache } from './dpop-nonce.js'; /** * A function that returns a valid access token string. @@ -86,10 +87,14 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI const [token, keys] = await Promise.all([options.tokenProvider(), dpopKeysPromise]); const url = new URL(req.url); - const httpUri = `${url.origin}${url.pathname}`; + const origin = url.origin; + const httpUri = `${origin}${url.pathname}`; + + // Check for cached nonce + const cachedNonce = globalNonceCache.get(origin); // Generate DPoP proof JWT for this request - const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST'); + const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST', cachedNonce, token); // Export public key PEM for X-VirtruPubKey header const publicKeyPem = await cryptoService.exportPublicKeyPem(keys.publicKey); @@ -98,7 +103,60 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI req.header.set('DPoP', dpopProof); req.header.set('X-VirtruPubKey', base64.encode(publicKeyPem)); - return next(req); + // Call next and handle DPoP-Nonce retry + try { + const response = await next(req); + + // Extract and cache nonce from successful responses + const responseNonce = response.header.get('dpop-nonce'); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + + return response; + } catch (err) { + // Check if this is a 401 with DPoP-Nonce challenge + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 16 && // Code.Unauthenticated + 'metadata' in err + ) { + const metadata = err.metadata as { get?: (key: string) => string | null }; + const serverNonce = metadata.get?.('dpop-nonce'); + + if (serverNonce && !cachedNonce) { + // Server sent a nonce and we didn't have one cached + // Cache it and retry once + globalNonceCache.set(origin, serverNonce); + + // Regenerate proof with server nonce + const retryDpopProof = await DPoP( + keys, + cryptoService, + httpUri, + 'POST', + serverNonce, + token + ); + req.header.set('DPoP', retryDpopProof); + + const retryResponse = await next(req); + + // Update cache from retry response if present + const retryNonce = retryResponse.header.get('dpop-nonce'); + if (retryNonce) { + globalNonceCache.set(origin, retryNonce); + } + + return retryResponse; + } + } + + // Re-throw if not a nonce challenge or retry failed + throw err; + } }; // Attach dpopKeys to the interceptor function diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index a92a836d2..47586c19f 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -4,6 +4,7 @@ import { base64 } from '../encodings/index.js'; import { ConfigurationError, TdfError } from '../errors.js'; import { rstrip } from '../utils.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; +import { globalNonceCache, DPoPNonceCache } from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -139,21 +140,34 @@ export class AccessToken { * @returns */ async info(accessToken: string): Promise { + const origin = new URL(this.userInfoEndpoint).origin; const headers = { ...this.extraHeaders, Authorization: `Bearer ${accessToken}`, } as Record; if (this.config.dpopEnabled && this.signingKey) { + const cachedNonce = globalNonceCache.get(origin); headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, this.userInfoEndpoint, - 'POST' + 'POST', + cachedNonce, + accessToken ); } const response = await (this.request || fetch)(this.userInfoEndpoint, { headers, }); + + // Update nonce cache from response + if (this.config.dpopEnabled) { + const responseNonce = DPoPNonceCache.extractNonce(response.headers); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + } + if (!response.ok) { console.error(await response.text()); throw new TdfError( @@ -165,6 +179,7 @@ export class AccessToken { } async doPost(url: string, o: Record) { + const origin = new URL(url).origin; const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', @@ -179,13 +194,59 @@ export class AccessToken { // TODO: Rename to X-OpenTDF-PubKey; requires coordinated change with // platform Keycloak mapper (lib/fixtures/keycloak.go `client.publickey`). headers['X-VirtruPubKey'] = base64.encode(publicKeyPem); - headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST'); + + // Get cached nonce for token endpoint + const cachedNonce = globalNonceCache.get(origin); + headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST', cachedNonce); } - return (this.request || fetch)(url, { + + const response = await (this.request || fetch)(url, { method: 'POST', headers, body: qstringify(o), }); + + // Handle DPoP-Nonce retry on 401 + if (this.config.dpopEnabled && response.status === 401) { + const responseNonce = DPoPNonceCache.extractNonce(response.headers); + if (responseNonce) { + // Cache the server-provided nonce and retry + globalNonceCache.set(origin, responseNonce); + + // Regenerate DPoP proof with nonce + headers.DPoP = await dpopFn( + this.signingKey!, + this.cryptoService, + url, + 'POST', + responseNonce + ); + + const retryResponse = await (this.request || fetch)(url, { + method: 'POST', + headers, + body: qstringify(o), + }); + + // Update cache from retry response + const retryNonce = DPoPNonceCache.extractNonce(retryResponse.headers); + if (retryNonce) { + globalNonceCache.set(origin, retryNonce); + } + + return retryResponse; + } + } + + // Update nonce cache from successful responses + if (this.config.dpopEnabled && response.ok) { + const responseNonce = DPoPNonceCache.extractNonce(response.headers); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + } + + return response; } async accessTokenLookup(cfg: OIDCCredentials) { @@ -309,12 +370,14 @@ export class AccessToken { } const accessToken = (this.currentAccessToken ??= await this.get()); if (this.config.dpopEnabled && this.signingKey) { + const origin = new URL(httpReq.url).origin; + const cachedNonce = globalNonceCache.get(origin); const dpopToken = await dpopFn( this.signingKey, this.cryptoService, httpReq.url, httpReq.method, - /* nonce */ undefined, + cachedNonce, accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? From c957bf226cd0c420966903acb46e26fc164c09bf Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:10:46 -0400 Subject: [PATCH 02/45] docs: add DPoP CLI flags design spec (DSPX-3397) Signed-off-by: Dave Mihalcik --- .../specs/2026-06-09-dpop-cli-flags-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md diff --git a/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md b/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md new file mode 100644 index 000000000..95a694fb7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-dpop-cli-flags-design.md @@ -0,0 +1,114 @@ +# DPoP CLI Flags Design — DSPX-3397 (web-sdk slice) + +**Date:** 2026-06-09 +**Branch:** DSPX-3397-web-sdk +**Scope:** `cli/src/cli.ts` only — no SDK core changes + +--- + +## Background + +The branch already ships `lib/src/auth/dpop-nonce.ts` (nonce cache) and `lib/src/auth/interceptors.ts` (`authTokenDPoPInterceptor` with 401-retry). The CLI already has `--dpop` as a boolean and wires `disableDPoP: !argv.dpop` into `OpenTDF`. However, it always falls back to RSA-2048 key generation (not ES256 as RFC 9449 §4.2 requires by default) and has no way to supply a custom PEM key. + +--- + +## Flags + +| Flag | Yargs config | Semantics | +|---|---|---| +| `--dpop[=alg]` | `type: 'string'`, group `Security:` | `--dpop` → enable with ES256 (empty string → default). `--dpop=ES512` → enable with specific alg. Omitted → DPoP disabled. | +| `--dpop-key ` | `type: 'string'`, alias `dpop-key`, group `Security:` | PEM-encoded private key file. Enables DPoP alone (algorithm inferred from key type). | + +Supported algorithm values: `ES256`, `ES384`, `ES512`, `RS256`, `RS384`, `RS512`. RS384/RS512 are accepted but the SDK's `determineJWSAlgorithmFromKeyInfo` maps all RSA keys to RS256 — document in help. + +Help text for both flags contains the word "dpop" so `grep -i dpop` matches. + +--- + +## DPoP Enablement Logic + +```ts +// Normalize the --dpop flag: '' (flag with no value) → 'ES256' +const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); +const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; +``` + +--- + +## Key Pair Resolution + +A single async helper `resolveDPoPKeyPair(alg, keyPath)` in `cli.ts`: + +### Auto-generated keys + +- **EC (ES256/ES384/ES512):** `crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, ['sign','verify'])` → export PKCS8/SPKI PEM → `WebCryptoService.importPrivateKey/importPublicKey(pem, { usage: 'sign' })` +- **RSA (RS256/RS384/RS512):** `WebCryptoService.generateSigningKeyPair()` (existing, returns RSA-2048) + +### PEM key from file (`--dpop-key`) + +1. Read the file +2. Strip PEM armor, decode DER +3. Try `crypto.subtle.importKey('pkcs8', der, { name: 'ECDSA', namedCurve }, true, ['sign'])` for each curve (P-256, P-384, P-521), then RSA fallback +4. Export successful import as JWK; strip private components (`d`, `p`, `q`, `dp`, `dq`, `qi`); import public JWK; export as SPKI PEM +5. Import both keys through `WebCryptoService.importPrivateKey/importPublicKey(pem, { usage: 'sign' })` to get the opaque `KeyPair` + +Algorithm of the loaded key is inferred automatically (the SDK's `importPrivateKey` reads the OID). + +--- + +## OpenTDF Constructor Changes + +Same pattern in both `encrypt` and `decrypt` handlers: + +```ts +const dpopKeyPair = dpopEnabled + ? await resolveDPoPKeyPair(dpopAlg, argv.dpopKey) + : undefined; + +const client = new OpenTDF({ + ...existingOptions, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, +}); +``` + +The existing interceptor in the SDK then uses these keys for every request, including the 401-nonce retry flow. + +--- + +## Type Change: `--dpop` boolean → string + +`argv.dpop` changes from `boolean | undefined` to `string | undefined`. Two places in the decrypt handler need updating: + +```ts +// Before +console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); // guarded by if (argv.dpop) +console.assert(!argv.dpop || dpopToken, 'DPoP requested but absent'); + +// After (use dpopEnabled instead of argv.dpop) +console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); // guarded by if (dpopEnabled) +console.assert(!dpopEnabled || dpopToken, 'DPoP requested but absent'); +``` + +--- + +## Validation + +- Unknown algorithm string → `CLIError` before key generation +- PEM file not found / unparseable → `CLIError` with path in message +- `--dpop-key` with a valid PEM overrides the algorithm from `--dpop` (key type wins) + +--- + +## Verification Steps + +1. `npm run build` from `cli/` — must succeed +2. `npm test` — existing logger tests must pass +3. `npx @opentdf/ctl encrypt --help | grep -i dpop` — must show both `--dpop` and `--dpop-key` +4. `node dist/src/cli.js supports dpop; echo $?` — must print `0` + +--- + +## Files Changed + +- `cli/src/cli.ts` — only file touched From 80291604513f648db17172bbde9e633515ad2fc4 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:16:24 -0400 Subject: [PATCH 03/45] docs: add DPoP CLI flags implementation plan (DSPX-3397) Signed-off-by: Dave Mihalcik --- .../plans/2026-06-09-dpop-cli-flags.md | 594 ++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-dpop-cli-flags.md diff --git a/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md b/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md new file mode 100644 index 000000000..ec38e2b13 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-dpop-cli-flags.md @@ -0,0 +1,594 @@ +# DPoP CLI Flags Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `--dpop[=alg]` and `--dpop-key ` flags to the `@opentdf/ctl` CLI so callers can enable DPoP with ES256 (default) or a specific algorithm, using an auto-generated or PEM-supplied key. + +**Architecture:** One new file (`cli/src/dpop-helpers.ts`) holds all key-management logic; `cli/src/cli.ts` changes only its option definitions and call sites. The helpers generate ECDSA keys via WebCrypto directly, then wrap them through the SDK's `importPrivateKey`/`importPublicKey` to get the opaque `KeyPair` type `OpenTDF` needs. + +**Tech Stack:** TypeScript 5, yargs 18, Node 24 WebCrypto (`crypto.subtle`), `@opentdf/sdk` (singlecontainer subpath provides `WebCryptoService` and `KeyPair`) + +--- + +## File Map + +| Path | Action | Responsibility | +|---|---|---| +| `cli/src/dpop-helpers.ts` | **Create** | DPoP key-pair generation, PEM loading, algorithm resolution | +| `cli/tests/dpop-helpers.spec.ts` | **Create** | Unit tests for the helpers | +| `cli/src/cli.ts` | **Modify** | Option definitions, enablement logic, wiring into encrypt/decrypt | + +--- + +### Task 1: Write failing tests + +**Files:** +- Create: `cli/tests/dpop-helpers.spec.ts` + +- [ ] **Step 1.1: Create the test file** + +```typescript +// cli/tests/dpop-helpers.spec.ts +import { expect } from '@esm-bundle/chai'; +import { + derToPem, + generateEphemeralDPoPKeyPair, + resolveDPoPKeyPair, +} from '../src/dpop-helpers.js'; + +describe('derToPem', function () { + it('wraps DER bytes in PEM armor with the given type', function () { + const der = new Uint8Array([0x01, 0x02, 0x03]); + const pem = derToPem(der, 'PUBLIC KEY'); + expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); + expect(pem).to.include('-----END PUBLIC KEY-----'); + expect(pem).to.include('AQID'); // base64 of [1,2,3] + }); + + it('wraps an ArrayBuffer in PEM armor', function () { + const der = new Uint8Array([0x01, 0x02]).buffer; + const pem = derToPem(der, 'PRIVATE KEY'); + expect(pem).to.include('-----BEGIN PRIVATE KEY-----'); + expect(pem).to.include('-----END PRIVATE KEY-----'); + }); +}); + +describe('generateEphemeralDPoPKeyPair', function () { + it('generates ES256 (ec:secp256r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES256'); + expect(kp.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('generates ES384 (ec:secp384r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES384'); + expect(kp.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('generates ES512 (ec:secp521r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES512'); + expect(kp.publicKey.algorithm).to.equal('ec:secp521r1'); + }); + + it('generates RS256 (rsa:2048) key pair', async function () { + this.timeout(15_000); + const kp = await generateEphemeralDPoPKeyPair('RS256'); + expect(kp.publicKey.algorithm).to.equal('rsa:2048'); + }); + + it('throws on unknown algorithm', async function () { + try { + await generateEphemeralDPoPKeyPair('HS256'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Unsupported DPoP algorithm'); + } + }); +}); + +describe('resolveDPoPKeyPair', function () { + it('returns undefined when both alg and keyPath are undefined', async function () { + const result = await resolveDPoPKeyPair(undefined, undefined); + expect(result).to.be.undefined; + }); + + it('returns an ES256 key pair when alg is ES256', async function () { + const result = await resolveDPoPKeyPair('ES256', undefined); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); + }); +}); +``` + +- [ ] **Step 1.2: Verify tests fail (module not found)** + +```bash +cd cli && npm run build 2>&1 | tail -5 +``` + +Expected: TypeScript error — `Cannot find module '../src/dpop-helpers.js'` + +--- + +### Task 2: Implement `cli/src/dpop-helpers.ts` + +**Files:** +- Create: `cli/src/dpop-helpers.ts` + +- [ ] **Step 2.1: Create the implementation file** + +```typescript +// cli/src/dpop-helpers.ts +import { readFile } from 'node:fs/promises'; +import { type KeyPair, WebCryptoService } from '@opentdf/sdk/singlecontainer'; +import { CLIError } from './logger.js'; + +const VALID_DPOP_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512'] as const; +export type DPoPAlg = (typeof VALID_DPOP_ALGS)[number]; + +const EC_CURVE_MAP: Record = { + ES256: 'P-256', + ES384: 'P-384', + ES512: 'P-521', +}; + +/** Convert a DER buffer to a PEM string with the given type label. */ +export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { + const bytes = der instanceof ArrayBuffer ? new Uint8Array(der) : der; + const b64 = btoa(String.fromCharCode(...bytes)); + const lines = b64.match(/.{1,64}/g)!.join('\n'); + return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`; +} + +/** + * Generate an ephemeral DPoP key pair for the given JWS algorithm. + * ES256/ES384/ES512 → ECDSA key via WebCrypto + SDK import. + * RS256/RS384/RS512 → RSA-2048 via SDK's generateSigningKeyPair() (all map to RS256 in DPoP proof). + */ +export async function generateEphemeralDPoPKeyPair(alg: string): Promise { + if (!VALID_DPOP_ALGS.includes(alg as DPoPAlg)) { + throw new CLIError( + 'CRITICAL', + `Unsupported DPoP algorithm: ${alg}. Valid values: ${VALID_DPOP_ALGS.join(', ')}` + ); + } + + const namedCurve = EC_CURVE_MAP[alg]; + if (namedCurve) { + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + const [privDer, pubDer] = await Promise.all([ + crypto.subtle.exportKey('pkcs8', raw.privateKey), + crypto.subtle.exportKey('spki', raw.publicKey), + ]); + const privPem = derToPem(privDer, 'PRIVATE KEY'); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + const [privateKey, publicKey] = await Promise.all([ + WebCryptoService.importPrivateKey!(privPem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; + } + + // RSA fallback — generateSigningKeyPair() produces RSA-2048 (DPoP maps this to RS256) + return WebCryptoService.generateSigningKeyPair(); +} + +/** + * Load a DPoP key pair from a PKCS8 PEM-encoded private key file. + * Derives the public key from the private key via JWK round-trip. + * Supports ECDSA (P-256, P-384, P-521) and RSA (PKCS1-v1_5 SHA-256). + */ +export async function loadDPoPKeyPairFromPem(pemPath: string): Promise { + let privatePem: string; + try { + privatePem = await readFile(pemPath, 'utf8'); + } catch (err) { + throw new CLIError('CRITICAL', `Cannot read DPoP key file: ${pemPath}`, err as Error); + } + + const b64 = privatePem.replace(/-----[\w\s]+-----|[\r\n]/g, ''); + const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + + // Try EC curves (P-256, P-384, P-521) + for (const namedCurve of ['P-256', 'P-384', 'P-521']) { + try { + const privCK = await crypto.subtle.importKey( + 'pkcs8', + der, + { name: 'ECDSA', namedCurve }, + true, + ['sign'] + ); + return await buildKeyPairFromCryptoKey(privatePem, privCK, { name: 'ECDSA', namedCurve }); + } catch { + // wrong curve or not an EC key — try next + } + } + + // Try RSA (PKCS1-v1_5 SHA-256) + try { + const privCK = await crypto.subtle.importKey( + 'pkcs8', + der, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + true, + ['sign'] + ); + return await buildKeyPairFromCryptoKey(privatePem, privCK, { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }); + } catch { + // not RSA either + } + + throw new CLIError( + 'CRITICAL', + `Cannot parse DPoP key from ${pemPath}: expected PKCS8 PEM with ECDSA (P-256/P-384/P-521) or RSA private key` + ); +} + +/** + * Derive the public key from an already-imported private CryptoKey via JWK round-trip, + * then import both through the SDK to get the opaque KeyPair type. + */ +async function buildKeyPairFromCryptoKey( + privatePem: string, + privCK: CryptoKey, + algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams +): Promise { + // Export private key as JWK; strip private components to build the public JWK + const privJwk = await crypto.subtle.exportKey('jwk', privCK); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { d, p, q, dp, dq, qi, ...pubJwkProps } = privJwk; + const pubJwk: JsonWebKey = { ...pubJwkProps, key_ops: ['verify'] }; + + const pubCK = await crypto.subtle.importKey('jwk', pubJwk, algorithm, true, ['verify']); + const pubDer = await crypto.subtle.exportKey('spki', pubCK); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + + const [privateKey, publicKey] = await Promise.all([ + WebCryptoService.importPrivateKey!(privatePem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; +} + +/** + * Main entry point: resolve a DPoP KeyPair from CLI arguments. + * Returns undefined if DPoP is not requested. + */ +export async function resolveDPoPKeyPair( + alg: string | undefined, + keyPath: string | undefined +): Promise { + if (keyPath) { + return loadDPoPKeyPairFromPem(keyPath); + } + if (alg) { + return generateEphemeralDPoPKeyPair(alg); + } + return undefined; +} +``` + +- [ ] **Step 2.2: Run tests to verify they pass** + +```bash +cd cli && npm test 2>&1 | tail -20 +``` + +Expected: All `dpop-helpers` tests pass. The logger tests also still pass. + +- [ ] **Step 2.3: Commit** + +```bash +cd cli && git add src/dpop-helpers.ts tests/dpop-helpers.spec.ts && git commit -m "feat(cli): add DPoP key pair helpers (DSPX-3397)" +``` + +--- + +### Task 3: Update CLI option definitions in `cli.ts` + +**Files:** +- Modify: `cli/src/cli.ts` + +- [ ] **Step 3.1: Add import for dpop helpers and `readFile`** + +At the top of `cli/src/cli.ts`, change: + +```typescript +// Before: +import { type KeyPair } from '@opentdf/sdk/singlecontainer'; + +// After: +import { type KeyPair } from '@opentdf/sdk/singlecontainer'; +import { resolveDPoPKeyPair } from './dpop-helpers.js'; +``` + +- [ ] **Step 3.2: Replace the `--dpop` boolean option with a string option, add `--dpop-key`** + +Find this block (around line 320 in the global options): + +```typescript + .option('dpop', { + group: 'Security:', + desc: 'Use DPoP for token binding', + type: 'boolean', + }) +``` + +Replace with: + +```typescript + .option('dpop', { + group: 'Security:', + desc: 'Enable DPoP token binding. Optional value selects algorithm: ES256 (default), ES384, ES512, RS256. Use --dpop=ES512 to specify.', + type: 'string', + }) + .option('dpopKey', { + alias: 'dpop-key', + group: 'Security:', + desc: 'Path to PEM-encoded PKCS8 private key for DPoP signing. Enables DPoP alone if --dpop is omitted.', + type: 'string', + }) +``` + +- [ ] **Step 3.3: Build to verify no type errors** + +```bash +cd cli && npm run build 2>&1 | grep -E "error|Error" | head -20 +``` + +Expected: No errors. (TypeScript will now treat `argv.dpop` as `string | undefined` instead of `boolean | undefined` — we'll fix the call sites in the next tasks.) + +--- + +### Task 4: Wire DPoP into the `encrypt` command + +**Files:** +- Modify: `cli/src/cli.ts` — the `encrypt` command handler + +- [ ] **Step 4.1: Add DPoP enablement logic and key resolution before creating `OpenTDF`** + +Find the `encrypt` command handler (around line 600). It currently starts like: + +```typescript + async (argv) => { + log('DEBUG', 'Running encrypt command'); + const authProvider = await processAuth(argv); + log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); + const guessedPolicyEndpoint = guessPolicyUrl(argv); + + const client = new OpenTDF({ + authProvider, + defaultCreateOptions: { + defaultKASEndpoint: argv.kasEndpoint, + }, + disableDPoP: !argv.dpop, + policyEndpoint: guessedPolicyEndpoint, + platformUrl: argv.platformUrl || guessedPolicyEndpoint, + }); +``` + +Replace with: + +```typescript + async (argv) => { + log('DEBUG', 'Running encrypt command'); + const authProvider = await processAuth(argv); + log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); + const guessedPolicyEndpoint = guessPolicyUrl(argv); + + const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; + const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + + const client = new OpenTDF({ + authProvider, + defaultCreateOptions: { + defaultKASEndpoint: argv.kasEndpoint, + }, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, + policyEndpoint: guessedPolicyEndpoint, + platformUrl: argv.platformUrl || guessedPolicyEndpoint, + }); +``` + +- [ ] **Step 4.2: Build to verify** + +```bash +cd cli && npm run build 2>&1 | grep -E "error|Error" | head -20 +``` + +Expected: No errors. + +--- + +### Task 5: Wire DPoP into the `decrypt` command + +**Files:** +- Modify: `cli/src/cli.ts` — the `decrypt` command handler + +- [ ] **Step 5.1: Add DPoP enablement logic and fix DPoP assertions** + +Find the `decrypt` command handler. It currently starts with: + +```typescript + async (argv) => { + log('DEBUG', 'Running decrypt command'); + const allowedKases = argv.allowList?.split(','); + log('DEBUG', `Allowed KASes: ${allowedKases}`); + const ignoreAllowList = !!argv.ignoreAllowList; + if (!argv.oidcEndpoint) { + throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); + } + const authProvider = await processAuth(argv); + log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); + const guessedPolicyEndpoint = guessPolicyUrl(argv); + const client = new OpenTDF({ + authProvider, + defaultCreateOptions: { + defaultKASEndpoint: argv.kasEndpoint, + }, + defaultReadOptions: { + allowedKASEndpoints: allowedKases, + ignoreAllowlist: ignoreAllowList, + noVerify: !!argv.noVerifyAssertions, + }, + disableDPoP: !argv.dpop, + policyEndpoint: guessedPolicyEndpoint, + platformUrl: argv.platformUrl || guessedPolicyEndpoint, + }); +``` + +Replace with: + +```typescript + async (argv) => { + log('DEBUG', 'Running decrypt command'); + const allowedKases = argv.allowList?.split(','); + log('DEBUG', `Allowed KASes: ${allowedKases}`); + const ignoreAllowList = !!argv.ignoreAllowList; + if (!argv.oidcEndpoint) { + throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); + } + const authProvider = await processAuth(argv); + log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); + const guessedPolicyEndpoint = guessPolicyUrl(argv); + + const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; + const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + + const client = new OpenTDF({ + authProvider, + defaultCreateOptions: { + defaultKASEndpoint: argv.kasEndpoint, + }, + defaultReadOptions: { + allowedKASEndpoints: allowedKases, + ignoreAllowlist: ignoreAllowList, + noVerify: !!argv.noVerifyAssertions, + }, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, + policyEndpoint: guessedPolicyEndpoint, + platformUrl: argv.platformUrl || guessedPolicyEndpoint, + }); +``` + +- [ ] **Step 5.2: Fix DPoP token assertions in the decrypt command** + +In the same `decrypt` handler, find the two DPoP assertion lines (inside the `for` loop over headers and after it). Change both from `argv.dpop` to `dpopEnabled`: + +```typescript + // Before: + if (argv.dpop) { + console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); + } + + // After: + if (dpopEnabled) { + console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); + } +``` + +```typescript + // Before: + console.assert(!argv.dpop || dpopToken, 'DPoP requested but absent'); + + // After: + console.assert(!dpopEnabled || dpopToken, 'DPoP requested but absent'); +``` + +- [ ] **Step 5.3: Build to verify no remaining type errors** + +```bash +cd cli && npm run build 2>&1 | grep -E "error|Error" | head -20 +``` + +Expected: No errors. + +- [ ] **Step 5.4: Run all tests** + +```bash +cd cli && npm test 2>&1 | tail -20 +``` + +Expected: All tests pass (logger + dpop-helpers). + +- [ ] **Step 5.5: Commit** + +```bash +git add cli/src/cli.ts && git commit -m "feat(cli): add --dpop[=alg] and --dpop-key flags for DPoP support (DSPX-3397)" +``` + +--- + +### Task 6: Smoke test and final push + +**Files:** none changed + +- [ ] **Step 6.1: Verify help output contains dpop** + +```bash +cd cli && node dist/src/cli.js encrypt --help | grep -i dpop +``` + +Expected output (both lines must appear): +``` + --dpop Enable DPoP token binding. Optional value selects algorithm... + --dpop-key Path to PEM-encoded PKCS8 private key for DPoP signing... +``` + +- [ ] **Step 6.2: Verify `supports dpop` exits 0** + +```bash +cd cli && node dist/src/cli.js supports dpop; echo "exit: $?" +``` + +Expected: `exit: 0` + +- [ ] **Step 6.3: Verify `--dpop` parses without error** + +```bash +cd cli && node dist/src/cli.js encrypt --dpop --help 2>&1 | grep -i dpop +``` + +Expected: no parse errors, dpop flags appear in help. + +- [ ] **Step 6.4: Push to remote** + +```bash +git push origin DSPX-3397-web-sdk +``` + +Expected: Push succeeds. Pre-commit hooks (prettier, eslint) run during the earlier commits — if they fail, run `npm run format && npm run lint` in `cli/` and re-commit. + +--- + +## Self-Review + +**Spec coverage:** +- `--dpop` (no value → ES256) ✓ Task 3 + `dpopAlg = argv.dpop || 'ES256'` +- `--dpop=` (specific algorithm) ✓ Task 3, yargs string type captures `=value` +- `--dpop-key ` (PEM key) ✓ Task 3, Task 2 `loadDPoPKeyPairFromPem` +- `--dpop-key` alone enables DPoP ✓ `dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey` +- Help text mentions "dpop" ✓ both option descriptions contain the word +- Wire into existing interceptor ✓ `dpopKeys` passed to `OpenTDF` which feeds the existing `authTokenDPoPInterceptor` +- Don't reimplement nonce-retry ✓ interceptor unchanged +- No new auth client ✓ +- `npm run build` + `npm test` verification ✓ Task 2 and Task 6 +- Smoke `grep -i dpop` ✓ Task 6 step 1 +- `feat(cli):` commit convention ✓ Task 5 step 5 commit message + +**Placeholder scan:** None found. + +**Type consistency:** +- `resolveDPoPKeyPair(alg, keyPath)` — defined in Task 2, used identically in Tasks 4 and 5 ✓ +- `dpopAlg`, `dpopEnabled`, `dpopKeyPair` — defined and used within the same handler in each task ✓ +- `WebCryptoService.importPrivateKey!` — non-null assertion consistent in both usages within `dpop-helpers.ts` ✓ From 7258b84bf07da62d27987671ccea497aa6fcaee3 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:22:11 -0400 Subject: [PATCH 04/45] feat(cli): add DPoP key pair helpers (DSPX-3397) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- cli/src/dpop-helpers.ts | 156 +++++++++++++++++++++++++++++++++ cli/tests/dpop-helpers.spec.ts | 69 +++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 cli/src/dpop-helpers.ts create mode 100644 cli/tests/dpop-helpers.spec.ts diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts new file mode 100644 index 000000000..0e1e2a97c --- /dev/null +++ b/cli/src/dpop-helpers.ts @@ -0,0 +1,156 @@ +// cli/src/dpop-helpers.ts +import { readFile } from 'node:fs/promises'; +import { type webcrypto } from 'node:crypto'; +import { type KeyPair, WebCryptoService } from '@opentdf/sdk/singlecontainer'; +import { CLIError } from './logger.js'; + +const VALID_DPOP_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'RS384', 'RS512'] as const; +export type DPoPAlg = (typeof VALID_DPOP_ALGS)[number]; + +const EC_CURVE_MAP: Record = { + ES256: 'P-256', + ES384: 'P-384', + ES512: 'P-521', +}; + +/** Convert a DER buffer to a PEM string with the given type label. */ +export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { + const bytes = der instanceof ArrayBuffer ? new Uint8Array(der) : der; + const b64 = btoa(String.fromCharCode(...bytes)); + const lines = b64.match(/.{1,64}/g)!.join('\n'); + return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`; +} + +/** + * Generate an ephemeral DPoP key pair for the given JWS algorithm. + * ES256/ES384/ES512 → ECDSA key via WebCrypto + SDK import. + * RS256/RS384/RS512 → RSA-2048 via SDK's generateSigningKeyPair() (all map to RS256 in DPoP proof). + */ +export async function generateEphemeralDPoPKeyPair(alg: string): Promise { + if (!VALID_DPOP_ALGS.includes(alg as DPoPAlg)) { + throw new CLIError( + 'CRITICAL', + `Unsupported DPoP algorithm: ${alg}. Valid values: ${VALID_DPOP_ALGS.join(', ')}` + ); + } + + const namedCurve = EC_CURVE_MAP[alg]; + if (namedCurve) { + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + const [privDer, pubDer] = await Promise.all([ + crypto.subtle.exportKey('pkcs8', raw.privateKey), + crypto.subtle.exportKey('spki', raw.publicKey), + ]); + const privPem = derToPem(privDer, 'PRIVATE KEY'); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + const [privateKey, publicKey] = await Promise.all([ + WebCryptoService.importPrivateKey!(privPem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; + } + + // RSA fallback — generateSigningKeyPair() produces RSA-2048 (DPoP maps this to RS256) + return WebCryptoService.generateSigningKeyPair(); +} + +/** + * Load a DPoP key pair from a PKCS8 PEM-encoded private key file. + * Derives the public key from the private key via JWK round-trip. + * Supports ECDSA (P-256, P-384, P-521) and RSA (PKCS1-v1_5 SHA-256). + */ +export async function loadDPoPKeyPairFromPem(pemPath: string): Promise { + let privatePem: string; + try { + privatePem = await readFile(pemPath, 'utf8'); + } catch (err) { + throw new CLIError('CRITICAL', `Cannot read DPoP key file: ${pemPath}`, err as Error); + } + + const b64 = privatePem.replace(/-----[\w\s]+-----|[\r\n]/g, ''); + const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + + // Try EC curves (P-256, P-384, P-521) + for (const namedCurve of ['P-256', 'P-384', 'P-521']) { + try { + const privCK = await crypto.subtle.importKey( + 'pkcs8', + der, + { name: 'ECDSA', namedCurve }, + true, + ['sign'] + ); + return await buildKeyPairFromCryptoKey(privatePem, privCK, { name: 'ECDSA', namedCurve }); + } catch { + // wrong curve or not an EC key — try next + } + } + + // Try RSA (PKCS1-v1_5 SHA-256) + try { + const privCK = await crypto.subtle.importKey( + 'pkcs8', + der, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + true, + ['sign'] + ); + return await buildKeyPairFromCryptoKey(privatePem, privCK, { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }); + } catch { + // not RSA either + } + + throw new CLIError( + 'CRITICAL', + `Cannot parse DPoP key from ${pemPath}: expected PKCS8 PEM with ECDSA (P-256/P-384/P-521) or RSA private key` + ); +} + +/** + * Derive the public key from an already-imported private CryptoKey via JWK round-trip, + * then import both through the SDK to get the opaque KeyPair type. + */ +async function buildKeyPairFromCryptoKey( + privatePem: string, + privCK: webcrypto.CryptoKey, + algorithm: webcrypto.AlgorithmIdentifier | webcrypto.RsaHashedImportParams | webcrypto.EcKeyImportParams +): Promise { + // Export private key as JWK; strip private components to build the public JWK + const privJwk = await crypto.subtle.exportKey('jwk', privCK); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { d, p, q, dp, dq, qi, ...pubJwkProps } = privJwk; + const pubJwk: webcrypto.JsonWebKey = { ...pubJwkProps, key_ops: ['verify'] }; + + const pubCK = await crypto.subtle.importKey('jwk', pubJwk, algorithm, true, ['verify']); + const pubDer = await crypto.subtle.exportKey('spki', pubCK); + const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + + const [privateKey, publicKey] = await Promise.all([ + WebCryptoService.importPrivateKey!(privatePem, { usage: 'sign', extractable: true }), + WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; +} + +/** + * Main entry point: resolve a DPoP KeyPair from CLI arguments. + * Returns undefined if DPoP is not requested. + */ +export async function resolveDPoPKeyPair( + alg: string | undefined, + keyPath: string | undefined +): Promise { + if (keyPath) { + return loadDPoPKeyPairFromPem(keyPath); + } + if (alg) { + return generateEphemeralDPoPKeyPair(alg); + } + return undefined; +} diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts new file mode 100644 index 000000000..a298f29ca --- /dev/null +++ b/cli/tests/dpop-helpers.spec.ts @@ -0,0 +1,69 @@ +// cli/tests/dpop-helpers.spec.ts +import { expect } from '@esm-bundle/chai'; +import { + derToPem, + generateEphemeralDPoPKeyPair, + resolveDPoPKeyPair, +} from '../src/dpop-helpers.js'; + +describe('derToPem', function () { + it('wraps DER bytes in PEM armor with the given type', function () { + const der = new Uint8Array([0x01, 0x02, 0x03]); + const pem = derToPem(der, 'PUBLIC KEY'); + expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); + expect(pem).to.include('-----END PUBLIC KEY-----'); + expect(pem).to.include('AQID'); // base64 of [1,2,3] + }); + + it('wraps an ArrayBuffer in PEM armor', function () { + const der = new Uint8Array([0x01, 0x02]).buffer; + const pem = derToPem(der, 'PRIVATE KEY'); + expect(pem).to.include('-----BEGIN PRIVATE KEY-----'); + expect(pem).to.include('-----END PRIVATE KEY-----'); + }); +}); + +describe('generateEphemeralDPoPKeyPair', function () { + it('generates ES256 (ec:secp256r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES256'); + expect(kp.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('generates ES384 (ec:secp384r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES384'); + expect(kp.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('generates ES512 (ec:secp521r1) key pair', async function () { + const kp = await generateEphemeralDPoPKeyPair('ES512'); + expect(kp.publicKey.algorithm).to.equal('ec:secp521r1'); + }); + + it('generates RS256 (rsa:2048) key pair', async function () { + this.timeout(15_000); + const kp = await generateEphemeralDPoPKeyPair('RS256'); + expect(kp.publicKey.algorithm).to.equal('rsa:2048'); + }); + + it('throws on unknown algorithm', async function () { + try { + await generateEphemeralDPoPKeyPair('HS256'); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Unsupported DPoP algorithm'); + } + }); +}); + +describe('resolveDPoPKeyPair', function () { + it('returns undefined when both alg and keyPath are undefined', async function () { + const result = await resolveDPoPKeyPair(undefined, undefined); + expect(result).to.be.undefined; + }); + + it('returns an ES256 key pair when alg is ES256', async function () { + const result = await resolveDPoPKeyPair('ES256', undefined); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); + }); +}); From 3b07386900552c054f922e42970de4bdfc7a9dbd Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:52:23 -0400 Subject: [PATCH 05/45] fix(cli): guard derToPem empty input, warn on RS384/RS512 downgrade Signed-off-by: Dave Mihalcik --- cli/src/dpop-helpers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index 0e1e2a97c..d4ce738ae 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -17,7 +17,7 @@ const EC_CURVE_MAP: Record = { export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { const bytes = der instanceof ArrayBuffer ? new Uint8Array(der) : der; const b64 = btoa(String.fromCharCode(...bytes)); - const lines = b64.match(/.{1,64}/g)!.join('\n'); + const lines = b64.match(/.{1,64}/g)?.join('\n') ?? b64; return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`; } @@ -34,6 +34,12 @@ export async function generateEphemeralDPoPKeyPair(alg: string): Promise Date: Tue, 9 Jun 2026 13:54:17 -0400 Subject: [PATCH 06/45] feat(cli): change --dpop to string type, add --dpop-key option (DSPX-3397) Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 69a283287..3378d6d1a 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -21,6 +21,8 @@ import { CLIError, Level, log } from './logger.js'; import * as assertions from '@opentdf/sdk/assertions'; import { base64 } from '@opentdf/sdk/encodings'; import { type KeyPair } from '@opentdf/sdk/singlecontainer'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in Task 4+5 +import { resolveDPoPKeyPair as _resolveDPoPKeyPair } from './dpop-helpers.js'; type AuthToProcess = { auth?: string; @@ -393,8 +395,14 @@ export const handleArgs = (args: string[]) => { }) .option('dpop', { group: 'Security:', - desc: 'Use DPoP for token binding', - type: 'boolean', + desc: 'Enable DPoP token binding. Optional value selects algorithm: ES256 (default), ES384, ES512, RS256. Use --dpop=ES512 to specify.', + type: 'string', + }) + .option('dpopKey', { + alias: 'dpop-key', + group: 'Security:', + desc: 'Path to PEM-encoded PKCS8 private key for DPoP signing. Enables DPoP alone if --dpop is omitted.', + type: 'string', }) .implies('auth', '--no-clientId') .implies('auth', '--no-clientSecret') From b77182d1c8d883134264422b44f10724c8547c66 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 9 Jun 2026 13:56:08 -0400 Subject: [PATCH 07/45] feat(cli): wire --dpop and --dpop-key into encrypt/decrypt commands (DSPX-3397) Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 3378d6d1a..21c9b711b 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -590,6 +590,10 @@ export const handleArgs = (args: string[]) => { const authProvider = await processAuth(argv); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); + const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; + const dpopKeyPair = await _resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + const client = new OpenTDF({ authProvider, defaultCreateOptions: { @@ -600,7 +604,8 @@ export const handleArgs = (args: string[]) => { ignoreAllowlist: ignoreAllowList, noVerify: !!argv.noVerifyAssertions, }, - disableDPoP: !argv.dpop, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, policyEndpoint: guessedPolicyEndpoint, platformUrl: argv.platformUrl || guessedPolicyEndpoint, }); @@ -626,14 +631,14 @@ export const handleArgs = (args: string[]) => { console.assert(!accessToken, 'Multiple authorization headers found'); accessToken = parseJwt(lastRequest.headers[h].split(' ')[1]); log('INFO', `Access Token: ${JSON.stringify(accessToken)}`); - if (argv.dpop) { + if (dpopEnabled) { console.assert(accessToken.cnf?.jkt, 'Access token must have a cnf.jkt'); } break; } } console.assert(accessToken, 'No access_token found'); - console.assert(!argv.dpop || dpopToken, 'DPoP requested but absent'); + console.assert(!dpopEnabled || dpopToken, 'DPoP requested but absent'); } finally { client.close(); } @@ -654,12 +659,17 @@ export const handleArgs = (args: string[]) => { log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); + const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; + const dpopKeyPair = await _resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + const client = new OpenTDF({ authProvider, defaultCreateOptions: { defaultKASEndpoint: argv.kasEndpoint, }, - disableDPoP: !argv.dpop, + disableDPoP: !dpopEnabled, + dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined, policyEndpoint: guessedPolicyEndpoint, platformUrl: argv.platformUrl || guessedPolicyEndpoint, }); From ce59a5353abdfc1e88f24b3a3d378c704f7c3ca3 Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:01:12 +0000 Subject: [PATCH 08/45] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 4 ++-- cli/src/dpop-helpers.ts | 5 ++++- cli/tests/dpop-helpers.spec.ts | 6 +----- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 21c9b711b..b1e237279 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -590,7 +590,7 @@ export const handleArgs = (args: string[]) => { const authProvider = await processAuth(argv); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); - const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; const dpopKeyPair = await _resolveDPoPKeyPair(dpopAlg, argv.dpopKey); @@ -659,7 +659,7 @@ export const handleArgs = (args: string[]) => { log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); - const dpopAlg = argv.dpop === undefined ? undefined : (argv.dpop || 'ES256'); + const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; const dpopKeyPair = await _resolveDPoPKeyPair(dpopAlg, argv.dpopKey); diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index d4ce738ae..b0a764a3f 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -125,7 +125,10 @@ export async function loadDPoPKeyPairFromPem(pemPath: string): Promise async function buildKeyPairFromCryptoKey( privatePem: string, privCK: webcrypto.CryptoKey, - algorithm: webcrypto.AlgorithmIdentifier | webcrypto.RsaHashedImportParams | webcrypto.EcKeyImportParams + algorithm: + | webcrypto.AlgorithmIdentifier + | webcrypto.RsaHashedImportParams + | webcrypto.EcKeyImportParams ): Promise { // Export private key as JWK; strip private components to build the public JWK const privJwk = await crypto.subtle.exportKey('jwk', privCK); diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts index a298f29ca..b046d7af5 100644 --- a/cli/tests/dpop-helpers.spec.ts +++ b/cli/tests/dpop-helpers.spec.ts @@ -1,10 +1,6 @@ // cli/tests/dpop-helpers.spec.ts import { expect } from '@esm-bundle/chai'; -import { - derToPem, - generateEphemeralDPoPKeyPair, - resolveDPoPKeyPair, -} from '../src/dpop-helpers.js'; +import { derToPem, generateEphemeralDPoPKeyPair, resolveDPoPKeyPair } from '../src/dpop-helpers.js'; describe('derToPem', function () { it('wraps DER bytes in PEM armor with the given type', function () { From 9eae720d009c76513174e0335a4dcfb9d21cdaaa Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 08:34:03 -0400 Subject: [PATCH 09/45] fix(dpop): address code review feedback on nonce retry logic and defensive handling (DSPX-3397) - interceptors.ts: use serverNonce !== cachedNonce to allow retry on nonce rotation, not just first nonce - interceptors.ts: optional-chain err.metadata to prevent TypeError crash on absent metadata - dpop-nonce.ts: guard extractNonce against null/non-standard headers (test-env robustness) - oidc.ts: hoist cachedNonce outside dpopEnabled block; skip retry when server returns same nonce - cli.ts: remove redundant process.exit(0) from supports command handler; yargs choices handles validation Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 10 ++-------- lib/src/auth/dpop-nonce.ts | 5 ++--- lib/src/auth/interceptors.ts | 8 ++++---- lib/src/auth/oidc.ts | 6 +++--- 4 files changed, 11 insertions(+), 18 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index b1e237279..a3efa2fd4 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -528,14 +528,8 @@ export const handleArgs = (args: string[]) => { choices: ['dpop'], }); }, - async (argv) => { - const feature = argv.feature as string; - if (feature === 'dpop') { - // DPoP is supported - exit 0 - process.exit(0); - } - // Unknown feature - exit 1 - process.exit(1); + async () => { + // yargs choices validation ensures feature is supported; return naturally exits 0 } ) diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 5b6762677..b1dad54ad 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -31,9 +31,8 @@ export class DPoPNonceCache { /** * Extract DPoP-Nonce from response headers (case-insensitive). */ - static extractNonce(headers: Headers): string | undefined { - // Headers.get() is case-insensitive per spec - return headers.get('dpop-nonce') || undefined; + static extractNonce(headers?: Headers): string | undefined { + return typeof headers?.get === 'function' ? headers.get('dpop-nonce') || undefined : undefined; } } diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index c22ffae39..da504238c 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -123,11 +123,11 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI err.code === 16 && // Code.Unauthenticated 'metadata' in err ) { - const metadata = err.metadata as { get?: (key: string) => string | null }; - const serverNonce = metadata.get?.('dpop-nonce'); + const metadata = err.metadata as { get?: (key: string) => string | null } | undefined; + const serverNonce = metadata?.get?.('dpop-nonce'); - if (serverNonce && !cachedNonce) { - // Server sent a nonce and we didn't have one cached + if (serverNonce && serverNonce !== cachedNonce) { + // Server sent a new nonce (or we didn't have one cached) // Cache it and retry once globalNonceCache.set(origin, serverNonce); diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 47586c19f..5c0e4efc8 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -185,6 +185,7 @@ export class AccessToken { Accept: 'application/json', }; // add DPoP headers if configured + let cachedNonce: string | undefined; if (this.config.dpopEnabled) { if (!this.signingKey) { throw new ConfigurationError('No signature configured'); @@ -195,8 +196,7 @@ export class AccessToken { // platform Keycloak mapper (lib/fixtures/keycloak.go `client.publickey`). headers['X-VirtruPubKey'] = base64.encode(publicKeyPem); - // Get cached nonce for token endpoint - const cachedNonce = globalNonceCache.get(origin); + cachedNonce = globalNonceCache.get(origin); headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST', cachedNonce); } @@ -209,7 +209,7 @@ export class AccessToken { // Handle DPoP-Nonce retry on 401 if (this.config.dpopEnabled && response.status === 401) { const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce) { + if (responseNonce && responseNonce !== cachedNonce) { // Cache the server-provided nonce and retry globalNonceCache.set(origin, responseNonce); From 0757686860fe743cc5b565182f25ad17f60b80f8 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 09:20:32 -0400 Subject: [PATCH 10/45] test(dpop): add DPoP nonce challenge to mock server and cover retry logic (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dpop-nonce.ts: add clearAll() to DPoPNonceCache for test teardown - server.ts: add /protocol/openid-connect/token endpoint that issues a DPoP-Nonce challenge (fixed nonce 'dpop-test-nonce-abc') when the incoming DPoP proof has no nonce, accepts on retry with correct nonce - tests/web/auth/dpop-nonce.test.ts: WTR unit tests covering doPost() nonce retry (via mock fetch) and authTokenDPoPInterceptor nonce retry (via mock next), including no-retry-on-same-nonce regression cases - tests/mocha/dpop-nonce.spec.ts: Mocha integration tests hitting the real server — verifies transparent retry, nonce cache population, and pre-cached nonce path Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- lib/src/auth/dpop-nonce.ts | 7 ++ lib/tests/mocha/dpop-nonce.spec.ts | 80 +++++++++++++ lib/tests/server.ts | 24 ++++ lib/tests/web/auth/dpop-nonce.test.ts | 166 ++++++++++++++++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 lib/tests/mocha/dpop-nonce.spec.ts create mode 100644 lib/tests/web/auth/dpop-nonce.test.ts diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index b1dad54ad..16c7b3c61 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -28,6 +28,13 @@ export class DPoPNonceCache { this.cache.delete(origin); } + /** + * Clear all cached nonces. Useful for test teardown. + */ + clearAll(): void { + this.cache.clear(); + } + /** * Extract DPoP-Nonce from response headers (case-insensitive). */ diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts new file mode 100644 index 000000000..a464f89e3 --- /dev/null +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -0,0 +1,80 @@ +import { expect } from 'chai'; +import { AccessToken } from '../../src/auth/oidc.js'; +import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; +import { DefaultCryptoService, generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce issued by server.ts /protocol/openid-connect/token endpoint +const SERVER_NONCE = 'dpop-test-nonce-abc'; + +describe('DPoP nonce challenge — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + it('transparently retries with server-issued nonce and returns 200', async () => { + const accessToken = new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + // No fetch override: uses global fetch (Node 18+) against the real server + ); + + // doPost sends the initial request (no nonce), gets 401 + DPoP-Nonce, + // then automatically retries with the nonce and receives 200. + const response = await accessToken.doPost(TOKEN_URL, { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + }); + + expect(response.status).to.equal(200); + const body = (await response.json()) as { access_token: string }; + expect(body.access_token).to.equal('test-dpop-token'); + + // Cache must be populated with the server's nonce after the round-trip + expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); + + it('uses cached nonce on the first request after a prior successful challenge', async () => { + // Pre-seed cache as if a prior request already populated it + globalNonceCache.set(SERVER_ORIGIN, SERVER_NONCE); + + const accessToken = new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: SERVER_ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService + ); + + // With the correct nonce already cached, the first request should succeed directly (no retry). + const response = await accessToken.doPost(TOKEN_URL, { + grant_type: 'client_credentials', + client_id: 'test-client', + client_secret: 'test-secret', + }); + + expect(response.status).to.equal(200); + }); +}); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 6f389b201..1a1c12249 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -470,6 +470,30 @@ const kas: RequestListener = async (req, res) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ status: 'ok' })); return; + } else if (url.pathname === '/protocol/openid-connect/token') { + // DPoP nonce challenge test endpoint — simulates a Keycloak token endpoint. + // Always challenges the first request (no nonce in DPoP JWT) with a fixed nonce. + // Accepts the retry once the DPoP proof includes the expected nonce. + const DPOP_TEST_NONCE = 'dpop-test-nonce-abc'; + const dpopHeader = req.headers['dpop'] as string | undefined; + if (!dpopHeader) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid_request', error_description: 'DPoP header required' })); + return; + } + const dpopPayload = jose.decodeJwt(dpopHeader); + if (dpopPayload.nonce !== DPOP_TEST_NONCE) { + res.writeHead(401, { + 'Content-Type': 'application/json', + 'DPoP-Nonce': DPOP_TEST_NONCE, + }); + res.end(JSON.stringify({ error: 'use_dpop_nonce', error_description: 'DPoP nonce required' })); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ access_token: 'test-dpop-token', token_type: 'DPoP', expires_in: 3600 })); + return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); res.statusCode = 404; diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts new file mode 100644 index 000000000..2f6600c65 --- /dev/null +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -0,0 +1,166 @@ +import { expect } from '@esm-bundle/chai'; +import { stub } from 'sinon'; +import { AccessToken } from '../../../src/auth/oidc.js'; +import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; +import { authTokenDPoPInterceptor } from '../../../src/auth/interceptors.js'; +import { DefaultCryptoService, generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; + +/** Decode JWT payload without verification (base64url → JSON). */ +function decodeJwtPayload(jwt: string): Record { + const b64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '='); + return JSON.parse(atob(padded)); +} + +// ── AccessToken.doPost nonce retry ────────────────────────────────────────── + +describe('AccessToken.doPost DPoP-Nonce retry', () => { + const ORIGIN = 'http://localhost:3000'; + const TOKEN_URL = `${ORIGIN}/protocol/openid-connect/token`; + const NONCE = 'server-nonce-xyz'; + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + function makeAccessToken(fetchStub: typeof fetch) { + return new AccessToken( + { + clientId: 'test-client', + clientSecret: 'test-secret', + exchange: 'client', + oidcOrigin: ORIGIN, + dpopEnabled: true, + signingKey: keyPair, + }, + DefaultCryptoService, + fetchStub + ); + } + + it('retries with nonce when server responds 401 with DPoP-Nonce header', async () => { + const fetchStub = stub(); + // First call: 401 challenge with DPoP-Nonce header + fetchStub.onFirstCall().resolves({ + status: 401, + ok: false, + headers: new Headers({ 'DPoP-Nonce': NONCE }), + } as Response); + // Second call: 200 success + fetchStub.onSecondCall().resolves({ + status: 200, + ok: true, + headers: new Headers(), + json: stub().resolves({ access_token: 'test-token' }), + } as unknown as Response); + + const accessToken = makeAccessToken(fetchStub as unknown as typeof fetch); + const result = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials' }); + + expect(fetchStub.callCount).to.equal(2); + expect(result.status).to.equal(200); + expect(globalNonceCache.get(ORIGIN)).to.equal(NONCE); + + // Second request's DPoP proof must include the nonce + const secondInit = fetchStub.secondCall.args[1] as RequestInit; + const secondHeaders = secondInit.headers as Record; + const retryPayload = decodeJwtPayload(secondHeaders['DPoP']); + expect(retryPayload.nonce).to.equal(NONCE); + }); + + it('does not retry when server returns the same nonce already cached', async () => { + // Pre-seed the cache with the same nonce the server will return + globalNonceCache.set(ORIGIN, NONCE); + + const fetchStub = stub().resolves({ + status: 401, + ok: false, + headers: new Headers({ 'DPoP-Nonce': NONCE }), + } as Response); + + const accessToken = makeAccessToken(fetchStub as unknown as typeof fetch); + const result = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials' }); + + // No retry — same nonce means we'd loop; return the 401 to the caller + expect(fetchStub.callCount).to.equal(1); + expect(result.status).to.equal(401); + }); +}); + +// ── authTokenDPoPInterceptor nonce retry ──────────────────────────────────── + +describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { + const ORIGIN = 'http://localhost:3000'; + const REQUEST_URL = `${ORIGIN}/kas.AccessService/Rewrap`; + const NONCE = 'interceptor-nonce-abc'; + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + function makeInterceptor() { + return authTokenDPoPInterceptor({ + tokenProvider: async () => 'dummy-access-token', + dpopKeys: Promise.resolve(keyPair), + }); + } + + function makeMockReq() { + return { header: new Headers(), url: REQUEST_URL } as Parameters< + ReturnType> + >[0]; + } + + it('retries with nonce when interceptor catches a code-16 error with dpop-nonce metadata', async () => { + const mockNext = stub(); + // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata + mockNext.onFirstCall().callsFake(() => + Promise.reject({ code: 16, metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) } }) + ); + // Second call: success + mockNext.onSecondCall().resolves({ header: { get: () => null } }); + + const interceptor = makeInterceptor(); + await interceptor(mockNext as Parameters[0])(makeMockReq()); + + expect(mockNext.callCount).to.equal(2); + expect(globalNonceCache.get(ORIGIN)).to.equal(NONCE); + + // Retry request must have nonce in its DPoP proof + const retryReq = mockNext.secondCall.firstArg as { header: Headers }; + const retryDpopJwt = retryReq.header.get('DPoP')!; + const retryPayload = decodeJwtPayload(retryDpopJwt); + expect(retryPayload.nonce).to.equal(NONCE); + }); + + it('does not retry when server returns the same nonce already cached', async () => { + globalNonceCache.set(ORIGIN, NONCE); + + const mockNext = stub().callsFake(() => + Promise.reject({ code: 16, metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) } }) + ); + + const interceptor = makeInterceptor(); + try { + await interceptor(mockNext as Parameters[0])(makeMockReq()); + expect.fail('should have thrown'); + } catch (err) { + // Expected: interceptor re-throws when nonce unchanged + } + + expect(mockNext.callCount).to.equal(1); + }); +}); From 4cba0ac9a9fee468d2373eaa14df1c00e16a2004 Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:21:33 +0000 Subject: [PATCH 11/45] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- lib/tests/server.ts | 12 +++++++++--- lib/tests/web/auth/dpop-nonce.test.ts | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 1a1c12249..3da6b4b34 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -478,7 +478,9 @@ const kas: RequestListener = async (req, res) => { const dpopHeader = req.headers['dpop'] as string | undefined; if (!dpopHeader) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'invalid_request', error_description: 'DPoP header required' })); + res.end( + JSON.stringify({ error: 'invalid_request', error_description: 'DPoP header required' }) + ); return; } const dpopPayload = jose.decodeJwt(dpopHeader); @@ -487,12 +489,16 @@ const kas: RequestListener = async (req, res) => { 'Content-Type': 'application/json', 'DPoP-Nonce': DPOP_TEST_NONCE, }); - res.end(JSON.stringify({ error: 'use_dpop_nonce', error_description: 'DPoP nonce required' })); + res.end( + JSON.stringify({ error: 'use_dpop_nonce', error_description: 'DPoP nonce required' }) + ); return; } res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ access_token: 'test-dpop-token', token_type: 'DPoP', expires_in: 3600 })); + res.end( + JSON.stringify({ access_token: 'test-dpop-token', token_type: 'DPoP', expires_in: 3600 }) + ); return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 2f6600c65..622e4be4b 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -127,9 +127,14 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { it('retries with nonce when interceptor catches a code-16 error with dpop-nonce metadata', async () => { const mockNext = stub(); // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata - mockNext.onFirstCall().callsFake(() => - Promise.reject({ code: 16, metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) } }) - ); + mockNext + .onFirstCall() + .callsFake(() => + Promise.reject({ + code: 16, + metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, + }) + ); // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); @@ -150,7 +155,10 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { globalNonceCache.set(ORIGIN, NONCE); const mockNext = stub().callsFake(() => - Promise.reject({ code: 16, metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) } }) + Promise.reject({ + code: 16, + metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, + }) ); const interceptor = makeInterceptor(); From 686db747da76ecf3c036d1a80f24cfaf105702ac Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:11:18 +0000 Subject: [PATCH 12/45] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- lib/tests/web/auth/dpop-nonce.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 622e4be4b..90c862050 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -127,14 +127,12 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { it('retries with nonce when interceptor catches a code-16 error with dpop-nonce metadata', async () => { const mockNext = stub(); // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata - mockNext - .onFirstCall() - .callsFake(() => - Promise.reject({ - code: 16, - metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, - }) - ); + mockNext.onFirstCall().callsFake(() => + Promise.reject({ + code: 16, + metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, + }) + ); // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); From e528331d1b10998d3a5b1a9cdf34177df740f78d Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 14:28:52 -0400 Subject: [PATCH 13/45] ci(dpop): upgrade Keycloak to 26.2 and enable DPoP nonce challenges in roundtrip tests (DSPX-3397) Upgrade the roundtrip CI Keycloak to 26.2, enable admin-fine-grained-authz:v1, drop the keycloakdb dependency (KC 26.2 uses embedded H2 in dev mode), require dpop.bound.access.tokens on existing clients (opentdf-sdk, testclient), and add --dpop to the CLI encrypt/decrypt invocations so both playwright and CLI roundtrip tests exercise the full nonce challenge/retry path. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- .../workflows/roundtrip/config-demo-idp.sh | 5 ++-- .../workflows/roundtrip/docker-compose.yaml | 23 ++----------------- .../workflows/roundtrip/encrypt-decrypt.sh | 3 +++ .../workflows/roundtrip/keycloak_data.yaml | 4 +++- web-app/tests/README.md | 4 ++++ 5 files changed, 15 insertions(+), 24 deletions(-) diff --git a/.github/workflows/roundtrip/config-demo-idp.sh b/.github/workflows/roundtrip/config-demo-idp.sh index 6978f161e..dcda88bf1 100755 --- a/.github/workflows/roundtrip/config-demo-idp.sh +++ b/.github/workflows/roundtrip/config-demo-idp.sh @@ -2,7 +2,7 @@ set -x -: "${KC_VERSION:=24.0.3}" +: "${KC_VERSION:=26.2.0}" if ! which kcadm.sh; then KCADM_URL=https://github.com/keycloak/keycloak/releases/download/${KC_VERSION}/keycloak-${KC_VERSION}.zip @@ -48,7 +48,8 @@ kcadm.sh create clients -r opentdf \ -s enabled=true \ -s standardFlowEnabled=true \ -s serviceAccountsEnabled=true \ - -s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' + -s 'protocolMappers=[{"name":"aud","protocol":"openid-connect","protocolMapper":"oidc-audience-mapper","consentRequired":false,"config":{"access.token.claim":"true","included.custom.audience":"http://localhost:65432"}}]' \ + -s 'attributes={"dpop.bound.access.tokens":"true"}' kcadm.sh create users -r opentdf -s username=user1 -s enabled=true -s firstName=Alice -s lastName=User kcadm.sh set-password -r opentdf --username user1 --new-password testuser123 diff --git a/.github/workflows/roundtrip/docker-compose.yaml b/.github/workflows/roundtrip/docker-compose.yaml index 0b513b45d..6b39b65ce 100644 --- a/.github/workflows/roundtrip/docker-compose.yaml +++ b/.github/workflows/roundtrip/docker-compose.yaml @@ -1,18 +1,12 @@ services: keycloak: - image: keycloak/keycloak:24.0.5 + image: keycloak/keycloak:26.2 restart: always command: - "start-dev" - "--verbose" environment: - KC_DB_VENDOR: postgres - KC_DB_URL_HOST: keycloakdb - KC_DB_URL_PORT: 5432 - KC_DB_URL_DATABASE: keycloak - KC_DB_USERNAME: keycloak - KC_DB_PASSWORD: changeme - KC_FEATURES: 'preview,token-exchange' + KC_FEATURES: "preview,token-exchange,admin-fine-grained-authz:v1" KC_HEALTH_ENABLED: 'true' KC_HOSTNAME_ADMIN_URL: 'http://localhost:65432/auth' KC_HOSTNAME_PORT: '65432' @@ -34,19 +28,6 @@ services: timeout: 10s retries: 3 start_period: 2m - keycloakdb: - image: postgres:15-alpine - restart: always - user: postgres - environment: - POSTGRES_PASSWORD: changeme - POSTGRES_USER: postgres - POSTGRES_DB: keycloak - healthcheck: - test: ["CMD-SHELL", "pg_isready"] - interval: 5s - timeout: 5s - retries: 10 opentdfdb: image: postgres:15-alpine restart: always diff --git a/.github/workflows/roundtrip/encrypt-decrypt.sh b/.github/workflows/roundtrip/encrypt-decrypt.sh index a57dc2bf8..e88106015 100755 --- a/.github/workflows/roundtrip/encrypt-decrypt.sh +++ b/.github/workflows/roundtrip/encrypt-decrypt.sh @@ -16,6 +16,7 @@ _tdf3_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample.txt.tdf \ encrypt "${plain}" \ --containerType tdf3 \ @@ -28,6 +29,7 @@ _tdf3_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample_out.txt \ --containerType tdf3 \ decrypt sample.txt.tdf @@ -50,6 +52,7 @@ _tdf3_inspect_test() { --ignoreAllowList \ --oidcEndpoint http://localhost:65432/auth/realms/opentdf \ --auth testclient:secret \ + --dpop \ --output sample-with-attrs.txt.tdf \ --attributes 'https://attr.io/attr/a/value/1,https://attr.io/attr/x/value/2' \ encrypt "${plain}" \ diff --git a/.github/workflows/roundtrip/keycloak_data.yaml b/.github/workflows/roundtrip/keycloak_data.yaml index 201a2b654..da410a4b8 100644 --- a/.github/workflows/roundtrip/keycloak_data.yaml +++ b/.github/workflows/roundtrip/keycloak_data.yaml @@ -42,9 +42,11 @@ realms: serviceAccountsEnabled: true clientAuthenticatorType: client-secret secret: secret + attributes: + dpop.bound.access.tokens: "true" protocolMappers: - *customAudMapper - sa_realm_roles: + sa_realm_roles: - opentdf-standard - client: clientID: tdf-entity-resolution diff --git a/web-app/tests/README.md b/web-app/tests/README.md index 46a5e9e9a..dec7da387 100644 --- a/web-app/tests/README.md +++ b/web-app/tests/README.md @@ -3,6 +3,10 @@ This folder contains playwright, e2e tests for web-app, running against a local or remote backend in proxy mode. +DPoP nonce challenges are enabled for all test clients (`browsertest` and `testclient`), +so the e2e tests and CLI roundtrip tests exercise the full DPoP challenge/retry path. +Keycloak 26.2 is required (configured in `.github/workflows/roundtrip/docker-compose.yaml`). + ## Bring up the platform behind local (vite dev server) proxy Bring up test backend services (identity provider, database, etc.): From a227e7fae443e241f8c89edb2eb27a92ee35b939 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 10 Jun 2026 15:22:22 -0400 Subject: [PATCH 14/45] fix(dpop): add missing CORS headers to mock server for browser test compatibility (DSPX-3397) x-virtrupubkey was missing from Access-Control-Allow-Headers, causing Chrome to block the CORS preflight for DPoP-enabled requests. DPoP-Nonce was missing from Access-Control-Expose-Headers, preventing browser JS from reading the nonce challenge on 401 responses. Node.js fetch ignores CORS so mocha tests passed; Chrome Headless tests failed with TypeError: Failed to fetch. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- lib/tests/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 3da6b4b34..589c47433 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -73,9 +73,11 @@ const kas: RequestListener = async (req, res) => { 'roundtrip-test-response', 'connect-protocol-version', 'connect-streaming-protocol-version', + 'x-virtrupubkey', ].join(', ') ); res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Expose-Headers', 'DPoP-Nonce'); // GET should be allowed for everything except rewrap, POST only for rewrap but IDC res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET, POST'); try { From 3a2e3d275f38f2786ec97c07c01e8d02c7c9ca43 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:37:23 -0400 Subject: [PATCH 15/45] chore(scripts): add local dev helper scripts for DPoP demo dev-local.sh starts the web-app dev server pointed at a local otdf-local instance (PLATFORM_URL/KC_URL/KC_CLIENT_ID overrideable via env). rebuild-local-lib.sh builds lib/ from source and installs it into web-app, replacing the published @opentdf/sdk with the local build. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/dev-local.sh | 30 ++++++++++++++++++++++++++++++ scripts/rebuild-local-lib.sh | 16 ++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100755 scripts/dev-local.sh create mode 100755 scripts/rebuild-local-lib.sh diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh new file mode 100755 index 000000000..f8b0724cf --- /dev/null +++ b/scripts/dev-local.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Start the web-app dev server pointed at a local otdf-local instance. +# +# Prerequisites: +# - otdf-local backend is running (tests/ dir): +# uv run otdf-local --instance DSPX-3397 up --services platform,kas +# - Local lib is built and installed: +# ./scripts/rebuild-local-lib.sh +# +# DPoP is active by default (the lib sends DPoP tokens on every request). +# To see enforcement (platform rejects non-DPoP tokens), set enforceDPoP: true +# in tests/instances/DSPX-3397/opentdf.yaml, then restart platform: +# uv run otdf-local --instance DSPX-3397 up --services platform --no-provision +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" +KC_URL="${KC_URL:-http://localhost:8888}" +KC_REALM="${KC_REALM:-opentdf}" +KC_CLIENT_ID="${KC_CLIENT_ID:-browsertest}" + +export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${KC_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${PLATFORM_URL}/api/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" + +echo "Starting web-app dev server with:" +echo " OIDC: ${KC_URL}/auth/realms/${KC_REALM} client=${KC_CLIENT_ID}" +echo " KAS: ${PLATFORM_URL}/api/kas" +echo "" + +cd "$REPO_ROOT/web-app" +npm run dev diff --git a/scripts/rebuild-local-lib.sh b/scripts/rebuild-local-lib.sh new file mode 100755 index 000000000..e850d25c3 --- /dev/null +++ b/scripts/rebuild-local-lib.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Build the local lib and install it into the web-app. +# Run this after making changes to lib/ before starting the dev server. +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$REPO_ROOT/lib" +npm ci +npm pack + +cd "$REPO_ROOT/web-app" +npm remove @opentdf/sdk +npm ci +npm install ../lib/opentdf-sdk-*.tgz + +echo "Done. Run scripts/dev-local.sh to start the dev server." From a71d56dba9cfe2ca787dd6d29fbfe24ce45b1da8 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:38:04 -0400 Subject: [PATCH 16/45] chore(scripts): add config-demo.sh to provision KC and start dev server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts .github/workflows/roundtrip/config-demo-idp.sh for local otdf-local use: creates the browsertest public KC client with dpop.bound.access.tokens enforced and an audience mapper pointing at PLATFORM_URL, then execs dev-local.sh to start the Vite dev server. No kcadm install required — uses the KC admin REST API directly. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/config-demo.sh | 71 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100755 scripts/config-demo.sh diff --git a/scripts/config-demo.sh b/scripts/config-demo.sh new file mode 100755 index 000000000..c040e6f9a --- /dev/null +++ b/scripts/config-demo.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Configure Keycloak for the DPoP browser demo and start the dev server. +# +# Mirrors .github/workflows/roundtrip/config-demo-idp.sh but uses the admin +# REST API directly (no kcadm download needed) and targets an otdf-local +# instance rather than the CI docker-compose stack. +# +# Prerequisites: +# - Keycloak is running (docker, via otdf-local): +# uv run otdf-local --instance DSPX-3397 up --services docker +# - Local lib is built and installed in web-app: +# ./scripts/rebuild-local-lib.sh +# +# Usage: +# ./scripts/config-demo.sh # uses defaults +# PLATFORM_URL=http://localhost:9080 ./scripts/config-demo.sh +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +KC_URL="${KC_URL:-http://localhost:8888}" +KC_REALM="${KC_REALM:-opentdf}" +KC_ADMIN_USER="${KC_ADMIN_USER:-admin}" +KC_ADMIN_PASSWORD="${KC_ADMIN_PASSWORD:-changeme}" +PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" +APP_URL="${APP_URL:-http://localhost:65432}" + +echo "Configuring Keycloak at ${KC_URL}/auth/realms/${KC_REALM}" + +KC_ADMIN_TOKEN=$(curl -sf "${KC_URL}/auth/realms/master/protocol/openid-connect/token" \ + -d "client_id=admin-cli&username=${KC_ADMIN_USER}&password=${KC_ADMIN_PASSWORD}&grant_type=password" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + +_kc() { curl -sf -H "Authorization: Bearer ${KC_ADMIN_TOKEN}" "$@"; } + +# Create browsertest public client with DPoP binding enforced. +# Audience maps to PLATFORM_URL so the platform's auth.audience check passes. +if _kc "${KC_URL}/auth/admin/realms/${KC_REALM}/clients?clientId=browsertest" \ + | python3 -c "import sys,json; exit(0 if json.load(sys.stdin) else 1)" 2>/dev/null; then + echo "browsertest client already exists, skipping creation" +else + _kc -X POST "${KC_URL}/auth/admin/realms/${KC_REALM}/clients" \ + -H "Content-Type: application/json" \ + -d "{ + \"clientId\": \"browsertest\", + \"enabled\": true, + \"redirectUris\": [\"${APP_URL}/\"], + \"consentRequired\": false, + \"standardFlowEnabled\": true, + \"directAccessGrantsEnabled\": true, + \"serviceAccountsEnabled\": false, + \"publicClient\": true, + \"protocol\": \"openid-connect\", + \"attributes\": {\"dpop.bound.access.tokens\": \"true\"}, + \"protocolMappers\": [{ + \"name\": \"aud\", + \"protocol\": \"openid-connect\", + \"protocolMapper\": \"oidc-audience-mapper\", + \"consentRequired\": false, + \"config\": { + \"access.token.claim\": \"true\", + \"included.custom.audience\": \"${PLATFORM_URL}\" + } + }] + }" + echo "Created browsertest client (DPoP-bound, audience=${PLATFORM_URL})" +fi + +echo "" +echo "Keycloak configured. Starting dev server..." +echo "" +exec "${REPO_ROOT}/scripts/dev-local.sh" From 3ef7f9caad7d8e611e36985bd280b788402a5679 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:43:32 -0400 Subject: [PATCH 17/45] fix(scripts): create demo user1 in config-demo.sh Matches the user creation step from config-demo-idp.sh that was omitted in the initial port. user1 / testuser123 is the expected login for the browser demo app. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/config-demo.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/config-demo.sh b/scripts/config-demo.sh index c040e6f9a..1d5d235aa 100755 --- a/scripts/config-demo.sh +++ b/scripts/config-demo.sh @@ -65,6 +65,22 @@ else echo "Created browsertest client (DPoP-bound, audience=${PLATFORM_URL})" fi +# Create demo user (user1 / testuser123) if not already present. +if _kc "${KC_URL}/auth/admin/realms/${KC_REALM}/users?username=user1" \ + | python3 -c "import sys,json; exit(0 if json.load(sys.stdin) else 1)" 2>/dev/null; then + echo "user1 already exists, skipping creation" +else + USER_ID=$(_kc -X POST "${KC_URL}/auth/admin/realms/${KC_REALM}/users" \ + -H "Content-Type: application/json" \ + -D - \ + -d '{"username":"user1","enabled":true,"firstName":"Alice","lastName":"User"}' \ + | grep -i "^location:" | grep -o '[^/]*$' | tr -d '\r') + _kc -X PUT "${KC_URL}/auth/admin/realms/${KC_REALM}/users/${USER_ID}/reset-password" \ + -H "Content-Type: application/json" \ + -d '{"type":"password","value":"testuser123","temporary":false}' + echo "Created user1 (password: testuser123)" +fi + echo "" echo "Keycloak configured. Starting dev server..." echo "" From 7168da0eec630f68e1661adf854d93013c7855d4 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:45:26 -0400 Subject: [PATCH 18/45] fix(scripts): route OIDC through Vite proxy to avoid CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser requests to Keycloak must go through the Vite /auth proxy (localhost:65432/auth → localhost:8888) rather than hitting port 8888 directly. Replaced the hard-coded KC_URL in VITE_TDF_CFG with APP_URL so the oidc.host always matches the app origin. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/dev-local.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh index f8b0724cf..d63f09af1 100755 --- a/scripts/dev-local.sh +++ b/scripts/dev-local.sh @@ -15,14 +15,16 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" -KC_URL="${KC_URL:-http://localhost:8888}" KC_REALM="${KC_REALM:-opentdf}" KC_CLIENT_ID="${KC_CLIENT_ID:-browsertest}" +APP_URL="${APP_URL:-http://localhost:65432}" -export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${KC_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${PLATFORM_URL}/api/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" +# Use the app origin for the OIDC host so browser requests go through +# Vite's /auth proxy instead of hitting Keycloak on port 8888 directly (CORS). +export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${APP_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${PLATFORM_URL}/api/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" echo "Starting web-app dev server with:" -echo " OIDC: ${KC_URL}/auth/realms/${KC_REALM} client=${KC_CLIENT_ID}" +echo " OIDC: ${APP_URL}/auth/realms/${KC_REALM} (proxied) client=${KC_CLIENT_ID}" echo " KAS: ${PLATFORM_URL}/api/kas" echo "" From d5f6f451da5dc9e92fa57aa5e6101ce9be7fe8fa Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 12:49:52 -0400 Subject: [PATCH 19/45] fix(scripts): route KAS through Vite proxy; drop PLATFORM_URL Both OIDC and KAS endpoints now use APP_URL (localhost:65432) so all browser requests go through Vite's reverse proxy rather than hitting ports 8888 or 8080 directly. Mirrors how the roundtrip scripts pass --kasEndpoint http://localhost:65432/kas. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- scripts/dev-local.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh index d63f09af1..da722774b 100755 --- a/scripts/dev-local.sh +++ b/scripts/dev-local.sh @@ -14,18 +14,17 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PLATFORM_URL="${PLATFORM_URL:-http://localhost:8080}" KC_REALM="${KC_REALM:-opentdf}" KC_CLIENT_ID="${KC_CLIENT_ID:-browsertest}" APP_URL="${APP_URL:-http://localhost:65432}" # Use the app origin for the OIDC host so browser requests go through # Vite's /auth proxy instead of hitting Keycloak on port 8888 directly (CORS). -export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${APP_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${PLATFORM_URL}/api/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" +export VITE_TDF_CFG="{\"oidc\":{\"host\":\"${APP_URL}/auth/realms/${KC_REALM}\",\"clientId\":\"${KC_CLIENT_ID}\"},\"kas\":\"${APP_URL}/kas\",\"reader\":\"https://secure.virtru.com/start?htmlProtocol=1\"}" echo "Starting web-app dev server with:" echo " OIDC: ${APP_URL}/auth/realms/${KC_REALM} (proxied) client=${KC_CLIENT_ID}" -echo " KAS: ${PLATFORM_URL}/api/kas" +echo " KAS: ${APP_URL}/kas (proxied)" echo "" cd "$REPO_ROOT/web-app" From 7dad52fa755876f49f69bf30cccb70ae41a3613b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 13:12:15 -0400 Subject: [PATCH 20/45] fix(sdk): use DPoP scheme when presenting DPoP-bound tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RFC 9449 §7.1, DPoP-bound access tokens (cnf.jkt claim present) MUST be presented to resource servers under the "DPoP" Authorization scheme, not "Bearer". Three lib paths (oidc.ts withCreds and info, interceptors.ts authTokenDPoPInterceptor) and the web-app sample's OidcClient.withCreds were sending Bearer alongside the DPoP proof header — silently accepted by lenient enforcers, but rejected by spec-compliant ones once enforcement is enabled. Non-DPoP paths continue to send Bearer. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- lib/src/auth/interceptors.ts | 2 +- lib/src/auth/oidc.ts | 6 ++++-- lib/tests/web/interceptors.test.ts | 2 +- web-app/src/session.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index da504238c..a39fa9606 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -99,7 +99,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI // Export public key PEM for X-VirtruPubKey header const publicKeyPem = await cryptoService.exportPublicKeyPem(keys.publicKey); - req.header.set('Authorization', `Bearer ${token}`); + req.header.set('Authorization', `DPoP ${token}`); req.header.set('DPoP', dpopProof); req.header.set('X-VirtruPubKey', base64.encode(publicKeyPem)); diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 5c0e4efc8..72b59d6ef 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -143,7 +143,6 @@ export class AccessToken { const origin = new URL(this.userInfoEndpoint).origin; const headers = { ...this.extraHeaders, - Authorization: `Bearer ${accessToken}`, } as Record; if (this.config.dpopEnabled && this.signingKey) { const cachedNonce = globalNonceCache.get(origin); @@ -155,6 +154,9 @@ export class AccessToken { cachedNonce, accessToken ); + headers.Authorization = `DPoP ${accessToken}`; + } else { + headers.Authorization = `Bearer ${accessToken}`; } const response = await (this.request || fetch)(this.userInfoEndpoint, { headers, @@ -381,7 +383,7 @@ export class AccessToken { accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? - return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}`, DPoP: dpopToken }); + return withHeaders(httpReq, { Authorization: `DPoP ${accessToken}`, DPoP: dpopToken }); } return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}` }); } diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 25ba358cb..4c71edd97 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -61,7 +61,7 @@ describe('authTokenDPoPInterceptor', () => { const headers = await captureHeaders(interceptor); - expect(headers.get('Authorization')).to.equal('Bearer dpop-token'); + expect(headers.get('Authorization')).to.equal('DPoP dpop-token'); expect(headers.get('DPoP')).to.be.a('string'); expect(headers.get('DPoP')!.split('.')).to.have.length(3); // JWT format expect(headers.get('X-VirtruPubKey')).to.be.a('string'); diff --git a/web-app/src/session.ts b/web-app/src/session.ts index 2bcbe1256..c63d4e669 100644 --- a/web-app/src/session.ts +++ b/web-app/src/session.ts @@ -524,6 +524,6 @@ export class OidcClient implements AuthProvider { accessToken ); // TODO: Consider: only set DPoP if cnf.jkt is present in access token? - return withHeaders(httpReq, { Authorization: `Bearer ${accessToken}`, DPoP: dpopToken }); + return withHeaders(httpReq, { Authorization: `DPoP ${accessToken}`, DPoP: dpopToken }); } } From 9ccebc6e2e4d81de19c16f6163408c4e412502dc Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 11 Jun 2026 13:12:15 -0400 Subject: [PATCH 21/45] test(web-app): add Playwright test capturing DPoP-protected headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercepts the OIDC token POST and the KAS Rewrap POST, asserts the DPoP header is present, and surfaces the captured Authorization scheme (DPoP vs Bearer) for visual inspection. Serves as a regression guard for the RFC 9449 §7.1 scheme requirement. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Dave Mihalcik --- web-app/tests/tests/dpop-headers.spec.ts | 65 ++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 web-app/tests/tests/dpop-headers.spec.ts diff --git a/web-app/tests/tests/dpop-headers.spec.ts b/web-app/tests/tests/dpop-headers.spec.ts new file mode 100644 index 000000000..9af7bf5a4 --- /dev/null +++ b/web-app/tests/tests/dpop-headers.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { authorize, loadFile } from './acts.js'; + +type CapturedRequest = { + url: string; + method: string; + authorization: string | undefined; + dpop: string | undefined; +}; + +test('DPoP headers on token and KAS rewrap requests', async ({ page }) => { + const captured: CapturedRequest[] = []; + + page.on('request', (request) => { + const url = request.url(); + if ( + url.includes('/protocol/openid-connect/token') || + url.includes('/kas.AccessService/Rewrap') || + url.includes('/kas/v2/rewrap') + ) { + const headers = request.headers(); + captured.push({ + url, + method: request.method(), + authorization: headers['authorization'], + dpop: headers['dpop'], + }); + } + }); + + page.on('console', (m) => console.log(m.text())); + + await authorize(page); + await loadFile(page, 'README.md'); + const downloadPromise = page.waitForEvent('download'); + await page.locator('#fileSink').click(); + await page.locator('#encryptButton').click(); + const enc = await downloadPromise; + const cipherTextPath = await enc.path(); + if (!cipherTextPath) throw new Error('no cipher'); + + await page.locator('#clearFile').click(); + await loadFile(page, cipherTextPath); + const plainDownloadPromise = page.waitForEvent('download'); + await page.locator('#fileSink').click(); + await page.locator('#decryptButton').click(); + await plainDownloadPromise; + + console.log('\n=== CAPTURED DPoP-RELEVANT REQUESTS ==='); + for (const r of captured) { + console.log(`\n${r.method} ${r.url}`); + console.log(` Authorization: ${r.authorization ?? '(none)'}`); + console.log(` DPoP: ${r.dpop ? r.dpop.slice(0, 80) + '...' : '(none)'}`); + } + + // We expect at minimum: token exchange + rewrap + expect(captured.length).toBeGreaterThanOrEqual(2); + + for (const r of captured) { + if (r.url.includes('/kas')) { + expect(r.authorization, `${r.url} should carry an Authorization header`).toBeTruthy(); + expect(r.dpop, `${r.url} should carry a DPoP header`).toBeTruthy(); + } + } +}); From 189d1442df88827bf5ecb2edfc018ce5e810250a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:47:39 -0400 Subject: [PATCH 22/45] fix(dpop): make nonce challenge handling RFC 9449 compliant (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doPost: trigger nonce retry on any non-OK response carrying a fresh DPoP-Nonce header. The previous status==401 gate never fired against spec-compliant authorization servers (Keycloak 26.2 included), which return HTTP 400 with error=use_dpop_nonce per RFC 9449 §8. - info: fix htm claim mismatch — the proof was generated with 'POST' but the userinfo request is GET, violating RFC 9449 §4.2. - info: add the missing single-shot nonce retry mirroring doPost, so the first userinfo request against a nonce-enforcing resource server succeeds instead of bubbling up as a spurious token-renewal cycle. Signed-off-by: Dave Mihalcik --- lib/src/auth/oidc.ts | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 72b59d6ef..19c27877d 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -144,13 +144,14 @@ export class AccessToken { const headers = { ...this.extraHeaders, } as Record; + let cachedNonce: string | undefined; if (this.config.dpopEnabled && this.signingKey) { - const cachedNonce = globalNonceCache.get(origin); + cachedNonce = globalNonceCache.get(origin); headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, this.userInfoEndpoint, - 'POST', + 'GET', cachedNonce, accessToken ); @@ -158,11 +159,30 @@ export class AccessToken { } else { headers.Authorization = `Bearer ${accessToken}`; } - const response = await (this.request || fetch)(this.userInfoEndpoint, { + let response = await (this.request || fetch)(this.userInfoEndpoint, { headers, }); - // Update nonce cache from response + // Handle DPoP-Nonce challenge per RFC 9449 §9: retry once with the server-supplied nonce. + if (this.config.dpopEnabled && this.signingKey && !response.ok) { + const challengeNonce = DPoPNonceCache.extractNonce(response.headers); + if (challengeNonce && challengeNonce !== cachedNonce) { + globalNonceCache.set(origin, challengeNonce); + headers.DPoP = await dpopFn( + this.signingKey, + this.cryptoService, + this.userInfoEndpoint, + 'GET', + challengeNonce, + accessToken + ); + response = await (this.request || fetch)(this.userInfoEndpoint, { + headers, + }); + } + } + + // Update nonce cache from final response if (this.config.dpopEnabled) { const responseNonce = DPoPNonceCache.extractNonce(response.headers); if (responseNonce) { @@ -208,8 +228,10 @@ export class AccessToken { body: qstringify(o), }); - // Handle DPoP-Nonce retry on 401 - if (this.config.dpopEnabled && response.status === 401) { + // Handle DPoP-Nonce challenge. RFC 9449 §8: authorization servers return + // HTTP 400 with error=use_dpop_nonce; §9: resource servers return 401. + // Trigger on any non-OK response that carries a fresh DPoP-Nonce header. + if (this.config.dpopEnabled && !response.ok) { const responseNonce = DPoPNonceCache.extractNonce(response.headers); if (responseNonce && responseNonce !== cachedNonce) { // Cache the server-provided nonce and retry From 54eca298e1445f937c1dddb3fd8bf76a0407ad0a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:47:47 -0400 Subject: [PATCH 23/45] fix(cli): harden DPoP key loading and helper utilities (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadDPoPKeyPairFromPem: narrow the curve-detection catches so that only crypto.subtle.importKey failures are swallowed. SDK-layer errors from buildKeyPairFromCryptoKey now propagate with full context instead of producing the misleading 'expected PKCS8 PEM' message. - loadDPoPKeyPairFromPem: wrap atob() in try/catch so malformed PEM content surfaces as a descriptive CLIError instead of a raw DOMException. - derToPem: replace btoa(String.fromCharCode(...bytes)) with Buffer.from(bytes).toString('base64') — matches the safer pattern already used in lib/src/auth/dpop.ts and avoids spread-on-large-array call-stack risk. - requireImportPrivateKey: guarded accessor that fails loudly with a CLIError if the optional WebCryptoService.importPrivateKey method is missing, replacing the silent ! non-null assertions. - resolveDPoPFromArgs: new exported helper that encapsulates the --dpop / --dpopKey three-way argv parsing (bare --dpop defaults to ES256). Lets cli.ts dedupe and unit tests exercise the helper without triggering yargs side effects. Signed-off-by: Dave Mihalcik --- cli/src/dpop-helpers.ts | 77 +++++++++++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/cli/src/dpop-helpers.ts b/cli/src/dpop-helpers.ts index b0a764a3f..b155df444 100644 --- a/cli/src/dpop-helpers.ts +++ b/cli/src/dpop-helpers.ts @@ -13,10 +13,21 @@ const EC_CURVE_MAP: Record = { ES512: 'P-521', }; +/** Resolve the optional WebCryptoService.importPrivateKey method, failing with a clear CLIError if absent. */ +function requireImportPrivateKey() { + if (!WebCryptoService.importPrivateKey) { + throw new CLIError( + 'CRITICAL', + 'WebCryptoService.importPrivateKey is unavailable in this SDK build; cannot load DPoP private keys' + ); + } + return WebCryptoService.importPrivateKey; +} + /** Convert a DER buffer to a PEM string with the given type label. */ export function derToPem(der: Uint8Array | ArrayBuffer, type: string): string { const bytes = der instanceof ArrayBuffer ? new Uint8Array(der) : der; - const b64 = btoa(String.fromCharCode(...bytes)); + const b64 = Buffer.from(bytes).toString('base64'); const lines = b64.match(/.{1,64}/g)?.join('\n') ?? b64; return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`; } @@ -52,8 +63,9 @@ export async function generateEphemeralDPoPKeyPair(alg: string): Promise throw new CLIError('CRITICAL', `Cannot read DPoP key file: ${pemPath}`, err as Error); } - const b64 = privatePem.replace(/-----[\w\s]+-----|[\r\n]/g, ''); - const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + let der: Uint8Array; + try { + const b64 = privatePem.replace(/-----[\w\s]+-----|[\r\n\s]/g, ''); + der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + } catch (err) { + throw new CLIError( + 'CRITICAL', + `Cannot decode DPoP key file as PEM/base64: ${pemPath}. Ensure the file is a PKCS8 PEM-encoded private key.`, + err as Error + ); + } - // Try EC curves (P-256, P-384, P-521) + // Try EC curves (P-256, P-384, P-521). Catch only the importKey call so that + // any SDK-layer errors from buildKeyPairFromCryptoKey propagate with full context. for (const namedCurve of ['P-256', 'P-384', 'P-521']) { + let privCK: webcrypto.CryptoKey | undefined; try { - const privCK = await crypto.subtle.importKey( - 'pkcs8', - der, - { name: 'ECDSA', namedCurve }, - true, - ['sign'] - ); - return await buildKeyPairFromCryptoKey(privatePem, privCK, { name: 'ECDSA', namedCurve }); + privCK = await crypto.subtle.importKey('pkcs8', der, { name: 'ECDSA', namedCurve }, true, [ + 'sign', + ]); } catch { // wrong curve or not an EC key — try next } + if (privCK) { + return await buildKeyPairFromCryptoKey(privatePem, privCK, { name: 'ECDSA', namedCurve }); + } } - // Try RSA (PKCS1-v1_5 SHA-256) + // Try RSA (PKCS1-v1_5 SHA-256). Same narrowing rationale as above. + let rsaCK: webcrypto.CryptoKey | undefined; try { - const privCK = await crypto.subtle.importKey( + rsaCK = await crypto.subtle.importKey( 'pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, true, ['sign'] ); - return await buildKeyPairFromCryptoKey(privatePem, privCK, { + } catch { + // not RSA either + } + if (rsaCK) { + return await buildKeyPairFromCryptoKey(privatePem, rsaCK, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256', }); - } catch { - // not RSA either } throw new CLIError( @@ -140,8 +164,9 @@ async function buildKeyPairFromCryptoKey( const pubDer = await crypto.subtle.exportKey('spki', pubCK); const pubPem = derToPem(pubDer, 'PUBLIC KEY'); + const importPriv = requireImportPrivateKey(); const [privateKey, publicKey] = await Promise.all([ - WebCryptoService.importPrivateKey!(privatePem, { usage: 'sign', extractable: true }), + importPriv(privatePem, { usage: 'sign', extractable: true }), WebCryptoService.importPublicKey(pubPem, { usage: 'sign', extractable: true }), ]); return { publicKey, privateKey }; @@ -163,3 +188,17 @@ export async function resolveDPoPKeyPair( } return undefined; } + +/** + * Resolve DPoP configuration from CLI argv. Bare `--dpop` defaults to ES256; + * `--dpopKey` enables DPoP even without `--dpop`. + */ +export async function resolveDPoPFromArgs(argv: { + dpop?: string; + dpopKey?: string; +}): Promise<{ dpopEnabled: boolean; dpopKeyPair: KeyPair | undefined }> { + const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; + const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; + const dpopKeyPair = await resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + return { dpopEnabled, dpopKeyPair }; +} From 4eb24844650004a8d63825876fc16899e29f1f56 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:47:53 -0400 Subject: [PATCH 24/45] refactor(dpop): use ConnectError type and dedupe CLI argv handling (DSPX-3397) - interceptors.ts: replace the duck-typed metadata cast with a real 'instanceof ConnectError && err.code === Code.Unauthenticated' check. ConnectError.metadata is typed as Headers so .get() is non-optional, and instanceof catches actual production errors rather than a hand-rolled shape. - dpop-nonce.test.ts: update the rejection stubs to throw real ConnectError instances (matches what Connect actually surfaces to interceptors in production; the prior plain objects only worked because of the duck-typing that we just removed). - cli.ts: replace the stale eslint-disable + _resolveDPoPKeyPair alias with a clean import. Replace the copy-pasted three-line DPoP resolution block in the encrypt and decrypt handlers with the new resolveDPoPFromArgs helper. Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 11 +++-------- lib/src/auth/interceptors.ts | 15 ++++----------- lib/tests/web/auth/dpop-nonce.test.ts | 23 +++++++++++++++-------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index a3efa2fd4..ba94632ef 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -21,8 +21,7 @@ import { CLIError, Level, log } from './logger.js'; import * as assertions from '@opentdf/sdk/assertions'; import { base64 } from '@opentdf/sdk/encodings'; import { type KeyPair } from '@opentdf/sdk/singlecontainer'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in Task 4+5 -import { resolveDPoPKeyPair as _resolveDPoPKeyPair } from './dpop-helpers.js'; +import { resolveDPoPFromArgs } from './dpop-helpers.js'; type AuthToProcess = { auth?: string; @@ -584,9 +583,7 @@ export const handleArgs = (args: string[]) => { const authProvider = await processAuth(argv); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); - const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; - const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; - const dpopKeyPair = await _resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); const client = new OpenTDF({ authProvider, @@ -653,9 +650,7 @@ export const handleArgs = (args: string[]) => { log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); - const dpopAlg = argv.dpop === undefined ? undefined : argv.dpop || 'ES256'; - const dpopEnabled = dpopAlg !== undefined || !!argv.dpopKey; - const dpopKeyPair = await _resolveDPoPKeyPair(dpopAlg, argv.dpopKey); + const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); const client = new OpenTDF({ authProvider, diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index a39fa9606..8c68e17b1 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -1,4 +1,4 @@ -import { type Interceptor } from '@connectrpc/connect'; +import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; export type { Interceptor } from '@connectrpc/connect'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; import * as DefaultCryptoService from '../../tdf3/src/crypto/index.js'; @@ -115,16 +115,9 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI return response; } catch (err) { - // Check if this is a 401 with DPoP-Nonce challenge - if ( - err && - typeof err === 'object' && - 'code' in err && - err.code === 16 && // Code.Unauthenticated - 'metadata' in err - ) { - const metadata = err.metadata as { get?: (key: string) => string | null } | undefined; - const serverNonce = metadata?.get?.('dpop-nonce'); + // Check for a Connect Unauthenticated error carrying a DPoP-Nonce challenge + if (err instanceof ConnectError && err.code === Code.Unauthenticated) { + const serverNonce = err.metadata.get('dpop-nonce'); if (serverNonce && serverNonce !== cachedNonce) { // Server sent a new nonce (or we didn't have one cached) diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 90c862050..451027f1f 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -1,4 +1,5 @@ import { expect } from '@esm-bundle/chai'; +import { Code, ConnectError } from '@connectrpc/connect'; import { stub } from 'sinon'; import { AccessToken } from '../../../src/auth/oidc.js'; import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; @@ -128,10 +129,13 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { const mockNext = stub(); // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata mockNext.onFirstCall().callsFake(() => - Promise.reject({ - code: 16, - metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, - }) + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) ); // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); @@ -153,10 +157,13 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { globalNonceCache.set(ORIGIN, NONCE); const mockNext = stub().callsFake(() => - Promise.reject({ - code: 16, - metadata: { get: (k: string) => (k === 'dpop-nonce' ? NONCE : null) }, - }) + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) + ) ); const interceptor = makeInterceptor(); From 7b93362998b2b3fab36857244efd5c42d86d9892 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 16 Jun 2026 14:48:00 -0400 Subject: [PATCH 25/45] test(cli): cover PEM loading, key-path resolution, and CLI argv shape (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing coverage flagged during PR review: - loadDPoPKeyPairFromPem: round-trip tests for P-256, P-384, P-521, and RSA-2048 PEMs (generated in-memory via WebCrypto + derToPem), plus error paths for missing file, invalid base64, and valid base64 whose bytes are not a recognized key type. - resolveDPoPKeyPair: keyPath-only and keyPath-overrides-alg branches (previously the keyPath path was completely uncovered). - resolveDPoPFromArgs: full argv matrix — no flags, bare --dpop defaults to ES256, explicit algorithm, --dpopKey alone, and the CLIError propagation for an unknown algorithm. Signed-off-by: Dave Mihalcik --- cli/tests/dpop-helpers.spec.ts | 191 ++++++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/cli/tests/dpop-helpers.spec.ts b/cli/tests/dpop-helpers.spec.ts index b046d7af5..444a318af 100644 --- a/cli/tests/dpop-helpers.spec.ts +++ b/cli/tests/dpop-helpers.spec.ts @@ -1,6 +1,16 @@ // cli/tests/dpop-helpers.spec.ts import { expect } from '@esm-bundle/chai'; -import { derToPem, generateEphemeralDPoPKeyPair, resolveDPoPKeyPair } from '../src/dpop-helpers.js'; +import { type webcrypto } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + derToPem, + generateEphemeralDPoPKeyPair, + loadDPoPKeyPairFromPem, + resolveDPoPFromArgs, + resolveDPoPKeyPair, +} from '../src/dpop-helpers.js'; describe('derToPem', function () { it('wraps DER bytes in PEM armor with the given type', function () { @@ -51,7 +61,122 @@ describe('generateEphemeralDPoPKeyPair', function () { }); }); +type GeneratedPair = { privateKey: webcrypto.CryptoKey; publicKey: webcrypto.CryptoKey }; + +async function ecPrivatePem(curve: 'P-256' | 'P-384' | 'P-521'): Promise { + const raw = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: curve }, true, [ + 'sign', + 'verify', + ])) as GeneratedPair; + const der = await crypto.subtle.exportKey('pkcs8', raw.privateKey); + return derToPem(der, 'PRIVATE KEY'); +} + +async function rsaPrivatePem(): Promise { + const raw = (await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + )) as GeneratedPair; + const der = await crypto.subtle.exportKey('pkcs8', raw.privateKey); + return derToPem(der, 'PRIVATE KEY'); +} + +describe('loadDPoPKeyPairFromPem', function () { + let tmpDir: string; + + before(async function () { + tmpDir = await mkdtemp(join(tmpdir(), 'dpop-helpers-test-')); + }); + + after(async function () { + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeTmp(name: string, contents: string): Promise { + const path = join(tmpDir, name); + await writeFile(path, contents); + return path; + } + + it('loads a P-256 PEM into an ec:secp256r1 key pair', async function () { + const path = await writeTmp('p256.pem', await ecPrivatePem('P-256')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('loads a P-384 PEM into an ec:secp384r1 key pair', async function () { + const path = await writeTmp('p384.pem', await ecPrivatePem('P-384')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('loads a P-521 PEM into an ec:secp521r1 key pair', async function () { + const path = await writeTmp('p521.pem', await ecPrivatePem('P-521')); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('ec:secp521r1'); + }); + + it('loads an RSA-2048 PEM into an rsa:2048 key pair', async function () { + this.timeout(15_000); + const path = await writeTmp('rsa.pem', await rsaPrivatePem()); + const kp = await loadDPoPKeyPairFromPem(path); + expect(kp.publicKey.algorithm).to.equal('rsa:2048'); + }); + + it('throws CLIError when the file cannot be read', async function () { + try { + await loadDPoPKeyPairFromPem(join(tmpDir, 'does-not-exist.pem')); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot read DPoP key file'); + } + }); + + it('throws CLIError when the PEM body is not valid base64', async function () { + const path = await writeTmp( + 'corrupt.pem', + '-----BEGIN PRIVATE KEY-----\n!!!not-base64!!!\n-----END PRIVATE KEY-----' + ); + try { + await loadDPoPKeyPairFromPem(path); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot decode DPoP key file as PEM/base64'); + } + }); + + it('throws CLIError when the bytes are not a recognized key type', async function () { + // Valid base64 but the decoded bytes are not a PKCS8 EC or RSA key. + const path = await writeTmp( + 'garbage.pem', + '-----BEGIN PRIVATE KEY-----\nQUJDREVGR0g=\n-----END PRIVATE KEY-----' + ); + try { + await loadDPoPKeyPairFromPem(path); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Cannot parse DPoP key from'); + } + }); +}); + describe('resolveDPoPKeyPair', function () { + let tmpDir: string; + + before(async function () { + tmpDir = await mkdtemp(join(tmpdir(), 'dpop-resolve-test-')); + }); + + after(async function () { + await rm(tmpDir, { recursive: true, force: true }); + }); + it('returns undefined when both alg and keyPath are undefined', async function () { const result = await resolveDPoPKeyPair(undefined, undefined); expect(result).to.be.undefined; @@ -62,4 +187,68 @@ describe('resolveDPoPKeyPair', function () { expect(result).to.not.be.undefined; expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); }); + + it('loads from the keyPath PEM when only keyPath is provided', async function () { + const path = join(tmpDir, 'p256-from-path.pem'); + await writeFile(path, await ecPrivatePem('P-256')); + const result = await resolveDPoPKeyPair(undefined, path); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('prefers keyPath over alg when both are provided', async function () { + const path = join(tmpDir, 'p384-pref.pem'); + await writeFile(path, await ecPrivatePem('P-384')); + const result = await resolveDPoPKeyPair('ES256', path); + expect(result).to.not.be.undefined; + expect(result!.publicKey.algorithm).to.equal('ec:secp384r1'); + }); +}); + +describe('resolveDPoPFromArgs', function () { + let tmpDir: string; + + before(async function () { + tmpDir = await mkdtemp(join(tmpdir(), 'dpop-args-test-')); + }); + + after(async function () { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns disabled when neither --dpop nor --dpopKey is set', async function () { + const result = await resolveDPoPFromArgs({}); + expect(result.dpopEnabled).to.be.false; + expect(result.dpopKeyPair).to.be.undefined; + }); + + it('defaults to ES256 when --dpop is passed without a value', async function () { + // yargs delivers a bare `--dpop` as the empty string for type: 'string' + const result = await resolveDPoPFromArgs({ dpop: '' }); + expect(result.dpopEnabled).to.be.true; + expect(result.dpopKeyPair?.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('honours an explicit --dpop=ES384', async function () { + const result = await resolveDPoPFromArgs({ dpop: 'ES384' }); + expect(result.dpopEnabled).to.be.true; + expect(result.dpopKeyPair?.publicKey.algorithm).to.equal('ec:secp384r1'); + }); + + it('enables DPoP from --dpopKey alone (no --dpop)', async function () { + const path = join(tmpDir, 'args-key.pem'); + await writeFile(path, await ecPrivatePem('P-256')); + const result = await resolveDPoPFromArgs({ dpopKey: path }); + expect(result.dpopEnabled).to.be.true; + expect(result.dpopKeyPair?.publicKey.algorithm).to.equal('ec:secp256r1'); + }); + + it('propagates the CLIError for an invalid algorithm', async function () { + try { + await resolveDPoPFromArgs({ dpop: 'INVALID' }); + expect.fail('should have thrown'); + } catch (err) { + expect((err as Error).message).to.include('Unsupported DPoP algorithm'); + } + }); }); From 359a321dca02f5454077186d6459cda442821aad Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Tue, 16 Jun 2026 18:49:20 +0000 Subject: [PATCH 26/45] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- lib/tests/web/auth/dpop-nonce.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 451027f1f..055faee80 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -128,15 +128,17 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { it('retries with nonce when interceptor catches a code-16 error with dpop-nonce metadata', async () => { const mockNext = stub(); // First call: simulate server rejecting with Unauthenticated + dpop-nonce metadata - mockNext.onFirstCall().callsFake(() => - Promise.reject( - new ConnectError( - 'unauthenticated', - Code.Unauthenticated, - new Headers({ 'dpop-nonce': NONCE }) + mockNext + .onFirstCall() + .callsFake(() => + Promise.reject( + new ConnectError( + 'unauthenticated', + Code.Unauthenticated, + new Headers({ 'dpop-nonce': NONCE }) + ) ) - ) - ); + ); // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); From 81c37360e2cb602c7c0d06834d831d9095b9385a Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 17 Jun 2026 10:58:45 -0400 Subject: [PATCH 27/45] fix(dpop): send DPoP proof on initial Keycloak token request (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keycloak clients with dpop_bound_access_tokens=true reject the client_credentials POST /token unless it carries a DPoP proof header (RFC 9449 §5). The SDK was minting the proof only for KAS calls — the initial token exchange went out bare and Keycloak responded with 400 invalid_request 'DPoP proof is missing'. Two wiring gaps caused this: 1. AccessToken.refreshTokenClaimsWithClientPubkeyIfNeeded set this.signingKey but left config.dpopEnabled false, so doPost skipped the DPoP branch even after the key was bound. Now it also flips dpopEnabled and clears the cached token (a pre-binding token would lack cnf.jkt and immediately fail downstream KAS calls). 2. CLI processAuth performed a warm-up oidcAuth.get() before OpenTDF constructed, so the first token fetch pre-dated key binding regardless of (1). processAuth now accepts the resolved dpopKeyPair and binds it via updateClientPublicKey before the warm-up call; encrypt/decrypt handlers resolve DPoP first. Existing nonce-retry path in doPost (RFC 9449 §8) is unchanged and exercised by the new regression test. Proof is minted without ath on the token endpoint; ath remains scoped to resource requests. Regression test in dpop-nonce.spec.ts drives the full provider path (clientSecretAuthProvider -> updateClientPublicKey -> get) and reproduces the bug's exact 400 error when the fix is reverted. Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 33 ++++++++++++++++++------------ lib/src/auth/oidc.ts | 5 +++++ lib/tests/mocha/dpop-nonce.spec.ts | 19 +++++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index ba94632ef..8fbf942b8 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -53,14 +53,17 @@ const parseJwtComplete = (jwt: string) => { return { header: parseJwt(jwt, 0), payload: parseJwt(jwt) }; }; -async function processAuth({ - auth, - clientId, - clientSecret, - concurrencyLimit, - oidcEndpoint, - userId, -}: AuthToProcess): Promise { +async function processAuth( + { + auth, + clientId, + clientSecret, + concurrencyLimit, + oidcEndpoint, + userId, + }: AuthToProcess, + dpopKeyPair?: KeyPair +): Promise { log('DEBUG', 'Processing auth params'); if (!oidcEndpoint) { throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); @@ -85,6 +88,11 @@ async function processAuth({ exchange: 'client', clientSecret, }); + // Bind DPoP key before any token fetch so the initial POST /token carries a + // DPoP proof (required by clients with dpop_bound_access_tokens=true). + if (dpopKeyPair) { + await actual.updateClientPublicKey(dpopKeyPair); + } if (concurrencyLimit !== 1) { await actual.oidcAuth.get(); } @@ -580,10 +588,10 @@ export const handleArgs = (args: string[]) => { if (!argv.oidcEndpoint) { throw new CLIError('CRITICAL', 'oidcEndpoint must be specified'); } - const authProvider = await processAuth(argv); + const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); + const authProvider = await processAuth(argv, dpopKeyPair); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); - const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); const client = new OpenTDF({ authProvider, @@ -646,12 +654,11 @@ export const handleArgs = (args: string[]) => { }, async (argv) => { log('DEBUG', 'Running encrypt command'); - const authProvider = await processAuth(argv); + const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); + const authProvider = await processAuth(argv, dpopKeyPair); log('DEBUG', `Initialized auth provider ${JSON.stringify(authProvider)}`); const guessedPolicyEndpoint = guessPolicyUrl(argv); - const { dpopEnabled, dpopKeyPair } = await resolveDPoPFromArgs(argv); - const client = new OpenTDF({ authProvider, defaultCreateOptions: { diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 19c27877d..9bb5a97ad 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -357,6 +357,11 @@ export class AccessToken { } delete this.currentAccessToken; this.signingKey = signingKey; + // Binding a signing key implies DPoP. Enable it on the config so the + // next token request includes a DPoP proof (RFC 9449 §5), and drop any + // cached token from a prior non-DPoP fetch since it won't carry cnf.jkt. + this.config = { ...this.config, dpopEnabled: true, signingKey }; + delete this.data; } /** diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts index a464f89e3..22d7b26ef 100644 --- a/lib/tests/mocha/dpop-nonce.spec.ts +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import { AccessToken } from '../../src/auth/oidc.js'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; import { DefaultCryptoService, generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -77,4 +78,22 @@ describe('DPoP nonce challenge — integration with mock server', function (this expect(response.status).to.equal(200); }); + + it('initial token fetch via clientSecretAuthProvider includes DPoP proof after updateClientPublicKey', async () => { + // Mirrors the CLI path: provider is created without DPoP awareness, then a + // DPoP key is bound via updateClientPublicKey. The very next token POST + // must carry a DPoP header (RFC 9449 §5) and survive the nonce challenge. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + }); + await provider.updateClientPublicKey(keyPair); + + const token = await provider.oidcAuth.get(false); + expect(token).to.equal('test-dpop-token'); + expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + }); }); From 6b83cef299d8342caf0764b98124cbfcd20917fe Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:59:48 +0000 Subject: [PATCH 28/45] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 8fbf942b8..82bb457b7 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -54,14 +54,7 @@ const parseJwtComplete = (jwt: string) => { }; async function processAuth( - { - auth, - clientId, - clientSecret, - concurrencyLimit, - oidcEndpoint, - userId, - }: AuthToProcess, + { auth, clientId, clientSecret, concurrencyLimit, oidcEndpoint, userId }: AuthToProcess, dpopKeyPair?: KeyPair ): Promise { log('DEBUG', 'Processing auth params'); From 3511081bc73887482a69fa8c39ae7b77f4e92fcd Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 17 Jun 2026 17:15:12 -0400 Subject: [PATCH 29/45] fix(dpop): scope DPoP enablement to providers configured with a key (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e693605b enabled DPoP unconditionally inside AccessToken.refreshTokenClaimsWithClientPubkeyIfNeeded. But TDF3Client.createSessionKeys always calls updateClientPublicKey — even for non-DPoP flows — because the same key is reused for TDF body signing. The unconditional flip therefore turned DPoP on for every CLI invocation, including 'opentdf:secret' (non-DPoP client). Keycloak then issued a DPoP-bound token (cnf.jkt set), but the SDK's Connect-RPC interceptors still presented it as plain Bearer to the platform, producing 401 on /key-access-servers and breaking test_legacy.py::test_decrypt_* in xtest. Plumb dpopEnabled and signingKey through clientSecretAuthProvider, refreshAuthProvider, externalAuthProvider and their provider classes into the AccessToken constructor (the AccessToken type already accepted these fields). The CLI now passes them at construction time so DPoP is on iff --dpop was requested. refreshTokenClaimsWithClientPubkeyIfNeeded no longer flips dpopEnabled. It still drops the cached token when DPoP is on (rotating the key invalidates cnf.jkt) but leaves cached non-DPoP Bearer tokens alone — they're key-independent. dpop-nonce.spec.ts: updated the DPoP-path test to use the new config-time wiring, and added a non-DPoP test asserting that updateClientPublicKey (the call TDF3Client.createSessionKeys makes unconditionally) does NOT flip dpopEnabled. Verified it fails on e693605b and passes here. Signed-off-by: Dave Mihalcik --- cli/src/cli.ts | 13 ++++---- .../auth/oidc-clientcredentials-provider.ts | 4 +++ lib/src/auth/oidc-externaljwt-provider.ts | 4 +++ lib/src/auth/oidc-refreshtoken-provider.ts | 4 +++ lib/src/auth/oidc.ts | 11 +++---- lib/src/auth/providers.ts | 6 ++++ lib/tests/mocha/dpop-nonce.spec.ts | 30 +++++++++++++++---- 7 files changed, 57 insertions(+), 15 deletions(-) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 82bb457b7..fad9b695d 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -75,17 +75,20 @@ async function processAuth( 'Auth expects clientId and clientSecret, or combined auth param' ); } + // Pass DPoP key into the provider config so the AccessToken is born with + // DPoP enabled (config.dpopEnabled + signingKey). Without this, the very + // first POST /token would go out without a DPoP proof — Keycloak clients + // with dpop_bound_access_tokens=true reject that with 400 invalid_request. + // Without a key, DPoP stays off so non-DPoP clients still get plain Bearer + // tokens that the platform will accept. const actual = await AuthProviders.clientSecretAuthProvider({ clientId, oidcOrigin: oidcEndpoint, exchange: 'client', clientSecret, + dpopEnabled: !!dpopKeyPair, + signingKey: dpopKeyPair, }); - // Bind DPoP key before any token fetch so the initial POST /token carries a - // DPoP proof (required by clients with dpop_bound_access_tokens=true). - if (dpopKeyPair) { - await actual.updateClientPublicKey(dpopKeyPair); - } if (concurrencyLimit !== 1) { await actual.oidcAuth.get(); } diff --git a/lib/src/auth/oidc-clientcredentials-provider.ts b/lib/src/auth/oidc-clientcredentials-provider.ts index 8d3629bae..6a4ba83bb 100644 --- a/lib/src/auth/oidc-clientcredentials-provider.ts +++ b/lib/src/auth/oidc-clientcredentials-provider.ts @@ -14,6 +14,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -29,6 +31,8 @@ export class OIDCClientCredentialsProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); diff --git a/lib/src/auth/oidc-externaljwt-provider.ts b/lib/src/auth/oidc-externaljwt-provider.ts index 2a7266882..cfe66bff2 100644 --- a/lib/src/auth/oidc-externaljwt-provider.ts +++ b/lib/src/auth/oidc-externaljwt-provider.ts @@ -15,6 +15,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -30,6 +32,8 @@ export class OIDCExternalJwtProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); diff --git a/lib/src/auth/oidc-refreshtoken-provider.ts b/lib/src/auth/oidc-refreshtoken-provider.ts index 9f7bac2d9..c23a25b04 100644 --- a/lib/src/auth/oidc-refreshtoken-provider.ts +++ b/lib/src/auth/oidc-refreshtoken-provider.ts @@ -29,6 +29,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }: Partial & Omit, cryptoService: CryptoService = defaultCryptoService ) { @@ -44,6 +46,8 @@ export class OIDCRefreshTokenProvider implements AuthProvider { oidcOrigin, oidcTokenEndpoint, oidcUserInfoEndpoint, + dpopEnabled, + signingKey, }, cryptoService ); diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index 9bb5a97ad..fb3f9df73 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -357,11 +357,12 @@ export class AccessToken { } delete this.currentAccessToken; this.signingKey = signingKey; - // Binding a signing key implies DPoP. Enable it on the config so the - // next token request includes a DPoP proof (RFC 9449 §5), and drop any - // cached token from a prior non-DPoP fetch since it won't carry cnf.jkt. - this.config = { ...this.config, dpopEnabled: true, signingKey }; - delete this.data; + // A DPoP-bound token (cnf.jkt) is tied to a specific key; rotating the + // signing key invalidates any cached token. Non-DPoP tokens are key- + // independent and can stay cached. + if (this.config.dpopEnabled) { + delete this.data; + } } /** diff --git a/lib/src/auth/providers.ts b/lib/src/auth/providers.ts index d5893f609..b36d3134a 100644 --- a/lib/src/auth/providers.ts +++ b/lib/src/auth/providers.ts @@ -42,6 +42,8 @@ export const clientSecretAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); @@ -74,6 +76,8 @@ export const externalAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); @@ -104,6 +108,8 @@ export const refreshAuthProvider = async ( oidcOrigin: clientConfig.oidcOrigin, oidcTokenEndpoint: clientConfig.oidcTokenEndpoint, oidcUserInfoEndpoint: clientConfig.oidcUserInfoEndpoint, + dpopEnabled: clientConfig.dpopEnabled, + signingKey: clientConfig.signingKey, }, cryptoService ); diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts index 22d7b26ef..c01d9562d 100644 --- a/lib/tests/mocha/dpop-nonce.spec.ts +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -79,21 +79,41 @@ describe('DPoP nonce challenge — integration with mock server', function (this expect(response.status).to.equal(200); }); - it('initial token fetch via clientSecretAuthProvider includes DPoP proof after updateClientPublicKey', async () => { - // Mirrors the CLI path: provider is created without DPoP awareness, then a - // DPoP key is bound via updateClientPublicKey. The very next token POST - // must carry a DPoP header (RFC 9449 §5) and survive the nonce challenge. + it('initial token fetch via clientSecretAuthProvider sends DPoP proof when configured with a signing key', async () => { + // Mirrors the CLI path: when --dpop is set, the provider is constructed + // with dpopEnabled + signingKey so the very first POST /token carries a + // DPoP header (RFC 9449 §5) and survives the nonce challenge. const provider = await clientSecretAuthProvider({ clientId: 'test-client', clientSecret: 'test-secret', oidcOrigin: SERVER_ORIGIN, oidcTokenEndpoint: TOKEN_URL, exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, }); - await provider.updateClientPublicKey(keyPair); const token = await provider.oidcAuth.get(false); expect(token).to.equal('test-dpop-token'); expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); }); + + it('omits DPoP header when no signing key is configured, even after updateClientPublicKey binds one for body signing', async () => { + // Mirrors the legacy/non-DPoP CLI path: no --dpop flag, but TDF3Client. + // createSessionKeys still calls updateClientPublicKey to bind a key used + // for TDF body signing. The token POST must NOT include a DPoP header, + // otherwise Keycloak issues a DPoP-bound token that the platform's + // Connect-RPC interceptors then present as plain Bearer → 401. + const provider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: 'http://localhost:3000', // any origin; we never hit token endpoint here + oidcTokenEndpoint: 'http://localhost:3000/protocol/openid-connect/non-dpop-token', + exchange: 'client', + // No dpopEnabled / signingKey — non-DPoP flow. + }); + await provider.updateClientPublicKey(keyPair); + // The exposed AccessToken config must remain non-DPoP after the bind. + expect(provider.oidcAuth.config.dpopEnabled).to.not.equal(true); + }); }); From 28a8280dc5b0104fa9505fbc0c0b546cdb77b1ca Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 17 Jun 2026 18:19:55 -0400 Subject: [PATCH 30/45] fix(dpop): emit raw IEEE P1363 ECDSA sigs in DPoP proofs; strict mock verifier (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keycloak rejected our DPoP proofs with TokenSignatureInvalidException (Invalid token signature) because cryptoService.sign re-encodes ECDSA signatures in DER, while RFC 7518 §3.4 (the JWS spec DPoP inherits via RFC 9449) mandates raw R||S concatenation. crypto.subtle already returns that raw form; the DER re-encode in signing.ts:189-192 is the bug. Scope this change to DPoP only: - Export derToIeeeP1363 from lib/tdf3/src/crypto/core/signing.ts. - In lib/src/auth/dpop.ts, convert DER → raw at the JWS call site for ES* algs before base64url-encoding. RSA/EdDSA pass through unchanged. - Do NOT touch cryptoService.sign/verify or jwt.ts::signJwt — those back TDF assertion signing, and flipping their format would break reading existing ES256-signed assertions from older SDK versions. Tracked separately as DSPX-3634. Harden the mock test server so this regression is caught locally: - lib/tests/server.ts token endpoint: replace jose.decodeJwt with a full RFC 9449 verifier — decodeProtectedHeader (typ/alg/jwk no-priv checks), importJWK + jwtVerify (catches DER), htm/htu/iat/jti claim validation. Fix existing wrong 401 → 400 on use_dpop_nonce per §8. - Add a resource-server DPoP block to the rewrap handler: ath + cnf.jkt binding (Map tracked across token mint and rewrap). 401 + WWW-Authenticate: DPoP for nonce challenge. New regression tests in lib/tests/mocha/dpop-proof.spec.ts: - ES256/ES384/ES512 round-trip proofs minted by dpopFn through jose.jwtVerify (the verifier inside real Keycloak). The pre-fix dpop.js produces DER and fails all three with JWSSignatureVerificationFailed — verified by adversarial revert. - Negative: flipped signature byte → rejected. - Negative: forged proof with swapped jwk header → rejected. Verification: lib 346 mocha pass (+9 new); cli 25 pass; assertion and crypto-service unit tests unaffected (Part A scoped only to DPoP). Signed-off-by: Dave Mihalcik --- lib/src/auth/dpop.ts | 16 +- lib/tdf3/src/crypto/core/signing.ts | 13 +- lib/tests/mocha/dpop-proof.spec.ts | 169 +++++++++++++++ lib/tests/server.ts | 310 ++++++++++++++++++++++++++-- 4 files changed, 481 insertions(+), 27 deletions(-) create mode 100644 lib/tests/mocha/dpop-proof.spec.ts diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index 61a6ae0ec..e8e85c8ea 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -7,6 +7,7 @@ import type { PrivateKey, AsymmetricSigningAlgorithm, } from '../../tdf3/src/crypto/declarations.js'; +import { derToIeeeP1363 } from '../../tdf3/src/crypto/core/signing.js'; export type JsonObject = { [Key in string]?: JsonValue }; export type JsonArray = JsonValue[]; @@ -35,11 +36,16 @@ async function jwt( cryptoService: CryptoService ) { const input = `${b64u(buf(JSON.stringify(header)))}.${b64u(buf(JSON.stringify(claimsSet)))}`; - const signature = await cryptoService.sign( - buf(input), - privateKey, - header.alg as AsymmetricSigningAlgorithm - ); + const alg = header.alg as AsymmetricSigningAlgorithm; + let signature = await cryptoService.sign(buf(input), privateKey, alg); + // JWS requires raw IEEE P1363 (R || S) for ECDSA per RFC 7518 §3.4, but + // cryptoService.sign currently returns DER. Convert here so DPoP proofs are + // accepted by RFC-conformant verifiers (Keycloak, panva-jose). RSA/EdDSA + // signatures are already raw bytes — no conversion. See DSPX-3634 for the + // broader cleanup that would make this transform unnecessary. + if (alg.startsWith('ES')) { + signature = derToIeeeP1363(signature, alg); + } return `${input}.${b64u(signature)}`; } diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index c3b824f9d..c1dffc604 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -94,10 +94,17 @@ function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgor } /** - * Convert DER signature format (used by JWT) to IEEE P1363 format (used by WebCrypto ECDSA). - * RS256 signatures don't need conversion. + * Convert DER-encoded ECDSA signature to raw IEEE P1363 (R||S) format. + * RS256 signatures pass through unchanged. + * + * Exported because callers that emit JWS (e.g. DPoP proofs in lib/src/auth/dpop.ts) + * must produce raw R||S per RFC 7518 §3.4, while cryptoService.sign() currently + * returns DER. See DSPX-3634 for the broader cleanup. */ -function derToIeeeP1363(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { +export function derToIeeeP1363( + signature: Uint8Array, + algorithm: AsymmetricSigningAlgorithm +): Uint8Array { if (algorithm === 'RS256') { return signature; } diff --git a/lib/tests/mocha/dpop-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts new file mode 100644 index 000000000..58a04121f --- /dev/null +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -0,0 +1,169 @@ +import { expect } from 'chai'; +import * as jose from 'jose'; + +import dpopFn from '../../src/auth/dpop.js'; +import { DefaultCryptoService } from '../../tdf3/src/crypto/index.js'; +import { importPrivateKey, importPublicKey } from '../../tdf3/src/crypto/core/key-format.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +/** + * End-to-end DPoP proof signing tests. + * + * These tests verify the proofs minted by `dpopFn` (the function called from + * `AccessToken.doPost` and `withCreds`) against an independent, RFC 9449 / + * RFC 7518 §3.4 conformant verifier (`jose.jwtVerify`). + * + * Why these tests exist: the SDK's internal sign/verify pair is symmetric + * (both encode/decode ECDSA signatures as DER), so it round-trips inside this + * SDK even when the wire format is non-conformant. `jose.jwtVerify` is the + * same library used by real Keycloak under the hood — feeding our proofs + * through it catches DER-vs-raw and similar bugs that the in-SDK round-trip + * cannot. The earlier DSPX-3397 "Invalid token signature" failure from + * Keycloak would have been caught locally by these tests. + */ + +const HTU = 'https://example.test/protocol/openid-connect/token'; +const HTM = 'POST'; + +async function ecdsaKeyPair(namedCurve: 'P-256' | 'P-384' | 'P-521'): Promise { + // Generate via raw WebCrypto, then round-trip through PEM to obtain the + // SDK's opaque PrivateKey/PublicKey types (the same dance the CLI does in + // `cli/src/dpop-helpers.ts`). Keeps the test aligned with what real DPoP + // callers feed `dpopFn`. + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + const [privDer, pubDer] = await Promise.all([ + crypto.subtle.exportKey('pkcs8', raw.privateKey), + crypto.subtle.exportKey('spki', raw.publicKey), + ]); + const privPem = derToPem(new Uint8Array(privDer), 'PRIVATE KEY'); + const pubPem = derToPem(new Uint8Array(pubDer), 'PUBLIC KEY'); + const [privateKey, publicKey] = await Promise.all([ + importPrivateKey(privPem, { usage: 'sign', extractable: true }), + importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { publicKey, privateKey }; +} + +function derToPem(der: Uint8Array, label: string): string { + let b = ''; + for (let i = 0; i < der.length; i++) b += String.fromCharCode(der[i]); + const b64 = btoa(b).match(/.{1,64}/g)?.join('\n') ?? btoa(b); + return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----`; +} + +const CURVES: Array<{ namedCurve: 'P-256' | 'P-384' | 'P-521'; alg: 'ES256' | 'ES384' | 'ES512' }> = + [ + { namedCurve: 'P-256', alg: 'ES256' }, + { namedCurve: 'P-384', alg: 'ES384' }, + { namedCurve: 'P-521', alg: 'ES512' }, + ]; + +describe('DPoP proof — JWS conformance vs jose.jwtVerify (RFC 9449 + RFC 7518 §3.4)', function ( + this: Mocha.Suite +) { + this.timeout(10_000); + + for (const { namedCurve, alg } of CURVES) { + it(`${alg} proof verifies against jose.jwtVerify`, async () => { + const kp = await ecdsaKeyPair(namedCurve); + const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); + + // Verify with the public key extracted from the proof's own header, the + // way a real DPoP-aware server (Keycloak) would. + const header = jose.decodeProtectedHeader(proof); + expect(header.typ).to.equal('dpop+jwt'); + expect(header.alg).to.equal(alg); + expect(header.jwk).to.exist; + + const key = await jose.importJWK(header.jwk as jose.JWK, alg); + const { payload } = await jose.jwtVerify(proof, key); + expect(payload.htu).to.equal(HTU); + expect(payload.htm).to.equal(HTM); + expect(payload.jti).to.be.a('string').and.have.length.greaterThan(0); + expect(payload.iat).to.be.a('number'); + }); + + it(`${alg} proof verification rejects a flipped signature byte`, async () => { + const kp = await ecdsaKeyPair(namedCurve); + const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); + const tampered = flipOneBitInSignatureSegment(proof); + + const header = jose.decodeProtectedHeader(proof); + const key = await jose.importJWK(header.jwk as jose.JWK, alg); + let threw = false; + try { + await jose.jwtVerify(tampered, key); + } catch { + threw = true; + } + expect(threw, 'jose.jwtVerify must reject a tampered signature').to.equal(true); + }); + + it(`${alg} proof verification rejects a swapped jwk header (binding intact, key wrong)`, async () => { + const kp1 = await ecdsaKeyPair(namedCurve); + const kp2 = await ecdsaKeyPair(namedCurve); + + const proof = await dpopFn(kp1, DefaultCryptoService, HTU, HTM); + + // Build a forged proof: same payload + signature but kp2's public JWK in + // the header. A correct verifier must reject because the signature was + // made by kp1.privateKey. + const [hdrB64, payloadB64, sigB64] = proof.split('.'); + const realHeader = JSON.parse( + new TextDecoder().decode(jose.base64url.decode(hdrB64)) + ) as jose.ProtectedHeaderParameters; + const fakeJwk = await crypto.subtle.exportKey( + 'jwk', + await jose.importJWK( + (await proofHeaderJwkFor(kp2, alg)) as jose.JWK, + alg + ) as CryptoKey + ); + delete (fakeJwk as Record).d; + delete (fakeJwk as Record).key_ops; + realHeader.jwk = fakeJwk as jose.JWK; + const forgedHdrB64 = jose.base64url.encode( + new TextEncoder().encode(JSON.stringify(realHeader)) + ); + const forged = `${forgedHdrB64}.${payloadB64}.${sigB64}`; + + const key = await jose.importJWK(realHeader.jwk as jose.JWK, alg); + let threw = false; + try { + await jose.jwtVerify(forged, key); + } catch { + threw = true; + } + expect(threw, 'jose.jwtVerify must reject a forged proof with mismatched jwk').to.equal(true); + }); + } +}); + +/** + * Mint a real proof solely to extract a clean JWK for the public key. + * Round-tripping through `dpopFn` ensures the JWK shape matches what the + * SDK emits in real proofs. + */ +async function proofHeaderJwkFor( + kp: KeyPair, + alg: 'ES256' | 'ES384' | 'ES512' +): Promise { + const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); + const header = jose.decodeProtectedHeader(proof); + void alg; // alg unused; kept in signature for caller clarity + return header.jwk; +} + +/** + * Flip exactly one bit of the base64url-decoded signature segment. + * Re-encodes back into the JWT compact form. + */ +function flipOneBitInSignatureSegment(jwt: string): string { + const [h, p, s] = jwt.split('.'); + const sig = jose.base64url.decode(s); + sig[0] ^= 0x01; + return `${h}.${p}.${jose.base64url.encode(sig)}`; +} diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 589c47433..5a0f38e32 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -24,6 +24,234 @@ import { const Mocks = getMocks(); +// ============================================================================= +// DPoP proof verification (RFC 9449 + RFC 7518 §3.4) for the mock server. +// Strict on purpose: this is what real Keycloak / panva-jose do, so when a +// regression in our SDK's proof minting (e.g. DER-encoded ECDSA) lands, the +// integration tests fail locally instead of only at xtest time. +// ============================================================================= + +const DPOP_TOKEN_NONCE = 'dpop-test-nonce-abc'; +const DPOP_RS_NONCE = 'dpop-test-rs-nonce-xyz'; +const DPOP_IAT_SKEW_SECONDS = 60; + +// access_token → JWK SHA-256 thumbprint of the key it was bound to. +// Populated by the token endpoint when minting a DPoP-bound token; consulted +// by the KAS rewrap handler to enforce RFC 9449 §6.1 jkt binding. +const dpopBoundJkts = new Map(); + +// Seen jti values per minted-by-this-server lifetime to detect replay. +// Real servers would TTL-evict; this is a test mock, full clear on shutdown is fine. +const seenJtis = new Set(); + +type DPoPCheckOpts = { + htm: string; + htu: string; + requireAth?: { accessToken: string }; + requireBoundJkt?: string; + requireNonce?: string; +}; + +type DPoPCheckResult = + | { ok: true; jkt: string; jti: string; payload: jose.JWTPayload } + | { + ok: false; + status: number; + error: string; + error_description: string; + // If set, the server must include this DPoP-Nonce header so the client retries. + challengeNonce?: string; + }; + +/** Strict-mode parse and verify a DPoP proof per RFC 9449 + RFC 7518 §3.4. */ +async function verifyDpopProof( + rawProof: string | undefined, + opts: DPoPCheckOpts +): Promise { + if (!rawProof) { + return { + ok: false, + status: 400, + error: 'invalid_request', + error_description: 'DPoP header required', + }; + } + + let protectedHeader: jose.ProtectedHeaderParameters; + try { + protectedHeader = jose.decodeProtectedHeader(rawProof); + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `cannot decode DPoP header: ${(err as Error).message}`, + }; + } + if (protectedHeader.typ !== 'dpop+jwt') { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `typ must be "dpop+jwt", got ${String(protectedHeader.typ)}`, + }; + } + const alg = protectedHeader.alg; + if ( + !alg || + alg === 'none' || + alg.startsWith('HS') || + !/^(ES|RS|PS|EdDSA)/.test(alg) + ) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `alg "${String(alg)}" is not an allowed asymmetric JWS alg`, + }; + } + const jwk = protectedHeader.jwk; + if (!jwk || typeof jwk !== 'object') { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jwk header parameter missing', + }; + } + for (const forbidden of ['d', 'p', 'q', 'dp', 'dq', 'qi', 'k']) { + if (forbidden in (jwk as Record)) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `jwk must not contain private parameter "${forbidden}"`, + }; + } + } + + let key: jose.CryptoKey | Uint8Array; + try { + key = (await jose.importJWK(jwk as jose.JWK, alg)) as jose.CryptoKey; + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `cannot import jwk: ${(err as Error).message}`, + }; + } + + let payload: jose.JWTPayload; + try { + ({ payload } = await jose.jwtVerify(rawProof, key)); + } catch (err) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `signature verification failed: ${(err as Error).message}`, + }; + } + + if (payload.htm !== opts.htm) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `htm mismatch: expected ${opts.htm}, got ${String(payload.htm)}`, + }; + } + if (payload.htu !== opts.htu) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `htu mismatch: expected ${opts.htu}, got ${String(payload.htu)}`, + }; + } + const now = Math.floor(Date.now() / 1000); + if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > DPOP_IAT_SKEW_SECONDS) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: `iat out of window (${String(payload.iat)} vs server now ${now})`, + }; + } + if (typeof payload.jti !== 'string' || payload.jti.length === 0) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jti claim required', + }; + } + if (seenJtis.has(payload.jti)) { + return { + ok: false, + status: 400, + error: 'invalid_dpop_proof', + error_description: 'jti replay detected', + }; + } + + if (opts.requireNonce && payload.nonce !== opts.requireNonce) { + return { + ok: false, + status: 0, // caller decides 400 (AS) vs 401 (RS) + error: 'use_dpop_nonce', + error_description: 'DPoP nonce required', + challengeNonce: opts.requireNonce, + }; + } + + if (opts.requireAth) { + const expected = await athClaim(opts.requireAth.accessToken); + if (payload.ath !== expected) { + return { + ok: false, + status: 401, + error: 'invalid_dpop_proof', + error_description: `ath mismatch: expected ${expected}, got ${String(payload.ath)}`, + }; + } + } + + const jkt = await jose.calculateJwkThumbprint(jwk as jose.JWK); + if (opts.requireBoundJkt && opts.requireBoundJkt !== jkt) { + return { + ok: false, + status: 401, + error: 'invalid_token', + error_description: 'access token cnf.jkt does not match DPoP proof jkt', + }; + } + + seenJtis.add(payload.jti); + return { ok: true, jkt, jti: payload.jti, payload }; +} + +/** RFC 9449 §6.1: ath = base64url-nopad(SHA-256(ASCII(access_token))). */ +async function athClaim(accessToken: string): Promise { + const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken)); + return base64UrlNoPad(new Uint8Array(hash)); +} + +function base64UrlNoPad(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +/** Build the htu (target URI sans query and fragment) for a server request. */ +function requestHtu(req: IncomingMessage): string { + // The test server listens on http://localhost:3000; URL fields beyond + // pathname (query, fragment) MUST be stripped per RFC 9449 §4.2. + const url = new URL(req.url ?? '/', 'http://localhost:3000'); + return `${url.origin}${url.pathname}`; +} + function range(start: number, end: number): Uint8Array { const result = []; for (let i = start; i <= end; i++) { @@ -196,6 +424,45 @@ const kas: RequestListener = async (req, res) => { res.end(JSON.stringify({ code: 'unauthenticated', message: 'unauthenticated' })); return; } + + // Strict RFC 9449 DPoP resource-server check. Only triggers when the + // request actually carries `Authorization: DPoP `; non-DPoP + // (Bearer or unauthenticated) callers pass through unchanged so the + // many non-DPoP rewrap tests keep working. + const authHeader = (req.headers['authorization'] as string | undefined) ?? ''; + const dpopMatch = /^DPoP\s+(.+)$/.exec(authHeader); + if (dpopMatch) { + const accessToken = dpopMatch[1]; + const boundJkt = dpopBoundJkts.get(accessToken); + const proofCheck = await verifyDpopProof(req.headers['dpop'] as string | undefined, { + htm: 'POST', + htu: requestHtu(req), + requireAth: { accessToken }, + requireBoundJkt: boundJkt, + requireNonce: DPOP_RS_NONCE, + }); + if (!proofCheck.ok) { + // RFC 9449 §8: RS uses 401 + WWW-Authenticate: DPoP error="..." for + // nonce challenges (vs AS which uses 400). Other proof failures get + // 401 invalid_token (downstream platforms vary; tests can match on the + // error code rather than status). + const status = proofCheck.error === 'use_dpop_nonce' ? 401 : proofCheck.status || 401; + const headers: Record = { 'Content-Type': 'application/json' }; + if (proofCheck.challengeNonce) { + headers['DPoP-Nonce'] = proofCheck.challengeNonce; + headers['WWW-Authenticate'] = `DPoP error="${proofCheck.error}"`; + } + res.writeHead(status, headers); + res.end( + JSON.stringify({ + error: proofCheck.error, + error_description: proofCheck.error_description, + }) + ); + return; + } + } + const body = await getBody(req); const bodyText = new TextDecoder().decode(body); const { signedRequestToken } = JSON.parse(bodyText); @@ -473,33 +740,38 @@ const kas: RequestListener = async (req, res) => { res.end(JSON.stringify({ status: 'ok' })); return; } else if (url.pathname === '/protocol/openid-connect/token') { - // DPoP nonce challenge test endpoint — simulates a Keycloak token endpoint. - // Always challenges the first request (no nonce in DPoP JWT) with a fixed nonce. - // Accepts the retry once the DPoP proof includes the expected nonce. - const DPOP_TEST_NONCE = 'dpop-test-nonce-abc'; + // Mock Keycloak token endpoint with strict RFC 9449 DPoP verification. + // First request gets a nonce challenge (400 + use_dpop_nonce + DPoP-Nonce header + // per RFC 9449 §8 — note: AS uses 400, RS uses 401). The retry must include + // a proof whose `nonce` claim matches. const dpopHeader = req.headers['dpop'] as string | undefined; - if (!dpopHeader) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ error: 'invalid_request', error_description: 'DPoP header required' }) - ); - return; - } - const dpopPayload = jose.decodeJwt(dpopHeader); - if (dpopPayload.nonce !== DPOP_TEST_NONCE) { - res.writeHead(401, { - 'Content-Type': 'application/json', - 'DPoP-Nonce': DPOP_TEST_NONCE, - }); + const htu = requestHtu(req); + const check = await verifyDpopProof(dpopHeader, { + htm: 'POST', + htu, + requireNonce: DPOP_TOKEN_NONCE, + }); + if (!check.ok) { + const status = + check.error === 'use_dpop_nonce' ? 400 : check.status > 0 ? check.status : 400; + const headers: Record = { 'Content-Type': 'application/json' }; + if (check.challengeNonce) headers['DPoP-Nonce'] = check.challengeNonce; + res.writeHead(status, headers); res.end( - JSON.stringify({ error: 'use_dpop_nonce', error_description: 'DPoP nonce required' }) + JSON.stringify({ error: check.error, error_description: check.error_description }) ); return; } + + // Mint an opaque access token; bind it to the DPoP proof's JWK thumbprint + // so the rewrap handler (RS-side, below) can enforce cnf.jkt binding. + const accessToken = 'test-dpop-token'; + dpopBoundJkts.set(accessToken, check.jkt); + res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end( - JSON.stringify({ access_token: 'test-dpop-token', token_type: 'DPoP', expires_in: 3600 }) + JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 }) ); return; } else { From 99ab7ac8a23419c5625971acd7b02cbc315eaae0 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 24 Jun 2026 13:11:09 -0400 Subject: [PATCH 31/45] fix(dpop): pass full request URL to AuthProvider.withCreds (DSPX-3397) The authProviderInterceptor handed withCreds only the URL pathname. A DPoP-enabled AuthProvider computes the proof's htu claim and the nonce cache origin via new URL(req.url), which throws "Invalid URL" on a bare path. This surfaced as a CRITICAL [GetAttributeValuesByFqns] [unknown] Invalid URL during encrypt, and as the masked v2 request error in the KAS-list fallback during decrypt. Pass the absolute req.url instead. Non-DPoP providers ignore the URL (they only add a Bearer header), so legacy AuthProviders are unaffected. Signed-off-by: Dave Mihalcik --- lib/src/auth/interceptors.ts | 10 ++++++---- lib/tests/web/interceptors.test.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 8c68e17b1..7b634d361 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -172,13 +172,15 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI */ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor { return (next) => async (req) => { - const url = new URL(req.url); - const pathOnly = url.pathname; - // Signs only the path of the url in the request + // Pass the full request URL to withCreds. DPoP-enabled providers need the + // absolute URL to compute the proof's `htu` claim and the origin for the + // nonce cache; `new URL()` on a bare path throws "Invalid URL". Non-DPoP + // providers ignore the URL (they only add a Bearer header), so this stays + // backwards-compatible with legacy AuthProviders. let token; try { token = await authProvider.withCreds({ - url: pathOnly, + url: req.url, method: 'POST', // Start with any headers Connect already has headers: { diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 4c71edd97..932fb1633 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -142,6 +142,27 @@ describe('authProviderInterceptor', () => { expect(headers.get('X-Custom')).to.equal('custom-value'); }); + it('passes the full request URL to withCreds (not just the path)', async () => { + // Regression: a DPoP-enabled provider computes the proof `htu` and nonce + // origin via `new URL(req.url)`, which throws on a bare path. The + // interceptor must hand withCreds the absolute URL. + let seenUrl: string | undefined; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (req: HttpRequest) => { + seenUrl = req.url; + // Mimic a DPoP provider that parses the URL; a bare path throws here. + new URL(req.url); + return withHeaders(req, { Authorization: 'DPoP token' }); + }, + }; + + const interceptor = authProviderInterceptor(mockAuthProvider); + await captureHeaders(interceptor, 'https://platform.example.com/policy.attributes/Get'); + + expect(seenUrl).to.equal('https://platform.example.com/policy.attributes/Get'); + }); + it('wraps updateClientPublicKey errors with helpful message', async () => { const failingProvider: AuthProvider = { updateClientPublicKey: async () => {}, From 90760ed06050e53dd6fa3a1b49f3f8d0dc86ba2d Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 24 Jun 2026 13:23:27 -0400 Subject: [PATCH 32/45] fix(dpop): nonce-challenge retry on legacy fetch path; strip query from htu (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy resource-server fetch helpers (fetchKeyAccessServers, fetchWrappedKey) signed once via AuthProvider.withCreds and fetched once, with no DPoP-Nonce challenge handling. On the first request to a platform origin there is no cached nonce, so the server replies 401 + use_dpop_nonce + DPoP-Nonce and the helper gives up — the "unable to fetch kas list ... status: 401" seen during decrypt. Add fetchWithCredsAndNonceRetry: on a non-ok response carrying a fresh DPoP-Nonce, cache it by origin and retry once so withCreds can mint a proof bound to it. Non-DPoP providers/servers never emit DPoP-Nonce, so they keep the single-request path (legacy users unaffected). Also fix AccessToken.withCreds to strip query/fragment from the proof's htu claim per RFC 9449 §4.2; the kas-list URL carries ?pagination.offset=0, which an RFC-conformant verifier rejects. Signed-off-by: Dave Mihalcik --- lib/src/access/access-fetch.ts | 143 +++++++++++++++------- lib/src/auth/oidc.ts | 9 +- lib/tests/web/access/access-fetch.test.ts | 86 +++++++++++++ lib/tests/web/auth/auth.test.ts | 33 +++++ 4 files changed, 223 insertions(+), 48 deletions(-) diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index b69ec9148..e718b9025 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -1,5 +1,6 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../access.js'; -import { type AuthProvider } from '../auth/auth.js'; +import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; +import { DPoPNonceCache, globalNonceCache } from '../auth/dpop-nonce.js'; import { ConfigurationError, InvalidFileError, @@ -10,6 +11,67 @@ import { } from '../errors.js'; import { validateSecureUrl } from '../utils.js'; +/** fetch() options shared by the authenticated legacy requests. */ +type FetchInit = Omit; + +/** + * Signs `httpReq` via the AuthProvider, sends it, and handles a single + * DPoP-Nonce challenge (RFC 9449 §9): if a resource server rejects the request + * with a fresh `DPoP-Nonce` header, cache the nonce and retry once so + * `withCreds` can mint a proof carrying it. Non-DPoP providers and servers + * never emit a `DPoP-Nonce`, so they take the single-request path unchanged. + * + * The caller keeps ownership of status-code handling; this only owns transport + * and the nonce retry. + */ +async function fetchWithCredsAndNonceRetry( + authProvider: AuthProvider, + httpReq: HttpRequest, + init: FetchInit, + networkErrorMessage: string +): Promise { + const send = async (): Promise => { + const req = await authProvider.withCreds(httpReq); + try { + return await fetch(req.url, { + ...init, + method: req.method, + headers: req.headers, + body: req.body as BodyInit, + }); + } catch (e) { + throw new NetworkError(`${networkErrorMessage} [${req.url}]`, e); + } + }; + + let origin: string | undefined; + try { + origin = new URL(httpReq.url).origin; + } catch { + // Non-absolute URL: nonce caching is keyed by origin, so just pass through. + } + + let response = await send(); + + if (!response.ok && origin) { + const challengeNonce = DPoPNonceCache.extractNonce(response.headers); + if (challengeNonce && challengeNonce !== globalNonceCache.get(origin)) { + globalNonceCache.set(origin, challengeNonce); + response = await send(); + } + } + + // Keep the cache warm from whichever response we end on. + if (origin) { + const responseNonce = DPoPNonceCache.extractNonce(response.headers); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + } + + return response; +} + export type RewrapRequest = { signedRequestToken: string; }; @@ -33,51 +95,43 @@ export async function fetchWrappedKey( requestBody: RewrapRequest, authProvider: AuthProvider ): Promise { - const req = await authProvider.withCreds({ - url, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }); - - let response: Response; - - try { - response = await fetch(req.url, { - method: req.method, + const response = await fetchWithCredsAndNonceRetry( + authProvider, + { + url, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + } as HttpRequest, + { mode: 'cors', // no-cors, *cors, same-origin cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached credentials: 'same-origin', // include, *same-origin, omit - headers: req.headers, redirect: 'follow', // manual, *follow, error referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url - body: req.body as BodyInit, - }); - } catch (e) { - throw new NetworkError(`unable to fetch wrapped key from [${url}]`, e); - } + }, + 'unable to fetch wrapped key from' + ); if (!response.ok) { switch (response.status) { case 400: throw new InvalidFileError( - `400 for [${req.url}]: rewrap bad request [${await response.text()}]` + `400 for [${url}]: rewrap bad request [${await response.text()}]` ); case 401: - throw new UnauthenticatedError(`401 for [${req.url}]; rewrap auth failure`); + throw new UnauthenticatedError(`401 for [${url}]; rewrap auth failure`); case 403: - throw new PermissionDeniedError(`403 for [${req.url}]; rewrap permission denied`); + throw new PermissionDeniedError(`403 for [${url}]; rewrap permission denied`); default: if (response.status >= 500) { throw new ServiceError( - `${response.status} for [${req.url}]: rewrap failure due to service error [${await response.text()}]` + `${response.status} for [${url}]: rewrap failure due to service error [${await response.text()}]` ); } - throw new NetworkError( - `${req.method} ${req.url} => ${response.status} ${response.statusText}` - ); + throw new NetworkError(`POST ${url} => ${response.status} ${response.statusText}`); } } @@ -91,32 +145,29 @@ export async function fetchKeyAccessServers( let nextOffset = 0; const allServers = []; do { - const req = await authProvider.withCreds({ - url: `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`, - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - let response: Response; - try { - response = await fetch(req.url, { - method: req.method, - headers: req.headers, - body: req.body as BodyInit, + const requestUrl = `${platformUrl}/key-access-servers?pagination.offset=${nextOffset}`; + const response = await fetchWithCredsAndNonceRetry( + authProvider, + { + url: requestUrl, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } as HttpRequest, + { mode: 'cors', cache: 'no-cache', credentials: 'same-origin', redirect: 'follow', referrerPolicy: 'no-referrer', - }); - } catch (e) { - throw new NetworkError(`unable to fetch kas list from [${req.url}]`, e); - } + }, + 'unable to fetch kas list from' + ); // if we get an error from the kas registry, throw an error if (!response.ok) { throw new ServiceError( - `unable to fetch kas list from [${req.url}], status: ${response.status}` + `unable to fetch kas list from [${requestUrl}], status: ${response.status}` ); } const { keyAccessServers = [], pagination = {} } = await response.json(); diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index fb3f9df73..e88410e25 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -400,12 +400,17 @@ export class AccessToken { } const accessToken = (this.currentAccessToken ??= await this.get()); if (this.config.dpopEnabled && this.signingKey) { - const origin = new URL(httpReq.url).origin; + const url = new URL(httpReq.url); + const origin = url.origin; + // RFC 9449 §4.2: the `htu` claim is the request URI without query and + // fragment. Resource servers (and the mock) recompute and compare it, so + // a proof carrying the query string is rejected. + const htu = `${origin}${url.pathname}`; const cachedNonce = globalNonceCache.get(origin); const dpopToken = await dpopFn( this.signingKey, this.cryptoService, - httpReq.url, + htu, httpReq.method, cachedNonce, accessToken diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index 279e75370..4f3feb276 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -16,6 +16,7 @@ import { UnauthenticatedError, } from '../../../src/errors.js'; import { OriginAllowList } from '../../../src/access.js'; +import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; import type { AuthProvider } from '../../../src/index.js'; // ------------------------------------------------------------- @@ -230,6 +231,91 @@ describe('access-fetch.js', () => { }); }); + describe('DPoP-Nonce challenge retry (RFC 9449 §9)', () => { + const platformUrl = 'https://platform.example.com'; + const origin = 'https://platform.example.com'; + const challengeNonce = 'server-issued-nonce-123'; + + // A response carrying real Headers so DPoPNonceCache.extractNonce works. + // @ts-expect-error test helper, loose body typing + const responseWithNonce = (body, ok, status, nonce?: string) => + Promise.resolve({ + ok, + status, + statusText: ok ? 'OK' : 'Unauthorized', + headers: new Headers(nonce ? { 'DPoP-Nonce': nonce } : {}), + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + } as Response); + + // withCreds that signs each request with whatever nonce is currently cached + // for the origin, recording it so the test can confirm the retry saw the + // server challenge. + const noncesSeen: (string | undefined)[] = []; + const dpopAuthProvider: AuthProvider = { + withCreds: sinon.stub().callsFake(async (req) => { + noncesSeen.push(globalNonceCache.get(origin)); + return { ...req, headers: { ...req.headers, Authorization: 'DPoP test-token' } }; + }), + } as unknown as AuthProvider; + + beforeEach(() => { + noncesSeen.length = 0; + globalNonceCache.clear(origin); + // @ts-expect-error stub + dpopAuthProvider.withCreds.resetHistory(); + }); + + afterEach(() => { + globalNonceCache.clear(origin); + }); + + it('retries once with the server nonce and succeeds', async () => { + fetchStub + .onCall(0) + .returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); + fetchStub.onCall(1).returns( + responseWithNonce( + { keyAccessServers: [{ uri: 'https://kas1.example.com' }], pagination: {} }, + true, + 200 + ) + ); + + const result = await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + + expect(fetchStub.calledTwice).to.be.true; + // First proof had no nonce; the retry proof was minted after caching it. + expect(noncesSeen).to.deep.equal([undefined, challengeNonce]); + expect(result.origins).to.include('https://kas1.example.com'); + }); + + it('does not retry when the 401 carries no DPoP-Nonce', async () => { + fetchStub.returns(responseWithNonce('nope', false, 401)); + + try { + await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceError); + } + expect(fetchStub.calledOnce).to.be.true; + }); + + it('does not retry again when the same nonce is returned twice', async () => { + // Server keeps rejecting with the same nonce: retry once, then give up. + fetchStub.returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); + + try { + await fetchKeyAccessServers(platformUrl, dpopAuthProvider); + expect.fail('Should have thrown'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceError); + } + expect(fetchStub.calledTwice).to.be.true; + }); + }); + describe('fetchKasPubKey', () => { const kasEndpoint = 'https://kas.example.com'; // FIX: Provide a real, valid base64-encoded key. The `...` is not valid. diff --git a/lib/tests/web/auth/auth.test.ts b/lib/tests/web/auth/auth.test.ts index bd6971853..d821fae7b 100644 --- a/lib/tests/web/auth/auth.test.ts +++ b/lib/tests/web/auth/auth.test.ts @@ -386,5 +386,38 @@ describe('AccessToken', () => { expect(e.message).to.match(/required when DPoP is enabled/); } }); + + it('strips query and fragment from the DPoP proof htu (RFC 9449 §4.2)', async () => { + const signingKey = await generateTestSigningKey(); + const mf = mockFetch({ access_token: 'test_token' }); + const accessToken = new AccessToken( + { + exchange: 'refresh', + oidcOrigin: 'https://auth.invalid/auth/realms/test/', + clientId: 'myid', + refreshToken: 'refresh', + signingKey, + dpopEnabled: true, + }, + DefaultCryptoService, + mf + ); + const result = await accessToken.withCreds({ + url: 'https://platform.invalid/key-access-servers?pagination.offset=0', + method: 'GET', + headers: {}, + }); + + const decodeJwtPayload = (jwt: string): Record => { + let b64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4 !== 0) { + b64 += '='; + } + return JSON.parse(atob(b64)); + }; + const payload = decodeJwtPayload(result.headers.DPoP); + expect(payload.htu).to.equal('https://platform.invalid/key-access-servers'); + expect(payload.htm).to.equal('GET'); + }); }); }); From 8f43bb7d4fcbdebef52c7072baecad15614a50d5 Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:25:07 +0000 Subject: [PATCH 33/45] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dave Mihalcik --- lib/tests/mocha/dpop-proof.spec.ts | 19 +++++++------------ lib/tests/server.ts | 15 +++------------ lib/tests/web/access/access-fetch.test.ts | 16 +++++++++------- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/lib/tests/mocha/dpop-proof.spec.ts b/lib/tests/mocha/dpop-proof.spec.ts index 58a04121f..53dc7d3b5 100644 --- a/lib/tests/mocha/dpop-proof.spec.ts +++ b/lib/tests/mocha/dpop-proof.spec.ts @@ -50,7 +50,10 @@ async function ecdsaKeyPair(namedCurve: 'P-256' | 'P-384' | 'P-521'): Promise).d; delete (fakeJwk as Record).key_ops; @@ -147,10 +145,7 @@ describe('DPoP proof — JWS conformance vs jose.jwtVerify (RFC 9449 + RFC 7518 * Round-tripping through `dpopFn` ensures the JWK shape matches what the * SDK emits in real proofs. */ -async function proofHeaderJwkFor( - kp: KeyPair, - alg: 'ES256' | 'ES384' | 'ES512' -): Promise { +async function proofHeaderJwkFor(kp: KeyPair, alg: 'ES256' | 'ES384' | 'ES512'): Promise { const proof = await dpopFn(kp, DefaultCryptoService, HTU, HTM); const header = jose.decodeProtectedHeader(proof); void alg; // alg unused; kept in signature for caller clarity diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 5a0f38e32..66476201f 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -97,12 +97,7 @@ async function verifyDpopProof( }; } const alg = protectedHeader.alg; - if ( - !alg || - alg === 'none' || - alg.startsWith('HS') || - !/^(ES|RS|PS|EdDSA)/.test(alg) - ) { + if (!alg || alg === 'none' || alg.startsWith('HS') || !/^(ES|RS|PS|EdDSA)/.test(alg)) { return { ok: false, status: 400, @@ -757,9 +752,7 @@ const kas: RequestListener = async (req, res) => { const headers: Record = { 'Content-Type': 'application/json' }; if (check.challengeNonce) headers['DPoP-Nonce'] = check.challengeNonce; res.writeHead(status, headers); - res.end( - JSON.stringify({ error: check.error, error_description: check.error_description }) - ); + res.end(JSON.stringify({ error: check.error, error_description: check.error_description })); return; } @@ -770,9 +763,7 @@ const kas: RequestListener = async (req, res) => { res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 }) - ); + res.end(JSON.stringify({ access_token: accessToken, token_type: 'DPoP', expires_in: 3600 })); return; } else { console.log(`[DEBUG] invalid path [${url.pathname}]`); diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index 4f3feb276..a87c379ea 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -274,13 +274,15 @@ describe('access-fetch.js', () => { fetchStub .onCall(0) .returns(responseWithNonce({ error: 'use_dpop_nonce' }, false, 401, challengeNonce)); - fetchStub.onCall(1).returns( - responseWithNonce( - { keyAccessServers: [{ uri: 'https://kas1.example.com' }], pagination: {} }, - true, - 200 - ) - ); + fetchStub + .onCall(1) + .returns( + responseWithNonce( + { keyAccessServers: [{ uri: 'https://kas1.example.com' }], pagination: {} }, + true, + 200 + ) + ); const result = await fetchKeyAccessServers(platformUrl, dpopAuthProvider); From dccd23a7933dfa414329d1492e321eb0836cdea8 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 24 Jun 2026 14:44:33 -0400 Subject: [PATCH 34/45] test(dpop): enforce RFC 9449 on RPC endpoints + retry on AuthProvider path (DSPX-3397) The authProviderInterceptor (used by the CLI's --auth opentdf-dpop) lacked DPoP-Nonce challenge retry on the Connect-RPC path, so a server nonce challenge on ListKeyAccessServers failed instead of retrying. This reached xtest because the mock server never challenged that endpoint. - interceptors.ts: add nonce-challenge retry to authProviderInterceptor, mirroring authTokenDPoPInterceptor and the legacy fetch path. - tests/server.ts: add shared enforceRsDpop() gate (gated on Authorization: DPoP) and wire it into ListKeyAccessServers, GetAttributeValuesByFqns, ListAttributes; refactor the rewrap DPoP block to use it and return Connect-correct {code,message} 401s. - add node + browser regression tests driving PlatformClient through a DPoP provider so the RPC nonce-retry path is exercised end to end. --- lib/src/auth/interceptors.ts | 93 ++++++++++++++++------ lib/tests/mocha/dpop-rpc-nonce.spec.ts | 61 +++++++++++++++ lib/tests/server.ts | 94 ++++++++++++++--------- lib/tests/web/auth/dpop-rpc-nonce.test.ts | 48 ++++++++++++ lib/tests/web/interceptors.test.ts | 40 +++++++++- 5 files changed, 275 insertions(+), 61 deletions(-) create mode 100644 lib/tests/mocha/dpop-rpc-nonce.spec.ts create mode 100644 lib/tests/web/auth/dpop-rpc-nonce.test.ts diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 7b634d361..1d76affc9 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -177,35 +177,80 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // nonce cache; `new URL()` on a bare path throws "Invalid URL". Non-DPoP // providers ignore the URL (they only add a Bearer header), so this stays // backwards-compatible with legacy AuthProviders. - let token; - try { - token = await authProvider.withCreds({ - url: req.url, - method: 'POST', - // Start with any headers Connect already has - headers: { - ...Object.fromEntries(req.header.entries()), - 'Content-Type': 'application/json', - }, + + // Re-sign the request via withCreds and apply the resulting headers. Called + // once normally, and again on a DPoP-Nonce challenge so the provider mints a + // fresh proof carrying the server-issued nonce (read from globalNonceCache). + const sign = async (): Promise => { + let token; + try { + token = await authProvider.withCreds({ + url: req.url, + method: 'POST', + // Start with any headers Connect already has + headers: { + ...Object.fromEntries(req.header.entries()), + 'Content-Type': 'application/json', + }, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('public key') || msg.includes('updateClientPublicKey')) { + throw new Error( + 'PlatformClient: DPoP key binding is not complete. ' + + 'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' + + '`await client.ready` before constructing PlatformClient. ' + + `Original error: ${msg}` + ); + } + throw err; + } + + Object.entries(token.headers).forEach(([key, value]) => { + req.header.set(key, value); }); + }; + + let origin: string | undefined; + try { + origin = new URL(req.url).origin; + } catch { + // Non-absolute URL: nonce caching is keyed by origin, so just pass through. + } + + await sign(); + + try { + const response = await next(req); + // Keep the nonce cache warm from successful responses (RFC 9449 §8). + if (origin) { + const responseNonce = response.header.get('dpop-nonce'); + if (responseNonce) { + globalNonceCache.set(origin, responseNonce); + } + } + return response; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes('public key') || msg.includes('updateClientPublicKey')) { - throw new Error( - 'PlatformClient: DPoP key binding is not complete. ' + - 'If you are using OpenTDF with PlatformClient, create OpenTDF first and ' + - '`await client.ready` before constructing PlatformClient. ' + - `Original error: ${msg}` - ); + // A DPoP resource server rejects a proof minted without (or with a stale) + // nonce by returning Unauthenticated with a fresh `DPoP-Nonce`. Cache the + // nonce, re-sign so withCreds embeds it, and retry once (RFC 9449 §9). + // Non-DPoP providers/servers never emit a DPoP-Nonce, so this is a no-op + // for them. + if (origin && err instanceof ConnectError && err.code === Code.Unauthenticated) { + const serverNonce = err.metadata.get('dpop-nonce'); + if (serverNonce && serverNonce !== globalNonceCache.get(origin)) { + globalNonceCache.set(origin, serverNonce); + await sign(); + const retryResponse = await next(req); + const retryNonce = retryResponse.header.get('dpop-nonce'); + if (retryNonce) { + globalNonceCache.set(origin, retryNonce); + } + return retryResponse; + } } throw err; } - - Object.entries(token.headers).forEach(([key, value]) => { - req.header.set(key, value); - }); - - return await next(req); }; } diff --git a/lib/tests/mocha/dpop-rpc-nonce.spec.ts b/lib/tests/mocha/dpop-rpc-nonce.spec.ts new file mode 100644 index 000000000..6a2a588b0 --- /dev/null +++ b/lib/tests/mocha/dpop-rpc-nonce.spec.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; +import { PlatformClient } from '../../src/platform.js'; +import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce issued by the mock server's resource-server (RPC) endpoints. +const RS_NONCE = 'dpop-test-rs-nonce-xyz'; + +/** + * End-to-end regression for the Connect-RPC DPoP-Nonce challenge retry. + * + * Drives a real PlatformClient (Connect transport) through a DPoP auth provider + * against the mock server so `ListKeyAccessServers` issues an RS nonce challenge + * and the `authProviderInterceptor` must catch the ConnectError, cache the nonce, + * re-sign, and retry once. Before that interceptor fix the first call rejected + * with a Code.Unauthenticated ConnectError — exactly the bug that reached xtest + * (`test_dpop_server_issued_nonce_retry`). + */ +describe('DPoP RS nonce retry over Connect-RPC — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const platform = new PlatformClient({ authProvider, platformUrl: SERVER_ORIGIN }); + + // No RS nonce is cached for this origin yet: the first proof carries the + // wrong (or no) nonce, the server challenges with the RS nonce, and the + // interceptor must retry once for this call to resolve. + const response = await platform.v1.keyAccessServerRegistry.listKeyAccessServers({}); + + expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); + expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); + + // The consumed challenge leaves the RS nonce cached for the origin, proving + // a challenge happened and the retry adopted it. + expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 66476201f..e47f0dd3f 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -1,5 +1,5 @@ import * as jose from 'jose'; -import { createServer, IncomingMessage, RequestListener } from 'node:http'; +import { createServer, IncomingMessage, RequestListener, ServerResponse } from 'node:http'; import { base64 } from '../src/encodings/index.js'; import { decryptWithPrivateKey, encryptWithPublicKey } from '../tdf3/src/crypto/index.js'; @@ -247,6 +247,53 @@ function requestHtu(req: IncomingMessage): string { return `${url.origin}${url.pathname}`; } +/** + * RFC 9449 resource-server DPoP gate for Connect-RPC endpoints. A no-op + * (returns true) unless the request carries `Authorization: DPoP `, so + * Bearer / unauthenticated callers pass through unchanged and the many non-DPoP + * tests keep working. + * + * On a proof failure it writes a Connect-correct response and returns false; the + * caller MUST `return` immediately. The status is always 401 so connect-web maps + * it to Code.Unauthenticated (HTTP 400 would map to Code.Internal, which the + * SDK's nonce-retry interceptor does not act on). The nonce travels in the + * `DPoP-Nonce` response header (surfaced to the client via ConnectError.metadata), + * and the JSON body uses the Connect `{code, message}` envelope. + * + * The proof's `htm` is always 'POST': both SDK interceptors hard-code POST when + * minting the proof regardless of the verb the Connect transport uses, so we must + * NOT derive htm from req.method here. + */ +async function enforceRsDpop(req: IncomingMessage, res: ServerResponse): Promise { + const authHeader = (req.headers['authorization'] as string | undefined) ?? ''; + const dpopMatch = /^DPoP\s+(.+)$/.exec(authHeader); + if (!dpopMatch) return true; // non-DPoP request → unchanged behavior + + const accessToken = dpopMatch[1]; + const proofCheck = await verifyDpopProof(req.headers['dpop'] as string | undefined, { + htm: 'POST', + htu: requestHtu(req), + requireAth: { accessToken }, + requireBoundJkt: dpopBoundJkts.get(accessToken), + requireNonce: DPOP_RS_NONCE, + }); + if (proofCheck.ok) return true; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (proofCheck.challengeNonce) { + headers['DPoP-Nonce'] = proofCheck.challengeNonce; + headers['WWW-Authenticate'] = `DPoP error="${proofCheck.error}"`; + } + res.writeHead(401, headers); + res.end( + JSON.stringify({ + code: 'unauthenticated', + message: proofCheck.error_description || proofCheck.error, + }) + ); + return false; +} + function range(start: number, end: number): Uint8Array { const result = []; for (let i = start; i <= end; i++) { @@ -424,39 +471,7 @@ const kas: RequestListener = async (req, res) => { // request actually carries `Authorization: DPoP `; non-DPoP // (Bearer or unauthenticated) callers pass through unchanged so the // many non-DPoP rewrap tests keep working. - const authHeader = (req.headers['authorization'] as string | undefined) ?? ''; - const dpopMatch = /^DPoP\s+(.+)$/.exec(authHeader); - if (dpopMatch) { - const accessToken = dpopMatch[1]; - const boundJkt = dpopBoundJkts.get(accessToken); - const proofCheck = await verifyDpopProof(req.headers['dpop'] as string | undefined, { - htm: 'POST', - htu: requestHtu(req), - requireAth: { accessToken }, - requireBoundJkt: boundJkt, - requireNonce: DPOP_RS_NONCE, - }); - if (!proofCheck.ok) { - // RFC 9449 §8: RS uses 401 + WWW-Authenticate: DPoP error="..." for - // nonce challenges (vs AS which uses 400). Other proof failures get - // 401 invalid_token (downstream platforms vary; tests can match on the - // error code rather than status). - const status = proofCheck.error === 'use_dpop_nonce' ? 401 : proofCheck.status || 401; - const headers: Record = { 'Content-Type': 'application/json' }; - if (proofCheck.challengeNonce) { - headers['DPoP-Nonce'] = proofCheck.challengeNonce; - headers['WWW-Authenticate'] = `DPoP error="${proofCheck.error}"`; - } - res.writeHead(status, headers); - res.end( - JSON.stringify({ - error: proofCheck.error, - error_description: proofCheck.error_description, - }) - ); - return; - } - } + if (!(await enforceRsDpop(req, res))) return; const body = await getBody(req); const bodyText = new TextDecoder().decode(body); @@ -645,9 +660,12 @@ const kas: RequestListener = async (req, res) => { res.end(fullRange); } } else if (url.pathname === '/policy.attributes.AttributesService/GetAttributeValuesByFqns') { + // DPoP callers are authenticated by the RS gate; Bearer callers fall + // through to the legacy `Bearer dummy-auth-token` check below. + if (!(await enforceRsDpop(req, res))) return; res.setHeader('Content-Type', 'application/json'); const token = req.headers['authorization'] as string; - if (!token || !token.startsWith('Bearer dummy-auth-token')) { + if (!token || !(token.startsWith('Bearer dummy-auth-token') || token.startsWith('DPoP '))) { res.statusCode = 401; res.end(JSON.stringify({ code: 'unauthenticated', message: 'unauthenticated' })); return; @@ -695,6 +713,7 @@ const kas: RequestListener = async (req, res) => { } else if ( url.pathname === '/policy.kasregistry.KeyAccessServerRegistryService/ListKeyAccessServers' ) { + if (!(await enforceRsDpop(req, res))) return; res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); res.end( @@ -723,8 +742,11 @@ const kas: RequestListener = async (req, res) => { ); return; } else if (url.pathname === '/policy.attributes.AttributesService/ListAttributes') { + // DPoP callers are authenticated by the RS gate; Bearer callers fall + // through to the legacy `Bearer dummy-auth-token` check below. + if (!(await enforceRsDpop(req, res))) return; const token = req.headers['authorization'] as string; - if (!token || !token.startsWith('Bearer dummy-auth-token')) { + if (!token || !(token.startsWith('Bearer dummy-auth-token') || token.startsWith('DPoP '))) { res.statusCode = 401; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ status: 'error' })); diff --git a/lib/tests/web/auth/dpop-rpc-nonce.test.ts b/lib/tests/web/auth/dpop-rpc-nonce.test.ts new file mode 100644 index 000000000..03c6cd04f --- /dev/null +++ b/lib/tests/web/auth/dpop-rpc-nonce.test.ts @@ -0,0 +1,48 @@ +import { expect } from '@esm-bundle/chai'; +import { clientSecretAuthProvider } from '../../../src/auth/providers.js'; +import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; +import { PlatformClient } from '../../../src/platform.js'; +import { generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce issued by the mock server's resource-server (RPC) endpoints. +const RS_NONCE = 'dpop-test-rs-nonce-xyz'; + +/** + * Browser-side counterpart to tests/mocha/dpop-rpc-nonce.spec.ts: exercises the + * Connect-RPC DPoP-Nonce challenge retry through the connect-web transport that + * the browser SDK actually uses. See that file for the full rationale. + */ +describe('DPoP RS nonce retry over Connect-RPC (browser)', () => { + let keyPair: KeyPair; + + before(async () => { + keyPair = await generateSigningKeyPair(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: keyPair, + }); + + const platform = new PlatformClient({ authProvider, platformUrl: SERVER_ORIGIN }); + + const response = await platform.v1.keyAccessServerRegistry.listKeyAccessServers({}); + + expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); + expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); + expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index 932fb1633..c72af2691 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -1,5 +1,5 @@ import { expect } from '@esm-bundle/chai'; -import { type Interceptor } from '@connectrpc/connect'; +import { Code, ConnectError, type Interceptor } from '@connectrpc/connect'; import type { AuthProvider } from '../../src/auth/auth.js'; import { HttpRequest, withHeaders } from '../../src/auth/auth.js'; import { @@ -10,6 +10,7 @@ import { resolveAuthConfig, isInterceptorConfig, } from '../../src/auth/interceptors.js'; +import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; // --- helpers --- @@ -163,6 +164,43 @@ describe('authProviderInterceptor', () => { expect(seenUrl).to.equal('https://platform.example.com/policy.attributes/Get'); }); + it('retries once with the server-issued DPoP-Nonce on an Unauthenticated challenge', async () => { + const origin = 'https://platform.example.com'; + const url = `${origin}/policy.kasregistry/ListKeyAccessServers`; + globalNonceCache.clear(origin); + + // Provider records the nonce it sees so we can assert the retry carried it. + const seenNonces: (string | undefined)[] = []; + const mockAuthProvider: AuthProvider = { + updateClientPublicKey: async () => {}, + withCreds: async (req: HttpRequest) => { + seenNonces.push(globalNonceCache.get(new URL(req.url).origin)); + return withHeaders(req, { Authorization: 'DPoP token' }); + }, + }; + + let attempts = 0; + const mockNext = async () => { + attempts++; + if (attempts === 1) { + // First attempt: server issues a nonce challenge. + throw new ConnectError('unauthenticated', Code.Unauthenticated, { + 'dpop-nonce': 'server-nonce-xyz', + }); + } + return { header: new Headers(), message: {} } as Awaited>>; + }; + + const interceptor = authProviderInterceptor(mockAuthProvider); + const mockReq = { header: new Headers(), url } as Parameters>[0]; + await interceptor(mockNext)(mockReq); + + expect(attempts).to.equal(2); + expect(seenNonces).to.deep.equal([undefined, 'server-nonce-xyz']); + expect(globalNonceCache.get(origin)).to.equal('server-nonce-xyz'); + globalNonceCache.clear(origin); + }); + it('wraps updateClientPublicKey errors with helpful message', async () => { const failingProvider: AuthProvider = { updateClientPublicKey: async () => {}, From bee361835aea167215d644fc2b22d13071cf0d28 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 06:14:38 -0400 Subject: [PATCH 35/45] fix(dpop): sign rewrap request token with the dpop key's algorithm (DSPX-3397) The KAS rewrap request token was always signed with RS256, so an EC dpop key (e.g. --dpop ES256) made WebCrypto throw 'Unable to use this key to sign', surfacing as 'unable to unwrap key from kas'. Derive the JWS alg from the dpop private key's algorithm instead. Adds a regression test decrypting with EC dpop keys. --- lib/tdf3/src/tdf.ts | 30 +++++++++++++++- lib/tests/mocha/encrypt-decrypt.spec.ts | 47 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 4decf7a9b..c2652aa76 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -38,8 +38,10 @@ import { SymmetricCipher } from './ciphers/symmetric-cipher-base.js'; import { DecryptParams } from './client/builders.js'; import { DecoratedReadableStream } from './client/DecoratedReadableStream.js'; import { + type AsymmetricSigningAlgorithm, type CryptoService, type DecryptResult, + type KeyAlgorithm, type KeyPair, type SymmetricKey, } from './crypto/declarations.js'; @@ -743,6 +745,27 @@ type RewrapResponseData = { requiredObligations: string[]; }; +/** + * Map an opaque key's algorithm to the JWS signing algorithm used to sign the + * rewrap request token. RSA keys sign with RS256; EC keys sign with the ECDSA + * algorithm matching their curve. + */ +function signingAlgForKeyAlgorithm(algorithm: KeyAlgorithm): AsymmetricSigningAlgorithm { + switch (algorithm) { + case 'rsa:2048': + case 'rsa:4096': + return 'RS256'; + case 'ec:secp256r1': + return 'ES256'; + case 'ec:secp384r1': + return 'ES384'; + case 'ec:secp521r1': + return 'ES512'; + default: + throw new ConfigurationError(`Unsupported signing key algorithm [${algorithm}]`); + } +} + async function unwrapKey({ manifest, allowedKases, @@ -833,7 +856,12 @@ async function unwrapKey({ const requestBodyStr = toJsonString(UnsignedRewrapRequestSchema, unsignedRequest); const jwtPayload = { requestBody: requestBodyStr }; - const signedRequestToken = await reqSignature(jwtPayload, dpopKeys.privateKey, cryptoService); + // The request token must be signed with the algorithm matching the dpop key + // type. Defaulting to RS256 breaks EC keys (e.g. DPoP ES256), since WebCrypto + // rejects signing an EC key with RSA params ("Unable to use this key to sign"). + const signedRequestToken = await reqSignature(jwtPayload, dpopKeys.privateKey, cryptoService, { + alg: signingAlgForKeyAlgorithm(dpopKeys.privateKey.algorithm), + }); const rewrapResp = await fetchWrappedKey( url, diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index e1ba9fc72..5c9259624 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -363,6 +363,53 @@ describe('encrypt decrypt test', async function () { } } + it('decrypt signs the rewrap request token with EC dpop keys (ES256)', async function () { + // Regression for DSPX-3397: the rewrap request token was always signed with + // RS256, which made WebCrypto reject EC dpop keys ("Unable to use this key to + // sign"). The token alg must follow the dpop key algorithm. + const cipher = new AesGcmCipher(WebCryptoService); + const encryptionInformation = new SplitKey(cipher); + const key1 = await encryptionInformation.generateKey(); + const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); + + const client = new Client.Client({ + kasEndpoint: kasUrl, + platformUrl: kasUrl, + dpopKeys: Mocks.entityECKeyPair(), + clientId: 'id', + authProvider, + }); + + const scope: Scope = { + dissem: ['user@domain.com'], + attributes: [], + }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + wrappingKeyAlgorithm: 'rsa:2048', + offline: true, + scope, + keyMiddleware, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const decryptStream = await client.decrypt({ + source: { + type: 'stream', + location: encryptedStream.stream, + }, + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + }); + it('encrypt-decrypt with system metadata assertion', async function () { const cipher = new AesGcmCipher(WebCryptoService); const encryptionInformation = new SplitKey(cipher); From 1e3ea980bd1ea748a7f4c4db2a6bbbbafc397ec1 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 11:12:46 -0400 Subject: [PATCH 36/45] fix(dpop): surface RPC rewrap error instead of legacy 404 mask (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy REST rewrap fallback 404s on Connect-only KAS and masked the real RPC error (e.g. a post-nonce-challenge 401) in tryPromisesUntilFirstSuccess. Now auth/validation errors (401/403/400) surface immediately without a legacy attempt, and for other errors the legacy fallback is still tried but the original RPC error is surfaced if it also fails. Add an integration test driving a full encrypt->decrypt roundtrip through a DPoP auth provider so the rewrap trips the mock KAS RS nonce gate and the interceptor must retry once (RFC 9449 §9). Update rewrap error-case tests to assert the real RPC error now surfaces instead of the masked 404. --- lib/src/access.ts | 44 +++++++++-- lib/tests/mocha/dpop-rewrap-nonce.spec.ts | 94 +++++++++++++++++++++++ lib/tests/mocha/encrypt-decrypt.spec.ts | 25 ++++-- 3 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 lib/tests/mocha/dpop-rewrap-nonce.spec.ts diff --git a/lib/src/access.ts b/lib/src/access.ts index 5f2b11cd0..11f1e6106 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -2,6 +2,7 @@ import { type AuthConfig, resolveAuthConfig } from './auth/interceptors.js'; import { RewrapResponse } from './platform/kas/kas_pb.js'; import { getPlatformUrlFromKasEndpoint, validateSecureUrl } from './utils.js'; import { base64 } from './encodings/index.js'; +import { InvalidFileError, PermissionDeniedError, UnauthenticatedError } from './errors.js'; import { fetchKasBasePubKey, @@ -52,21 +53,52 @@ export async function fetchWrappedKey( ); // When no AuthProvider is available, skip the legacy fallback so the real - // RPC error propagates instead of being masked by tryPromisesUntilFirstSuccess. + // RPC error propagates instead of being masked. if (!authProvider) { return await rpcCall(); } - return await tryPromisesUntilFirstSuccess( - rpcCall, + // Try the modern Connect-RPC rewrap first. + try { + return await rpcCall(); + } catch (rpcError) { + // A definitive auth/validation answer from KAS (401/403/400 — including a + // post-nonce-challenge 401, RFC 9449 §9) must surface as-is. Falling back to + // the legacy REST endpoint here would mask it with a 404 on Connect-only + // platforms. + if (isRewrapAuthError(rpcError)) { + throw rpcError; + } + // Otherwise (transport/network error, or a platform old enough to be missing + // the Connect rewrap endpoint) fall back to the legacy REST rewrap for + // backwards compatibility. If that also fails, surface the original RPC error + // rather than the legacy 404. // We intentionally do not provide the rewrap additional context to legacy requests destined for older platforms. // Platforms new enough to have knowledge of obligations will be handling RPC requests successfully. - () => - fetchWrappedKeysLegacy( + console.info('v2 rewrap request error', rpcError); + try { + return (await fetchWrappedKeysLegacy( url, { signedRequestToken }, authProvider - ) as unknown as Promise + )) as unknown as RewrapResponse; + } catch { + throw rpcError; + } + } +} + +/** + * An auth/validation error from the RPC rewrap represents a definitive answer + * from KAS and must not be masked by the legacy REST fallback (which 404s on + * Connect-only platforms). Other errors (network failures, or an old platform + * missing the Connect endpoint) remain eligible for the legacy fallback. + */ +function isRewrapAuthError(e: unknown): boolean { + return ( + e instanceof UnauthenticatedError || + e instanceof PermissionDeniedError || + e instanceof InvalidFileError ); } diff --git a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts new file mode 100644 index 000000000..8eb09f14d --- /dev/null +++ b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts @@ -0,0 +1,94 @@ +import { assert, expect } from 'chai'; + +import { getMocks } from '../mocks/index.js'; +import { Client } from '../../tdf3/src/index.js'; +import { clientSecretAuthProvider } from '../../src/auth/providers.js'; +import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; +import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; +import type { Scope } from '../../tdf3/src/client/builders.js'; + +const Mocks = getMocks(); + +const SERVER_ORIGIN = 'http://localhost:3000'; +const TOKEN_URL = `${SERVER_ORIGIN}/protocol/openid-connect/token`; +// Fixed nonce the mock server's resource-server (rewrap) gate demands; see +// DPOP_RS_NONCE in tests/server.ts. +const RS_NONCE = 'dpop-test-rs-nonce-xyz'; + +/** + * End-to-end regression for the Connect-RPC DPoP-Nonce challenge retry on the + * KAS *rewrap* path (RFC 9449 §9) — the exact xtest scenario + * (`test_dpop_server_issued_nonce_retry`) that failed only when js was the + * decrypt SDK against a `require_nonce` KAS. + * + * Drives a full encrypt → decrypt roundtrip through a DPoP auth provider so the + * rewrap carries `Authorization: DPoP ` and trips the mock server's RS + * gate. The first proof lacks the RS nonce, the server challenges with a 401 + + * `DPoP-Nonce`, and `authProviderInterceptor` must cache the nonce, re-sign, and + * retry once for the rewrap (and therefore the decrypt) to succeed. + */ +describe('DPoP RS nonce retry on the KAS rewrap path — integration with mock server', function (this: Mocha.Suite) { + this.timeout(10_000); + + let dpopKeyPair: KeyPair; + + before(async () => { + dpopKeyPair = await generateSigningKeyPair(); + }); + + afterEach(() => { + globalNonceCache.clearAll(); + }); + + it('decrypt survives the rewrap nonce challenge and returns the plaintext', async () => { + const expectedVal = 'rewrap nonce roundtrip'; + + // A DPoP-enabled provider makes every authenticated request (token + rewrap) + // present `Authorization: DPoP` and a proof, which is what activates the RS + // gate on the mock KAS rewrap endpoint. + const authProvider = await clientSecretAuthProvider({ + clientId: 'test-client', + clientSecret: 'test-secret', + oidcOrigin: SERVER_ORIGIN, + oidcTokenEndpoint: TOKEN_URL, + exchange: 'client', + dpopEnabled: true, + signingKey: dpopKeyPair, + }); + + const client = new Client.Client({ + kasEndpoint: SERVER_ORIGIN, + platformUrl: SERVER_ORIGIN, + allowedKases: [SERVER_ORIGIN], + dpopKeys: Mocks.entityKeyPair(), + clientId: 'test-client', + authProvider, + }); + + const scope: Scope = { dissem: ['user@domain.com'], attributes: [] }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + offline: true, + scope, + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const decryptStream = await client.decrypt({ + source: { type: 'stream', location: encryptedStream.stream }, + }); + + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + + // A successful decrypt proves the rewrap survived the challenge; the cached + // RS nonce proves a challenge actually happened and the retry adopted it. + expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + }); +}); diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 5c9259624..659a6e294 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -13,7 +13,12 @@ import { Assertion, } from '../../tdf3/src/assertions.js'; import { Scope } from '../../tdf3/src/client/builders.js'; -import { NetworkError } from '../../src/errors.js'; +import { + NetworkError, + PermissionDeniedError, + ServiceError, + UnauthenticatedError, +} from '../../src/errors.js'; const Mocks = getMocks(); @@ -101,7 +106,9 @@ describe('rewrap error cases', function () { }); assert.fail('Expected Error'); } catch (error) { - assert.instanceOf(error, NetworkError); + // The real RPC auth error must surface; the legacy REST fallback no longer + // masks it with a 404/NetworkError (RFC 9449 §9 / DSPX-3397). + assert.instanceOf(error, UnauthenticatedError); } }); @@ -125,7 +132,7 @@ describe('rewrap error cases', function () { }); assert.fail('Expected Error'); } catch (error) { - assert.instanceOf(error, NetworkError); + assert.instanceOf(error, PermissionDeniedError); } }); @@ -154,7 +161,7 @@ describe('rewrap error cases', function () { }); assert.fail('Expected Error'); } catch (error) { - assert.instanceOf(error, NetworkError); + assert.instanceOf(error, ServiceError); } }); @@ -178,7 +185,7 @@ describe('rewrap error cases', function () { }); assert.fail('Expected ServiceError'); } catch (error) { - assert.instanceOf(error, NetworkError); + assert.instanceOf(error, ServiceError); } }); @@ -233,10 +240,12 @@ describe('rewrap error cases', function () { location: encryptedStream.stream, }, }); - assert.fail('Expected InvalidFileError'); + assert.fail('Expected ServiceError'); } catch (error) { - assert.instanceOf(error, NetworkError); - assert.include(error.message, '404 Not Found'); + // Previously the legacy REST fallback masked the real RPC error with a + // "404 Not Found" NetworkError; now the RPC error surfaces directly. + assert.instanceOf(error, ServiceError); + assert.notInclude((error as Error).message, '404 Not Found'); } }); }); From 95b3e078b2fe8a904cd4d69b8fd7e564c8674d24 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 12:46:39 -0400 Subject: [PATCH 37/45] fix(dpop): capture DPoP-Nonce at the Connect transport for rewrap retry (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Against a real require_nonce KAS, the rewrap 401 challenge carries DPoP-Nonce as a raw HTTP response header that connect-web does not surface on ConnectError. metadata, so the interceptor never saw the nonce and could not retry — the 401 propagated (xtest test_dpop_* failing with js as decrypt SDK). Wrap the Connect transport's fetch (platform.ts) to record DPoP-Nonce from the raw Response into the per-origin globalNonceCache via a new captureNonce helper (dpop-nonce.ts). Both DPoP interceptors now source the challenge nonce from the cache (populated by that wrapper) and fall back to error metadata, then re-mint a nonce-bearing proof and retry once (RFC 9449 §9). --- lib/src/auth/dpop-nonce.ts | 23 +++++++++++++++++++++++ lib/src/auth/interceptors.ts | 26 ++++++++++++++++++-------- lib/src/platform.ts | 17 +++++++++++++++++ 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 16c7b3c61..288f58fcc 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -48,3 +48,26 @@ export class DPoPNonceCache { * Shared across all instances to maintain nonce state per-origin. */ export const globalNonceCache = new DPoPNonceCache(); + +/** + * Record a `DPoP-Nonce` response header into {@link globalNonceCache}, keyed by + * the request's origin. + * + * This works directly off the raw `Response`, so it captures the nonce even when + * a transport (e.g. Connect-RPC) does not surface response headers on its error + * type. Some resource servers (KAS) reject a proof minted without a nonce with a + * raw HTTP 401 carrying `DPoP-Nonce` + `WWW-Authenticate: DPoP error="use_dpop_nonce"` + * (RFC 9449 §9); capturing here lets the auth layer mint a nonce-bearing proof on + * retry. + */ +export function captureNonce(requestUrl: string, headers?: Headers): void { + const nonce = DPoPNonceCache.extractNonce(headers); + if (!nonce) { + return; + } + try { + globalNonceCache.set(new URL(requestUrl).origin, nonce); + } catch { + // Non-absolute URL: the nonce cache is origin-keyed, so nothing to store. + } +} diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 1d76affc9..97e849b08 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -115,9 +115,13 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI return response; } catch (err) { - // Check for a Connect Unauthenticated error carrying a DPoP-Nonce challenge + // Check for a Connect Unauthenticated error carrying a DPoP-Nonce challenge. + // The transport's fetch wrapper captures the nonce from the raw 401 response + // into the cache (Connect errors don't reliably surface response headers); + // error metadata is a fallback for transports that do expose it. if (err instanceof ConnectError && err.code === Code.Unauthenticated) { - const serverNonce = err.metadata.get('dpop-nonce'); + const serverNonce = + globalNonceCache.get(origin) ?? err.metadata.get('dpop-nonce') ?? undefined; if (serverNonce && serverNonce !== cachedNonce) { // Server sent a new nonce (or we didn't have one cached) @@ -219,6 +223,9 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor } await sign(); + // Snapshot the nonce we just signed with (withCreds reads it from the cache) + // so a 401 can tell us whether the server handed back a *new* one to retry. + const sentNonce = origin ? globalNonceCache.get(origin) : undefined; try { const response = await next(req); @@ -232,13 +239,16 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor return response; } catch (err) { // A DPoP resource server rejects a proof minted without (or with a stale) - // nonce by returning Unauthenticated with a fresh `DPoP-Nonce`. Cache the - // nonce, re-sign so withCreds embeds it, and retry once (RFC 9449 §9). - // Non-DPoP providers/servers never emit a DPoP-Nonce, so this is a no-op - // for them. + // nonce by returning Unauthenticated with a fresh `DPoP-Nonce`. The + // transport's fetch wrapper captures that header from the raw response into + // the cache (Connect errors don't reliably surface response headers); we + // also fall back to error metadata. Re-sign so withCreds embeds the nonce + // and retry once (RFC 9449 §9). Non-DPoP providers/servers never emit a + // DPoP-Nonce, so this is a no-op for them. if (origin && err instanceof ConnectError && err.code === Code.Unauthenticated) { - const serverNonce = err.metadata.get('dpop-nonce'); - if (serverNonce && serverNonce !== globalNonceCache.get(origin)) { + const serverNonce = + globalNonceCache.get(origin) ?? err.metadata.get('dpop-nonce') ?? undefined; + if (serverNonce && serverNonce !== sentNonce) { globalNonceCache.set(origin, serverNonce); await sign(); const retryResponse = await next(req); diff --git a/lib/src/platform.ts b/lib/src/platform.ts index fdff544eb..dc09fc42a 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -5,6 +5,22 @@ export * as platformConnect from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-web'; import type { AuthProvider } from '../tdf3/index.js'; import { authProviderInterceptor } from './auth/interceptors.js'; +import { captureNonce } from './auth/dpop-nonce.js'; + +/** + * A `fetch` wrapper that records any `DPoP-Nonce` response header into the global + * nonce cache before handing the response back to the Connect transport. The + * Connect error type does not reliably surface response headers, so capturing at + * the transport layer is what lets the DPoP auth interceptors mint a + * nonce-bearing proof and retry a rewrap challenged per RFC 9449 §9. + */ +const nonceCapturingFetch: typeof globalThis.fetch = async (input, init) => { + const response = await fetch(input, init); + const requestUrl = + typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + captureNonce(requestUrl, response.headers); + return response; +}; import { Client, createClient, Interceptor } from '@connectrpc/connect'; import { WellKnownService } from './platform/wellknownconfiguration/wellknown_configuration_pb.js'; @@ -99,6 +115,7 @@ export class PlatformClient { const transport = createConnectTransport({ baseUrl: options.platformUrl, interceptors, + fetch: nonceCapturingFetch, }); this.v1 = { From da159f6d64f6db4c1ed223f4dbb38ec61e159b4f Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 13:50:58 -0400 Subject: [PATCH 38/45] debug(dpop): temporary fetch-layer header dump for 401/400 (DSPX-3397) --- lib/src/platform.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/src/platform.ts b/lib/src/platform.ts index dc09fc42a..7f558a6ef 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -18,6 +18,20 @@ const nonceCapturingFetch: typeof globalThis.fetch = async (input, init) => { const response = await fetch(input, init); const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + // TEMP DSPX-3397 DEBUG: dump what the fetch layer sees on auth failures. + if (response.status === 401 || response.status === 400) { + try { + const hdrs: Record = {}; + response.headers.forEach((v, k) => { + hdrs[k] = v; + }); + console.error( + `[DPOP-DEBUG] ${response.status} ${requestUrl} dpop-nonce=[${response.headers.get('dpop-nonce')}] headers=${JSON.stringify(hdrs)}` + ); + } catch (e) { + console.error('[DPOP-DEBUG] header dump failed', e); + } + } captureNonce(requestUrl, response.headers); return response; }; From 4e09e0e9d802b27df6f881dfebe15e05a5f79553 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 14:19:38 -0400 Subject: [PATCH 39/45] debug(dpop): dump rewrap proof claims + 401 body (DSPX-3397) --- lib/src/platform.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/src/platform.ts b/lib/src/platform.ts index 7f558a6ef..ee4bc0583 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -18,18 +18,36 @@ const nonceCapturingFetch: typeof globalThis.fetch = async (input, init) => { const response = await fetch(input, init); const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; - // TEMP DSPX-3397 DEBUG: dump what the fetch layer sees on auth failures. - if (response.status === 401 || response.status === 400) { + // TEMP DSPX-3397 DEBUG: dump request proof + response body for rewrap calls. + if (requestUrl.includes('Rewrap')) { try { - const hdrs: Record = {}; - response.headers.forEach((v, k) => { - hdrs[k] = v; - }); + const h = init?.headers; + let dpopHdr: string | undefined; + let authHdr: string | undefined; + if (h instanceof Headers) { + dpopHdr = h.get('dpop') ?? undefined; + authHdr = h.get('authorization') ?? undefined; + } else if (h && typeof h === 'object') { + const rec = h as Record; + dpopHdr = rec['dpop'] ?? rec['DPoP']; + authHdr = rec['authorization'] ?? rec['Authorization']; + } + let proof = ''; + if (dpopHdr) { + const parts = dpopHdr.split('.'); + if (parts.length === 3) { + proof = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); + } + } + let body = ''; + if (response.status === 401 || response.status === 400) { + body = await response.clone().text(); + } console.error( - `[DPOP-DEBUG] ${response.status} ${requestUrl} dpop-nonce=[${response.headers.get('dpop-nonce')}] headers=${JSON.stringify(hdrs)}` + `[DPOP-DEBUG2] ${response.status} ${requestUrl} authScheme=[${authHdr?.slice(0, 12)}] proof=${proof} respNonce=[${response.headers.get('dpop-nonce')}] body=${body}` ); } catch (e) { - console.error('[DPOP-DEBUG] header dump failed', e); + console.error('[DPOP-DEBUG2] dump failed', e); } } captureNonce(requestUrl, response.headers); From 78ce979fa672aede6c74af93098cca5f4f9cb7cb Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Thu, 25 Jun 2026 15:15:41 -0400 Subject: [PATCH 40/45] fix(dpop): emit raw IEEE P1363 ECDSA sigs in rewrap request token (DSPX-3397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KAS rewrap request token (SRT) is signed via reqSignature -> signJwt, but signJwt used cryptoService.sign's output directly, which is DER-encoded for ECDSA. JWS (RFC 7518 §3.4) requires raw IEEE P1363 (R||S), so a real KAS rejected the ES256-signed SRT with 'unable to verify request token' (a 401 that surfaced only after the DPoP nonce challenge was satisfied). The mock test server only decodeJwt's the SRT, so this was invisible locally. signJwt now converts ECDSA signatures DER->P1363 (mirroring src/auth/dpop.ts), and verifyJwt converts P1363->DER before cryptoService.verify so ES* assertions still round-trip. Export ieeeP1363ToDer for the verify path. Add a spec that verifies reqSignature/signJwt ES256/384/512 output against jose.jwtVerify (RFC-conformant), which the in-SDK round-trip and the mock server cannot catch. Also removes the temporary fetch-layer debug logging. --- lib/src/platform.ts | 32 --------- lib/tdf3/src/crypto/core/signing.ts | 7 +- lib/tdf3/src/crypto/jwt.ts | 28 +++++--- lib/tests/mocha/reqsignature-jws.spec.ts | 92 ++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 45 deletions(-) create mode 100644 lib/tests/mocha/reqsignature-jws.spec.ts diff --git a/lib/src/platform.ts b/lib/src/platform.ts index ee4bc0583..dc09fc42a 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -18,38 +18,6 @@ const nonceCapturingFetch: typeof globalThis.fetch = async (input, init) => { const response = await fetch(input, init); const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; - // TEMP DSPX-3397 DEBUG: dump request proof + response body for rewrap calls. - if (requestUrl.includes('Rewrap')) { - try { - const h = init?.headers; - let dpopHdr: string | undefined; - let authHdr: string | undefined; - if (h instanceof Headers) { - dpopHdr = h.get('dpop') ?? undefined; - authHdr = h.get('authorization') ?? undefined; - } else if (h && typeof h === 'object') { - const rec = h as Record; - dpopHdr = rec['dpop'] ?? rec['DPoP']; - authHdr = rec['authorization'] ?? rec['Authorization']; - } - let proof = ''; - if (dpopHdr) { - const parts = dpopHdr.split('.'); - if (parts.length === 3) { - proof = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')); - } - } - let body = ''; - if (response.status === 401 || response.status === 400) { - body = await response.clone().text(); - } - console.error( - `[DPOP-DEBUG2] ${response.status} ${requestUrl} authScheme=[${authHdr?.slice(0, 12)}] proof=${proof} respNonce=[${response.headers.get('dpop-nonce')}] body=${body}` - ); - } catch (e) { - console.error('[DPOP-DEBUG2] dump failed', e); - } - } captureNonce(requestUrl, response.headers); return response; }; diff --git a/lib/tdf3/src/crypto/core/signing.ts b/lib/tdf3/src/crypto/core/signing.ts index c1dffc604..9516472cd 100644 --- a/lib/tdf3/src/crypto/core/signing.ts +++ b/lib/tdf3/src/crypto/core/signing.ts @@ -40,10 +40,13 @@ function getSigningAlgorithmParams(algorithm: AsymmetricSigningAlgorithm): { } /** - * Convert IEEE P1363 signature format (used by WebCrypto ECDSA) to DER format (used by JWT). + * Convert IEEE P1363 signature format (used by WebCrypto ECDSA) to DER format. * RS256 signatures don't need conversion. */ -function ieeeP1363ToDer(signature: Uint8Array, algorithm: AsymmetricSigningAlgorithm): Uint8Array { +export function ieeeP1363ToDer( + signature: Uint8Array, + algorithm: AsymmetricSigningAlgorithm +): Uint8Array { if (algorithm === 'RS256') { return signature; } diff --git a/lib/tdf3/src/crypto/jwt.ts b/lib/tdf3/src/crypto/jwt.ts index 5ae08fd20..9ea29e37c 100644 --- a/lib/tdf3/src/crypto/jwt.ts +++ b/lib/tdf3/src/crypto/jwt.ts @@ -17,6 +17,7 @@ import { } from 'jose'; import jwtClaimsSet from './jose/jwt-claims-set.js'; import validateCrit from './jose/validate-crit.js'; +import { derToIeeeP1363, ieeeP1363ToDer } from './core/signing.js'; export type JwtHeader = JWTHeaderParameters & { alg: SigningAlgorithm }; export type JwtPayload = JWTPayload; @@ -134,11 +135,16 @@ export async function signJwt( if (key._brand !== 'PrivateKey') { throw new Error(`${header.alg} requires a PrivateKey`); } - signature = await cryptoService.sign( - signingInputBytes, - key, - header.alg as AsymmetricSigningAlgorithm - ); + const alg = header.alg as AsymmetricSigningAlgorithm; + signature = await cryptoService.sign(signingInputBytes, key, alg); + // JWS requires raw IEEE P1363 (R || S) for ECDSA per RFC 7518 §3.4, but + // cryptoService.sign returns DER. Convert here so the JWT (e.g. the KAS + // rewrap request token) is accepted by RFC-conformant verifiers. RSA/EdDSA + // signatures are already raw bytes — no conversion. Mirrors the DPoP proof + // signer in src/auth/dpop.ts. + if (alg.startsWith('ES')) { + signature = derToIeeeP1363(signature, alg); + } } // Return compact JWT @@ -232,12 +238,12 @@ export async function verifyJwt( typeof key === 'string' ? await cryptoService.importPublicKey(key, { usage: 'sign' }) : (key as PublicKey); - valid = await cryptoService.verify( - signingInputBytes, - signature, - publicKey, - header.alg as AsymmetricSigningAlgorithm - ); + const alg = header.alg as AsymmetricSigningAlgorithm; + // JWS carries ECDSA signatures as raw IEEE P1363 (RFC 7518 §3.4), but + // cryptoService.verify expects DER. Convert here so we accept RFC-conformant + // ES* JWTs (matches the signJwt signer above). RSA is unchanged. + const verifySignature = alg.startsWith('ES') ? ieeeP1363ToDer(signature, alg) : signature; + valid = await cryptoService.verify(signingInputBytes, verifySignature, publicKey, alg); } if (!valid) { diff --git a/lib/tests/mocha/reqsignature-jws.spec.ts b/lib/tests/mocha/reqsignature-jws.spec.ts new file mode 100644 index 000000000..099726b6a --- /dev/null +++ b/lib/tests/mocha/reqsignature-jws.spec.ts @@ -0,0 +1,92 @@ +import { expect } from 'chai'; +import * as jose from 'jose'; + +import { reqSignature } from '../../src/auth/auth.js'; +import { signJwt, verifyJwt } from '../../tdf3/src/crypto/jwt.js'; +import { DefaultCryptoService } from '../../tdf3/src/crypto/index.js'; +import { importPrivateKey, importPublicKey } from '../../tdf3/src/crypto/core/key-format.js'; +import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; + +/** + * RFC 7518 §3.4 conformance for `signJwt`/`reqSignature` (the KAS rewrap request + * token signer). + * + * Regression for DSPX-3397: the rewrap request token was signed with ECDSA + * signatures in DER form, which a real (RFC-conformant) KAS rejects with + * "unable to verify request token". The mock test server only `decodeJwt`s the + * token (no signature check), so the in-SDK round-trip and the mock both passed + * while the real platform failed. Verifying against `jose.jwtVerify` — which + * requires raw IEEE P1363 (R||S) signatures — catches the DER-vs-raw bug. + */ + +const CURVES: Array<{ namedCurve: 'P-256' | 'P-384' | 'P-521'; alg: 'ES256' | 'ES384' | 'ES512' }> = + [ + { namedCurve: 'P-256', alg: 'ES256' }, + { namedCurve: 'P-384', alg: 'ES384' }, + { namedCurve: 'P-521', alg: 'ES512' }, + ]; + +function derToPem(der: Uint8Array, label: string): string { + let b = ''; + for (let i = 0; i < der.length; i++) b += String.fromCharCode(der[i]); + const b64 = + btoa(b) + .match(/.{1,64}/g) + ?.join('\n') ?? btoa(b); + return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----`; +} + +async function ecdsaKeyPair( + namedCurve: 'P-256' | 'P-384' | 'P-521' +): Promise<{ sdk: KeyPair; pubPem: string }> { + const raw = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve }, true, [ + 'sign', + 'verify', + ]); + const [privDer, pubDer] = await Promise.all([ + crypto.subtle.exportKey('pkcs8', raw.privateKey), + crypto.subtle.exportKey('spki', raw.publicKey), + ]); + const privPem = derToPem(new Uint8Array(privDer), 'PRIVATE KEY'); + const pubPem = derToPem(new Uint8Array(pubDer), 'PUBLIC KEY'); + const [privateKey, publicKey] = await Promise.all([ + importPrivateKey(privPem, { usage: 'sign', extractable: true }), + importPublicKey(pubPem, { usage: 'sign', extractable: true }), + ]); + return { sdk: { publicKey, privateKey }, pubPem }; +} + +describe('reqSignature / signJwt — JWS conformance vs jose.jwtVerify (RFC 7518 §3.4)', function (this: Mocha.Suite) { + this.timeout(10_000); + + for (const { namedCurve, alg } of CURVES) { + it(`reqSignature ${alg} token verifies against jose.jwtVerify`, async () => { + const { sdk, pubPem } = await ecdsaKeyPair(namedCurve); + + const token = await reqSignature( + { requestBody: 'hello' }, + sdk.privateKey, + DefaultCryptoService, + { + alg, + } + ); + + // jose requires raw IEEE P1363 signatures — this rejects DER. + const key = await jose.importSPKI(pubPem, alg); + const { payload } = await jose.jwtVerify(token, key); + expect(payload.requestBody).to.equal('hello'); + expect(payload.iat).to.be.a('number'); + expect(payload.exp).to.be.a('number'); + }); + + it(`signJwt ${alg} round-trips through verifyJwt`, async () => { + const { sdk } = await ecdsaKeyPair(namedCurve); + const token = await signJwt(DefaultCryptoService, { sub: 'test' }, sdk.privateKey, { alg }); + const { payload } = await verifyJwt(DefaultCryptoService, token, sdk.publicKey, { + algorithms: [alg], + }); + expect(payload.sub).to.equal('test'); + }); + } +}); From dedd3471956a66c5d731d6f197686fda119eb118 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 7 Jul 2026 14:57:49 -0400 Subject: [PATCH 41/45] refactor(dpop): use type alias for DPoPJwtHeaderParameters Prefer a type alias over an interface, matching the prevailing style in lib/src/auth (the only interface in the directory). No behavior change. --- lib/src/auth/dpop.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/auth/dpop.ts b/lib/src/auth/dpop.ts index e8e85c8ea..2430088fe 100644 --- a/lib/src/auth/dpop.ts +++ b/lib/src/auth/dpop.ts @@ -20,11 +20,11 @@ function buf(input: string): Uint8Array { return encoder.encode(input); } -interface DPoPJwtHeaderParameters { +type DPoPJwtHeaderParameters = { alg: JWSAlgorithm; typ: string; jwk: JsonWebKey; -} +}; /** * Minimal JWT sign() implementation using CryptoService. From 07853c31283f06855826e1cbed7d09a3608f2390 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 7 Jul 2026 15:11:31 -0400 Subject: [PATCH 42/45] refactor(dpop): extract shared nonce-challenge helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DPoP-Nonce challenge/retry bookkeeping (RFC 9449 §8/§9) was duplicated across six call sites in two transport families. Extract three helpers in dpop-nonce.ts: - adoptChallengeNonce: fresh challenge nonce from a Response (raw-fetch family) - adoptChallengeNonceFromConnectError: same for a Connect Unauthenticated error - warmNonceFromResponse: keep the cache warm from any response nonce Each call site now delegates the cache bookkeeping and keeps only its transport-specific re-sign/resend inline. Still routed through globalNonceCache; no behavior change (DPoP mocha specs green). --- lib/src/access/access-fetch.ts | 16 ++++----- lib/src/auth/dpop-nonce.ts | 61 ++++++++++++++++++++++++++++++++ lib/src/auth/interceptors.ts | 64 ++++++++++++++-------------------- lib/src/auth/oidc.ts | 45 +++++++++++------------- 4 files changed, 115 insertions(+), 71 deletions(-) diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index fb5e6c4ec..bc9d34db7 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -1,6 +1,10 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../access.js'; import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; -import { DPoPNonceCache, globalNonceCache } from '../auth/dpop-nonce.js'; +import { + adoptChallengeNonce, + globalNonceCache, + warmNonceFromResponse, +} from '../auth/dpop-nonce.js'; import { ConfigurationError, InvalidFileError, @@ -54,19 +58,15 @@ async function fetchWithCredsAndNonceRetry( let response = await send(); if (!response.ok && origin) { - const challengeNonce = DPoPNonceCache.extractNonce(response.headers); - if (challengeNonce && challengeNonce !== globalNonceCache.get(origin)) { - globalNonceCache.set(origin, challengeNonce); + const sentNonce = globalNonceCache.get(origin); + if (adoptChallengeNonce(globalNonceCache, origin, response.headers, sentNonce)) { response = await send(); } } // Keep the cache warm from whichever response we end on. if (origin) { - const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } + warmNonceFromResponse(globalNonceCache, origin, response.headers); } return response; diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 288f58fcc..9d8cbf961 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -43,6 +43,67 @@ export class DPoPNonceCache { } } +/** + * A `DPoP-Nonce` header source: a raw `Response`'s headers or a Connect error's + * metadata (both are `Headers`, whose `get` is case-insensitive). + */ +type NonceHeaders = Headers | undefined; + +/** + * Given a response's headers, return a *fresh* challenge nonce that differs from + * the one we just sent (`sentNonce`), recording it in `cache`. Returns + * `undefined` when there is no nonce or it matches what we already used — i.e. + * when the caller should NOT retry. RFC 9449 §9. + */ +export function adoptChallengeNonce( + cache: DPoPNonceCache, + origin: string, + headers: NonceHeaders, + sentNonce: string | undefined +): string | undefined { + const challenge = DPoPNonceCache.extractNonce(headers); + if (challenge && challenge !== sentNonce) { + cache.set(origin, challenge); + return challenge; + } + return undefined; +} + +/** + * Connect-error variant of {@link adoptChallengeNonce}. The transport `fetch` + * wrapper usually records the nonce off the raw 401, but Connect errors don't + * reliably surface response headers, so we also consult the cache and the error + * metadata. Returns a fresh nonce to retry with, or `undefined`. + */ +export function adoptChallengeNonceFromConnectError( + cache: DPoPNonceCache, + origin: string, + metadata: NonceHeaders, + sentNonce: string | undefined +): string | undefined { + const serverNonce = cache.get(origin) ?? DPoPNonceCache.extractNonce(metadata); + if (serverNonce && serverNonce !== sentNonce) { + cache.set(origin, serverNonce); + return serverNonce; + } + return undefined; +} + +/** + * Warm the cache from a response's `DPoP-Nonce` header (RFC 9449 §8). No-op when + * the response carries no nonce. + */ +export function warmNonceFromResponse( + cache: DPoPNonceCache, + origin: string, + headers: NonceHeaders +): void { + const nonce = DPoPNonceCache.extractNonce(headers); + if (nonce) { + cache.set(origin, nonce); + } +} + /** * Global nonce cache singleton. * Shared across all instances to maintain nonce state per-origin. diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 97e849b08..7c7bddd2a 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -5,7 +5,11 @@ import * as DefaultCryptoService from '../../tdf3/src/crypto/index.js'; import DPoP from './dpop.js'; import { type AuthProvider } from './auth.js'; import { base64 } from '../encodings/index.js'; -import { globalNonceCache } from './dpop-nonce.js'; +import { + adoptChallengeNonceFromConnectError, + globalNonceCache, + warmNonceFromResponse, +} from './dpop-nonce.js'; /** * A function that returns a valid access token string. @@ -106,29 +110,22 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI // Call next and handle DPoP-Nonce retry try { const response = await next(req); - - // Extract and cache nonce from successful responses - const responseNonce = response.header.get('dpop-nonce'); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } - + warmNonceFromResponse(globalNonceCache, origin, response.header); return response; } catch (err) { - // Check for a Connect Unauthenticated error carrying a DPoP-Nonce challenge. - // The transport's fetch wrapper captures the nonce from the raw 401 response + // A Connect Unauthenticated error may carry a DPoP-Nonce challenge. The + // transport's fetch wrapper records the nonce from the raw 401 response // into the cache (Connect errors don't reliably surface response headers); // error metadata is a fallback for transports that do expose it. if (err instanceof ConnectError && err.code === Code.Unauthenticated) { - const serverNonce = - globalNonceCache.get(origin) ?? err.metadata.get('dpop-nonce') ?? undefined; - - if (serverNonce && serverNonce !== cachedNonce) { - // Server sent a new nonce (or we didn't have one cached) - // Cache it and retry once - globalNonceCache.set(origin, serverNonce); - - // Regenerate proof with server nonce + const serverNonce = adoptChallengeNonceFromConnectError( + globalNonceCache, + origin, + err.metadata, + cachedNonce + ); + if (serverNonce) { + // Regenerate proof with the server nonce and retry once. const retryDpopProof = await DPoP( keys, cryptoService, @@ -140,13 +137,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI req.header.set('DPoP', retryDpopProof); const retryResponse = await next(req); - - // Update cache from retry response if present - const retryNonce = retryResponse.header.get('dpop-nonce'); - if (retryNonce) { - globalNonceCache.set(origin, retryNonce); - } - + warmNonceFromResponse(globalNonceCache, origin, retryResponse.header); return retryResponse; } } @@ -231,10 +222,7 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor const response = await next(req); // Keep the nonce cache warm from successful responses (RFC 9449 §8). if (origin) { - const responseNonce = response.header.get('dpop-nonce'); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } + warmNonceFromResponse(globalNonceCache, origin, response.header); } return response; } catch (err) { @@ -246,16 +234,16 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // and retry once (RFC 9449 §9). Non-DPoP providers/servers never emit a // DPoP-Nonce, so this is a no-op for them. if (origin && err instanceof ConnectError && err.code === Code.Unauthenticated) { - const serverNonce = - globalNonceCache.get(origin) ?? err.metadata.get('dpop-nonce') ?? undefined; - if (serverNonce && serverNonce !== sentNonce) { - globalNonceCache.set(origin, serverNonce); + const serverNonce = adoptChallengeNonceFromConnectError( + globalNonceCache, + origin, + err.metadata, + sentNonce + ); + if (serverNonce) { await sign(); const retryResponse = await next(req); - const retryNonce = retryResponse.header.get('dpop-nonce'); - if (retryNonce) { - globalNonceCache.set(origin, retryNonce); - } + warmNonceFromResponse(globalNonceCache, origin, retryResponse.header); return retryResponse; } } diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index bf81856ef..bf521bff5 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -5,7 +5,7 @@ import { base64 } from '../encodings/index.js'; import { ConfigurationError, TdfError } from '../errors.js'; import { rstrip } from '../utils.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; -import { globalNonceCache, DPoPNonceCache } from './dpop-nonce.js'; +import { adoptChallengeNonce, globalNonceCache, warmNonceFromResponse } from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -171,9 +171,13 @@ export class AccessToken { // Handle DPoP-Nonce challenge per RFC 9449 §9: retry once with the server-supplied nonce. if (this.config.dpopEnabled && this.signingKey && !response.ok) { - const challengeNonce = DPoPNonceCache.extractNonce(response.headers); - if (challengeNonce && challengeNonce !== cachedNonce) { - globalNonceCache.set(origin, challengeNonce); + const challengeNonce = adoptChallengeNonce( + globalNonceCache, + origin, + response.headers, + cachedNonce + ); + if (challengeNonce) { headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, @@ -190,10 +194,7 @@ export class AccessToken { // Update nonce cache from final response if (this.config.dpopEnabled) { - const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } + warmNonceFromResponse(globalNonceCache, origin, response.headers); } if (!response.ok) { @@ -238,18 +239,20 @@ export class AccessToken { // HTTP 400 with error=use_dpop_nonce; §9: resource servers return 401. // Trigger on any non-OK response that carries a fresh DPoP-Nonce header. if (this.config.dpopEnabled && !response.ok) { - const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce && responseNonce !== cachedNonce) { - // Cache the server-provided nonce and retry - globalNonceCache.set(origin, responseNonce); - - // Regenerate DPoP proof with nonce + const challengeNonce = adoptChallengeNonce( + globalNonceCache, + origin, + response.headers, + cachedNonce + ); + if (challengeNonce) { + // Regenerate DPoP proof with the server-provided nonce and retry. headers.DPoP = await dpopFn( this.signingKey!, this.cryptoService, url, 'POST', - responseNonce + challengeNonce ); const retryResponse = await (this.request || fetch)(url, { @@ -258,22 +261,14 @@ export class AccessToken { body: qstringify(o), }); - // Update cache from retry response - const retryNonce = DPoPNonceCache.extractNonce(retryResponse.headers); - if (retryNonce) { - globalNonceCache.set(origin, retryNonce); - } - + warmNonceFromResponse(globalNonceCache, origin, retryResponse.headers); return retryResponse; } } // Update nonce cache from successful responses if (this.config.dpopEnabled && response.ok) { - const responseNonce = DPoPNonceCache.extractNonce(response.headers); - if (responseNonce) { - globalNonceCache.set(origin, responseNonce); - } + warmNonceFromResponse(globalNonceCache, origin, response.headers); } return response; From f5bee63d0f3ac057155ad85df483d260c08efbda Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 7 Jul 2026 17:22:04 -0400 Subject: [PATCH 43/45] refactor(dpop): make DPoP nonce cache injectable per-client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the process-wide global nonce singleton with a per-client cache so nonces no longer leak across independent SDK clients (RFC 9449 §8). - AccessToken owns a DPoPNonceCache; each OIDC provider exposes it via a nonceCache getter, so its interceptor and legacy-fetch retry read the same instance its withCreds writes. - AuthProvider and DPoPInterceptorOptions/PlatformClientOptions gain an optional nonceCache; authProviderInterceptor and fetchWithCredsAndNonceRetry resolve authProvider.nonceCache, authTokenDPoPInterceptor and PlatformClient fall back to defaultNonceCache (also exposed as the deprecated globalNonceCache alias for back-compat). - captureNonce and the transport fetch wrapper (makeNonceCapturingFetch) take the cache explicitly; access-rpc/access.ts thread the provider's cache so the transport's nonce capture and the interceptor retry stay on one instance (the KAS rewrap/RPC nonce-challenge guardrail). Tests migrated to observe the per-client cache. Full suite green: mocha 354, web-test-runner 260. --- lib/src/access.ts | 13 ++++-- lib/src/access/access-fetch.ts | 12 ++++-- lib/src/access/access-rpc.ts | 18 +++++++- lib/src/auth/auth.ts | 10 +++++ lib/src/auth/dpop-nonce.ts | 22 ++++++---- lib/src/auth/interceptors.ts | 33 ++++++++++----- .../auth/oidc-clientcredentials-provider.ts | 6 +++ lib/src/auth/oidc-externaljwt-provider.ts | 6 +++ lib/src/auth/oidc-refreshtoken-provider.ts | 6 +++ lib/src/auth/oidc.ts | 32 +++++++++----- lib/src/platform.ts | 42 +++++++++++++------ lib/tests/mocha/dpop-nonce.spec.ts | 16 ++++--- lib/tests/mocha/dpop-rewrap-nonce.spec.ts | 7 +--- lib/tests/mocha/dpop-rpc-nonce.spec.ts | 7 +--- lib/tests/web/access/access-fetch.test.ts | 11 +++-- lib/tests/web/auth/dpop-nonce.test.ts | 32 +++++++------- lib/tests/web/auth/dpop-rpc-nonce.test.ts | 7 +--- lib/tests/web/interceptors.test.ts | 10 ++--- 18 files changed, 190 insertions(+), 100 deletions(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 11f1e6106..158ec86bd 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -42,13 +42,16 @@ export async function fetchWrappedKey( fulfillableObligationFQNs: string[] ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); - const { interceptors, authProvider } = resolveAuthConfig(auth); + const { authProvider } = resolveAuthConfig(auth); + // Pass the original AuthConfig (not just its interceptors) so the RPC layer can + // recover the provider's per-client DPoP nonce cache and keep the transport's + // nonce capture and the interceptor's retry on the same instance (RFC 9449 §9). const rpcCall = () => fetchWrappedKeysRpc( platformUrl, signedRequestToken, - { interceptors }, + auth, rewrapAdditionalContextHeader(fulfillableObligationFQNs) ); @@ -207,9 +210,11 @@ export async function fetchKeyAccessServers( platformUrl: string, auth: AuthConfig ): Promise { - const { interceptors, authProvider } = resolveAuthConfig(auth); + const { authProvider } = resolveAuthConfig(auth); - const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, { interceptors }); + // Pass the original AuthConfig so the RPC layer shares the provider's per-client + // DPoP nonce cache with the transport (see fetchWrappedKey). + const rpcCall = () => fetchKeyAccessServersRpc(platformUrl, auth); if (!authProvider) { return await rpcCall(); diff --git a/lib/src/access/access-fetch.ts b/lib/src/access/access-fetch.ts index bc9d34db7..6c6b8c01e 100644 --- a/lib/src/access/access-fetch.ts +++ b/lib/src/access/access-fetch.ts @@ -2,7 +2,7 @@ import { KasPublicKeyAlgorithm, KasPublicKeyInfo, OriginAllowList } from '../acc import { type AuthProvider, type HttpRequest } from '../auth/auth.js'; import { adoptChallengeNonce, - globalNonceCache, + defaultNonceCache, warmNonceFromResponse, } from '../auth/dpop-nonce.js'; import { @@ -48,6 +48,10 @@ async function fetchWithCredsAndNonceRetry( } }; + // Use the provider's per-client cache so the retry proof carries the nonce + // withCreds reads back (falls back to the shared default for custom providers). + const nonceCache = authProvider.nonceCache ?? defaultNonceCache; + let origin: string | undefined; try { origin = new URL(httpReq.url).origin; @@ -58,15 +62,15 @@ async function fetchWithCredsAndNonceRetry( let response = await send(); if (!response.ok && origin) { - const sentNonce = globalNonceCache.get(origin); - if (adoptChallengeNonce(globalNonceCache, origin, response.headers, sentNonce)) { + const sentNonce = nonceCache.get(origin); + if (adoptChallengeNonce(nonceCache, origin, response.headers, sentNonce)) { response = await send(); } } // Keep the cache warm from whichever response we end on. if (origin) { - warmNonceFromResponse(globalNonceCache, origin, response.headers); + warmNonceFromResponse(nonceCache, origin, response.headers); } return response; diff --git a/lib/src/access/access-rpc.ts b/lib/src/access/access-rpc.ts index 56c106e9d..b4b350dff 100644 --- a/lib/src/access/access-rpc.ts +++ b/lib/src/access/access-rpc.ts @@ -7,6 +7,7 @@ import { } from '../access.js'; import { type AuthConfig, resolveInterceptors } from '../auth/interceptors.js'; +import { isAuthProvider } from '../auth/auth.js'; import { ConfigurationError, InvalidFileError, @@ -41,7 +42,14 @@ export async function fetchWrappedKey( rewrapAdditionalContextHeader?: string ): Promise { const platformUrl = getPlatformUrlFromKasEndpoint(url); - const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl }); + // Share the provider's per-client nonce cache so the transport's nonce capture + // and the auth interceptor's retry read the same instance (RFC 9449 §9). + const nonceCache = isAuthProvider(auth) ? auth.nonceCache : undefined; + const platform = new PlatformClient({ + interceptors: resolveInterceptors(auth), + platformUrl, + nonceCache, + }); const options: CallOptions = {}; if (rewrapAdditionalContextHeader) { options.headers = { @@ -129,7 +137,13 @@ export async function fetchKeyAccessServers( ): Promise { let nextOffset = 0; const allServers = []; - const platform = new PlatformClient({ interceptors: resolveInterceptors(auth), platformUrl }); + // Share the provider's per-client nonce cache (see fetchWrappedKey above). + const nonceCache = isAuthProvider(auth) ? auth.nonceCache : undefined; + const platform = new PlatformClient({ + interceptors: resolveInterceptors(auth), + platformUrl, + nonceCache, + }); do { let response: ListKeyAccessServersResponse; diff --git a/lib/src/auth/auth.ts b/lib/src/auth/auth.ts index 7405b3a40..6482a6c06 100644 --- a/lib/src/auth/auth.ts +++ b/lib/src/auth/auth.ts @@ -4,6 +4,7 @@ import { type PrivateKey, } from '../../tdf3/src/crypto/declarations.js'; import { signJwt, type JwtHeader, type JwtPayload } from '../../tdf3/src/crypto/jwt.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; export type HttpMethod = | 'GET' @@ -110,6 +111,15 @@ export type AuthProvider = { * @param httpReq - Required. An http request pre-populated with the data public key. */ withCreds(httpReq: HttpRequest): Promise; + + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8), keyed by origin. Optional so that + * custom/legacy providers remain valid; consumers fall back to a shared default + * when it is absent. Providers created by this SDK expose the cache their own + * proofs read and write, so the auth interceptor and the transport share one + * instance. + */ + nonceCache?: DPoPNonceCache; }; export function isAuthProvider(a?: unknown): a is AuthProvider { diff --git a/lib/src/auth/dpop-nonce.ts b/lib/src/auth/dpop-nonce.ts index 9d8cbf961..4d554f316 100644 --- a/lib/src/auth/dpop-nonce.ts +++ b/lib/src/auth/dpop-nonce.ts @@ -105,14 +105,22 @@ export function warmNonceFromResponse( } /** - * Global nonce cache singleton. - * Shared across all instances to maintain nonce state per-origin. + * Fallback nonce cache used when a caller (e.g. a custom/legacy `AuthProvider`, + * or the interceptor-only wiring) does not supply its own. SDK-built providers + * each own a per-client {@link DPoPNonceCache} instead, so nonces don't leak + * across clients; this shared instance only backs the paths that opt out of that. */ -export const globalNonceCache = new DPoPNonceCache(); +export const defaultNonceCache = new DPoPNonceCache(); /** - * Record a `DPoP-Nonce` response header into {@link globalNonceCache}, keyed by - * the request's origin. + * @deprecated Prefer a per-client {@link DPoPNonceCache} (SDK providers expose + * one via `nonceCache`). Retained as an alias of {@link defaultNonceCache} for + * backwards compatibility — it is the *same object*, not a second cache. + */ +export const globalNonceCache = defaultNonceCache; + +/** + * Record a `DPoP-Nonce` response header into `cache`, keyed by the request's origin. * * This works directly off the raw `Response`, so it captures the nonce even when * a transport (e.g. Connect-RPC) does not surface response headers on its error @@ -121,13 +129,13 @@ export const globalNonceCache = new DPoPNonceCache(); * (RFC 9449 §9); capturing here lets the auth layer mint a nonce-bearing proof on * retry. */ -export function captureNonce(requestUrl: string, headers?: Headers): void { +export function captureNonce(cache: DPoPNonceCache, requestUrl: string, headers?: Headers): void { const nonce = DPoPNonceCache.extractNonce(headers); if (!nonce) { return; } try { - globalNonceCache.set(new URL(requestUrl).origin, nonce); + cache.set(new URL(requestUrl).origin, nonce); } catch { // Non-absolute URL: the nonce cache is origin-keyed, so nothing to store. } diff --git a/lib/src/auth/interceptors.ts b/lib/src/auth/interceptors.ts index 7c7bddd2a..8791b3aac 100644 --- a/lib/src/auth/interceptors.ts +++ b/lib/src/auth/interceptors.ts @@ -7,7 +7,8 @@ import { type AuthProvider } from './auth.js'; import { base64 } from '../encodings/index.js'; import { adoptChallengeNonceFromConnectError, - globalNonceCache, + DPoPNonceCache, + defaultNonceCache, warmNonceFromResponse, } from './dpop-nonce.js'; @@ -27,6 +28,12 @@ export type DPoPInterceptorOptions = { dpopKeys?: KeyPair | Promise; /** CryptoService for signing. Defaults to DefaultCryptoService. */ cryptoService?: CryptoService; + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8). Defaults to the shared + * {@link defaultNonceCache}; pass the same instance to `PlatformClient` for + * strict per-client isolation on the interceptor-only path. + */ + nonceCache?: DPoPNonceCache; }; /** @@ -83,6 +90,7 @@ export function authTokenInterceptor(tokenProvider: TokenProvider): Interceptor */ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPInterceptor { const cryptoService = options.cryptoService ?? DefaultCryptoService; + const nonceCache = options.nonceCache ?? defaultNonceCache; const dpopKeysPromise: Promise = options.dpopKeys ? Promise.resolve(options.dpopKeys) : cryptoService.generateSigningKeyPair(); @@ -95,7 +103,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI const httpUri = `${origin}${url.pathname}`; // Check for cached nonce - const cachedNonce = globalNonceCache.get(origin); + const cachedNonce = nonceCache.get(origin); // Generate DPoP proof JWT for this request const dpopProof = await DPoP(keys, cryptoService, httpUri, 'POST', cachedNonce, token); @@ -110,7 +118,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI // Call next and handle DPoP-Nonce retry try { const response = await next(req); - warmNonceFromResponse(globalNonceCache, origin, response.header); + warmNonceFromResponse(nonceCache, origin, response.header); return response; } catch (err) { // A Connect Unauthenticated error may carry a DPoP-Nonce challenge. The @@ -119,7 +127,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI // error metadata is a fallback for transports that do expose it. if (err instanceof ConnectError && err.code === Code.Unauthenticated) { const serverNonce = adoptChallengeNonceFromConnectError( - globalNonceCache, + nonceCache, origin, err.metadata, cachedNonce @@ -137,7 +145,7 @@ export function authTokenDPoPInterceptor(options: DPoPInterceptorOptions): DPoPI req.header.set('DPoP', retryDpopProof); const retryResponse = await next(req); - warmNonceFromResponse(globalNonceCache, origin, retryResponse.header); + warmNonceFromResponse(nonceCache, origin, retryResponse.header); return retryResponse; } } @@ -175,7 +183,7 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // Re-sign the request via withCreds and apply the resulting headers. Called // once normally, and again on a DPoP-Nonce challenge so the provider mints a - // fresh proof carrying the server-issued nonce (read from globalNonceCache). + // fresh proof carrying the server-issued nonce (read from nonceCache). const sign = async (): Promise => { let token; try { @@ -206,6 +214,11 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor }); }; + // Share the provider's per-client cache so the nonce withCreds embeds and the + // one we read back on a 401 are the same instance (falls back to the shared + // default for custom providers that don't expose one). + const nonceCache = authProvider.nonceCache ?? defaultNonceCache; + let origin: string | undefined; try { origin = new URL(req.url).origin; @@ -216,13 +229,13 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor await sign(); // Snapshot the nonce we just signed with (withCreds reads it from the cache) // so a 401 can tell us whether the server handed back a *new* one to retry. - const sentNonce = origin ? globalNonceCache.get(origin) : undefined; + const sentNonce = origin ? nonceCache.get(origin) : undefined; try { const response = await next(req); // Keep the nonce cache warm from successful responses (RFC 9449 §8). if (origin) { - warmNonceFromResponse(globalNonceCache, origin, response.header); + warmNonceFromResponse(nonceCache, origin, response.header); } return response; } catch (err) { @@ -235,7 +248,7 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor // DPoP-Nonce, so this is a no-op for them. if (origin && err instanceof ConnectError && err.code === Code.Unauthenticated) { const serverNonce = adoptChallengeNonceFromConnectError( - globalNonceCache, + nonceCache, origin, err.metadata, sentNonce @@ -243,7 +256,7 @@ export function authProviderInterceptor(authProvider: AuthProvider): Interceptor if (serverNonce) { await sign(); const retryResponse = await next(req); - warmNonceFromResponse(globalNonceCache, origin, retryResponse.header); + warmNonceFromResponse(nonceCache, origin, retryResponse.header); return retryResponse; } } diff --git a/lib/src/auth/oidc-clientcredentials-provider.ts b/lib/src/auth/oidc-clientcredentials-provider.ts index 6a4ba83bb..d14a57b2b 100644 --- a/lib/src/auth/oidc-clientcredentials-provider.ts +++ b/lib/src/auth/oidc-clientcredentials-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ClientSecretCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -45,4 +46,9 @@ export class OIDCClientCredentialsProvider implements AuthProvider { async withCreds(httpReq: HttpRequest): Promise { return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc-externaljwt-provider.ts b/lib/src/auth/oidc-externaljwt-provider.ts index cfe66bff2..0a706e888 100644 --- a/lib/src/auth/oidc-externaljwt-provider.ts +++ b/lib/src/auth/oidc-externaljwt-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type ExternalJwtCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -54,4 +55,9 @@ export class OIDCExternalJwtProvider implements AuthProvider { } return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc-refreshtoken-provider.ts b/lib/src/auth/oidc-refreshtoken-provider.ts index c23a25b04..42391ebb9 100644 --- a/lib/src/auth/oidc-refreshtoken-provider.ts +++ b/lib/src/auth/oidc-refreshtoken-provider.ts @@ -1,6 +1,7 @@ import { ConfigurationError } from '../errors.js'; import { type AuthProvider, type HttpRequest } from './auth.js'; import { AccessToken, type RefreshTokenCredentials } from './oidc.js'; +import { type DPoPNonceCache } from './dpop-nonce.js'; import * as defaultCryptoService from '../../tdf3/src/crypto/index.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -68,4 +69,9 @@ export class OIDCRefreshTokenProvider implements AuthProvider { } return this.oidcAuth.withCreds(httpReq); } + + /** Per-client DPoP-Nonce cache, shared with the underlying {@link AccessToken}. */ + get nonceCache(): DPoPNonceCache { + return this.oidcAuth.nonceCache; + } } diff --git a/lib/src/auth/oidc.ts b/lib/src/auth/oidc.ts index bf521bff5..6490f4b0d 100644 --- a/lib/src/auth/oidc.ts +++ b/lib/src/auth/oidc.ts @@ -5,7 +5,7 @@ import { base64 } from '../encodings/index.js'; import { ConfigurationError, TdfError } from '../errors.js'; import { rstrip } from '../utils.js'; import { type CryptoService, type KeyPair } from '../../tdf3/src/crypto/declarations.js'; -import { adoptChallengeNonce, globalNonceCache, warmNonceFromResponse } from './dpop-nonce.js'; +import { adoptChallengeNonce, DPoPNonceCache, warmNonceFromResponse } from './dpop-nonce.js'; /** * Common fields used by all OIDC credentialing flows. @@ -110,7 +110,18 @@ export class AccessToken { cryptoService: CryptoService; - constructor(cfg: OIDCCredentials, cryptoService: CryptoService, request?: typeof fetch) { + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8). Owned here — the interceptor and + * legacy fetch path read the same instance via the provider's `nonceCache`. + */ + readonly nonceCache: DPoPNonceCache; + + constructor( + cfg: OIDCCredentials, + cryptoService: CryptoService, + request?: typeof fetch, + nonceCache: DPoPNonceCache = new DPoPNonceCache() + ) { if (!cfg.clientId) { throw new ConfigurationError( 'A Keycloak client identifier is currently required for all auth mechanisms' @@ -138,6 +149,7 @@ export class AccessToken { this.userInfoEndpoint = cfg.oidcUserInfoEndpoint || `${this.baseUrl}/protocol/openid-connect/userinfo`; this.signingKey = cfg.signingKey; + this.nonceCache = nonceCache; } /** @@ -152,7 +164,7 @@ export class AccessToken { } as Record; let cachedNonce: string | undefined; if (this.config.dpopEnabled && this.signingKey) { - cachedNonce = globalNonceCache.get(origin); + cachedNonce = this.nonceCache.get(origin); headers.DPoP = await dpopFn( this.signingKey, this.cryptoService, @@ -172,7 +184,7 @@ export class AccessToken { // Handle DPoP-Nonce challenge per RFC 9449 §9: retry once with the server-supplied nonce. if (this.config.dpopEnabled && this.signingKey && !response.ok) { const challengeNonce = adoptChallengeNonce( - globalNonceCache, + this.nonceCache, origin, response.headers, cachedNonce @@ -194,7 +206,7 @@ export class AccessToken { // Update nonce cache from final response if (this.config.dpopEnabled) { - warmNonceFromResponse(globalNonceCache, origin, response.headers); + warmNonceFromResponse(this.nonceCache, origin, response.headers); } if (!response.ok) { @@ -225,7 +237,7 @@ export class AccessToken { // platform Keycloak mapper (lib/fixtures/keycloak.go `client.publickey`). headers['X-VirtruPubKey'] = base64.encode(publicKeyPem); - cachedNonce = globalNonceCache.get(origin); + cachedNonce = this.nonceCache.get(origin); headers.DPoP = await dpopFn(this.signingKey, this.cryptoService, url, 'POST', cachedNonce); } @@ -240,7 +252,7 @@ export class AccessToken { // Trigger on any non-OK response that carries a fresh DPoP-Nonce header. if (this.config.dpopEnabled && !response.ok) { const challengeNonce = adoptChallengeNonce( - globalNonceCache, + this.nonceCache, origin, response.headers, cachedNonce @@ -261,14 +273,14 @@ export class AccessToken { body: qstringify(o), }); - warmNonceFromResponse(globalNonceCache, origin, retryResponse.headers); + warmNonceFromResponse(this.nonceCache, origin, retryResponse.headers); return retryResponse; } } // Update nonce cache from successful responses if (this.config.dpopEnabled && response.ok) { - warmNonceFromResponse(globalNonceCache, origin, response.headers); + warmNonceFromResponse(this.nonceCache, origin, response.headers); } return response; @@ -425,7 +437,7 @@ export class AccessToken { // fragment. Resource servers (and the mock) recompute and compare it, so // a proof carrying the query string is rejected. const htu = `${origin}${url.pathname}`; - const cachedNonce = globalNonceCache.get(origin); + const cachedNonce = this.nonceCache.get(origin); const dpopToken = await dpopFn( this.signingKey, this.cryptoService, diff --git a/lib/src/platform.ts b/lib/src/platform.ts index dc09fc42a..afab6f8f5 100644 --- a/lib/src/platform.ts +++ b/lib/src/platform.ts @@ -5,22 +5,29 @@ export * as platformConnect from '@connectrpc/connect'; import { createConnectTransport } from '@connectrpc/connect-web'; import type { AuthProvider } from '../tdf3/index.js'; import { authProviderInterceptor } from './auth/interceptors.js'; -import { captureNonce } from './auth/dpop-nonce.js'; +import { captureNonce, DPoPNonceCache, defaultNonceCache } from './auth/dpop-nonce.js'; /** - * A `fetch` wrapper that records any `DPoP-Nonce` response header into the global - * nonce cache before handing the response back to the Connect transport. The + * Build a `fetch` wrapper that records any `DPoP-Nonce` response header into + * `nonceCache` before handing the response back to the Connect transport. The * Connect error type does not reliably surface response headers, so capturing at * the transport layer is what lets the DPoP auth interceptors mint a - * nonce-bearing proof and retry a rewrap challenged per RFC 9449 §9. + * nonce-bearing proof and retry a rewrap challenged per RFC 9449 §9. `nonceCache` + * must be the same instance the auth interceptor reads. */ -const nonceCapturingFetch: typeof globalThis.fetch = async (input, init) => { - const response = await fetch(input, init); - const requestUrl = - typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; - captureNonce(requestUrl, response.headers); - return response; -}; +function makeNonceCapturingFetch(nonceCache: DPoPNonceCache): typeof globalThis.fetch { + return async (input, init) => { + const response = await fetch(input, init); + const requestUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : (input as Request).url; + captureNonce(nonceCache, requestUrl, response.headers); + return response; + }; +} import { Client, createClient, Interceptor } from '@connectrpc/connect'; import { WellKnownService } from './platform/wellknownconfiguration/wellknown_configuration_pb.js'; @@ -70,6 +77,13 @@ export interface PlatformClientOptions { interceptors?: Interceptor[]; /** Base URL of the platform API. */ platformUrl: string; + /** + * Per-client DPoP-Nonce cache (RFC 9449 §8) for the transport's nonce capture. + * When an `authProvider` is supplied its own `nonceCache` is used; otherwise + * pass the same instance given to `authTokenDPoPInterceptor` for the + * interceptor-only path. Defaults to the shared {@link defaultNonceCache}. + */ + nonceCache?: DPoPNonceCache; } /** @@ -112,10 +126,14 @@ export class PlatformClient { interceptors.push(...options.interceptors); } + // Capture nonces into the same cache the auth interceptor reads: the auth + // provider's own cache when present, else the caller-supplied/default one. + const nonceCache = options.authProvider?.nonceCache ?? options.nonceCache ?? defaultNonceCache; + const transport = createConnectTransport({ baseUrl: options.platformUrl, interceptors, - fetch: nonceCapturingFetch, + fetch: makeNonceCapturingFetch(nonceCache), }); this.v1 = { diff --git a/lib/tests/mocha/dpop-nonce.spec.ts b/lib/tests/mocha/dpop-nonce.spec.ts index c01d9562d..e601153e4 100644 --- a/lib/tests/mocha/dpop-nonce.spec.ts +++ b/lib/tests/mocha/dpop-nonce.spec.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import { AccessToken } from '../../src/auth/oidc.js'; import { clientSecretAuthProvider } from '../../src/auth/providers.js'; -import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; import { DefaultCryptoService, generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -19,9 +18,8 @@ describe('DPoP nonce challenge — integration with mock server', function (this keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // Each AccessToken/provider owns its own per-client nonce cache, so tests are + // naturally isolated — no shared-cache teardown needed. it('transparently retries with server-issued nonce and returns 200', async () => { const accessToken = new AccessToken( @@ -50,13 +48,10 @@ describe('DPoP nonce challenge — integration with mock server', function (this expect(body.access_token).to.equal('test-dpop-token'); // Cache must be populated with the server's nonce after the round-trip - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + expect(accessToken.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); }); it('uses cached nonce on the first request after a prior successful challenge', async () => { - // Pre-seed cache as if a prior request already populated it - globalNonceCache.set(SERVER_ORIGIN, SERVER_NONCE); - const accessToken = new AccessToken( { clientId: 'test-client', @@ -69,6 +64,9 @@ describe('DPoP nonce challenge — integration with mock server', function (this DefaultCryptoService ); + // Pre-seed this client's cache as if a prior request already populated it + accessToken.nonceCache.set(SERVER_ORIGIN, SERVER_NONCE); + // With the correct nonce already cached, the first request should succeed directly (no retry). const response = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials', @@ -95,7 +93,7 @@ describe('DPoP nonce challenge — integration with mock server', function (this const token = await provider.oidcAuth.get(false); expect(token).to.equal('test-dpop-token'); - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); + expect(provider.nonceCache.get(SERVER_ORIGIN)).to.equal(SERVER_NONCE); }); it('omits DPoP header when no signing key is configured, even after updateClientPublicKey binds one for body signing', async () => { diff --git a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts index 8eb09f14d..81ef6b8fe 100644 --- a/lib/tests/mocha/dpop-rewrap-nonce.spec.ts +++ b/lib/tests/mocha/dpop-rewrap-nonce.spec.ts @@ -3,7 +3,6 @@ import { assert, expect } from 'chai'; import { getMocks } from '../mocks/index.js'; import { Client } from '../../tdf3/src/index.js'; import { clientSecretAuthProvider } from '../../src/auth/providers.js'; -import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; import type { Scope } from '../../tdf3/src/client/builders.js'; @@ -37,9 +36,7 @@ describe('DPoP RS nonce retry on the KAS rewrap path — integration with mock s dpopKeyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // The provider owns its per-client nonce cache, so each test is isolated. it('decrypt survives the rewrap nonce challenge and returns the plaintext', async () => { const expectedVal = 'rewrap nonce roundtrip'; @@ -89,6 +86,6 @@ describe('DPoP RS nonce retry on the KAS rewrap path — integration with mock s // A successful decrypt proves the rewrap survived the challenge; the cached // RS nonce proves a challenge actually happened and the retry adopted it. - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); }); }); diff --git a/lib/tests/mocha/dpop-rpc-nonce.spec.ts b/lib/tests/mocha/dpop-rpc-nonce.spec.ts index 6a2a588b0..0ae030c34 100644 --- a/lib/tests/mocha/dpop-rpc-nonce.spec.ts +++ b/lib/tests/mocha/dpop-rpc-nonce.spec.ts @@ -1,6 +1,5 @@ import { expect } from 'chai'; import { clientSecretAuthProvider } from '../../src/auth/providers.js'; -import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; import { PlatformClient } from '../../src/platform.js'; import { generateSigningKeyPair } from '../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../tdf3/src/crypto/declarations.js'; @@ -29,9 +28,7 @@ describe('DPoP RS nonce retry over Connect-RPC — integration with mock server' keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // The provider owns its per-client nonce cache, so each test is isolated. it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { const authProvider = await clientSecretAuthProvider({ @@ -56,6 +53,6 @@ describe('DPoP RS nonce retry over Connect-RPC — integration with mock server' // The consumed challenge leaves the RS nonce cached for the origin, proving // a challenge happened and the retry adopted it. - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); }); }); diff --git a/lib/tests/web/access/access-fetch.test.ts b/lib/tests/web/access/access-fetch.test.ts index a92b1b83f..ab3bb23cd 100644 --- a/lib/tests/web/access/access-fetch.test.ts +++ b/lib/tests/web/access/access-fetch.test.ts @@ -16,7 +16,7 @@ import { UnauthenticatedError, } from '../../../src/errors.js'; import { OriginAllowList } from '../../../src/access.js'; -import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; +import { DPoPNonceCache } from '../../../src/auth/dpop-nonce.js'; import type { AuthProvider } from '../../../src/index.js'; // ------------------------------------------------------------- @@ -252,22 +252,25 @@ describe('access-fetch.js', () => { // for the origin, recording it so the test can confirm the retry saw the // server challenge. const noncesSeen: (string | undefined)[] = []; + // The provider owns its per-client cache; the retry path reads it back. + const nonceCache = new DPoPNonceCache(); const dpopAuthProvider: AuthProvider = { + nonceCache, withCreds: sinon.stub().callsFake(async (req) => { - noncesSeen.push(globalNonceCache.get(origin)); + noncesSeen.push(nonceCache.get(origin)); return { ...req, headers: { ...req.headers, Authorization: 'DPoP test-token' } }; }), } as unknown as AuthProvider; beforeEach(() => { noncesSeen.length = 0; - globalNonceCache.clear(origin); + nonceCache.clear(origin); // @ts-expect-error stub dpopAuthProvider.withCreds.resetHistory(); }); afterEach(() => { - globalNonceCache.clear(origin); + nonceCache.clear(origin); }); it('retries once with the server nonce and succeeds', async () => { diff --git a/lib/tests/web/auth/dpop-nonce.test.ts b/lib/tests/web/auth/dpop-nonce.test.ts index 055faee80..c695dc654 100644 --- a/lib/tests/web/auth/dpop-nonce.test.ts +++ b/lib/tests/web/auth/dpop-nonce.test.ts @@ -2,7 +2,7 @@ import { expect } from '@esm-bundle/chai'; import { Code, ConnectError } from '@connectrpc/connect'; import { stub } from 'sinon'; import { AccessToken } from '../../../src/auth/oidc.js'; -import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; +import { DPoPNonceCache } from '../../../src/auth/dpop-nonce.js'; import { authTokenDPoPInterceptor } from '../../../src/auth/interceptors.js'; import { DefaultCryptoService, generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; @@ -27,9 +27,7 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // Each AccessToken owns its own per-client nonce cache — tests are isolated. function makeAccessToken(fetchStub: typeof fetch) { return new AccessToken( @@ -67,7 +65,7 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { expect(fetchStub.callCount).to.equal(2); expect(result.status).to.equal(200); - expect(globalNonceCache.get(ORIGIN)).to.equal(NONCE); + expect(accessToken.nonceCache.get(ORIGIN)).to.equal(NONCE); // Second request's DPoP proof must include the nonce const secondInit = fetchStub.secondCall.args[1] as RequestInit; @@ -77,9 +75,6 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { }); it('does not retry when server returns the same nonce already cached', async () => { - // Pre-seed the cache with the same nonce the server will return - globalNonceCache.set(ORIGIN, NONCE); - const fetchStub = stub().resolves({ status: 401, ok: false, @@ -87,6 +82,8 @@ describe('AccessToken.doPost DPoP-Nonce retry', () => { } as Response); const accessToken = makeAccessToken(fetchStub as unknown as typeof fetch); + // Pre-seed this client's cache with the same nonce the server will return + accessToken.nonceCache.set(ORIGIN, NONCE); const result = await accessToken.doPost(TOKEN_URL, { grant_type: 'client_credentials' }); // No retry — same nonce means we'd loop; return the 401 to the caller @@ -108,14 +105,11 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); - - function makeInterceptor() { + function makeInterceptor(nonceCache: DPoPNonceCache) { return authTokenDPoPInterceptor({ tokenProvider: async () => 'dummy-access-token', dpopKeys: Promise.resolve(keyPair), + nonceCache, }); } @@ -142,11 +136,12 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { // Second call: success mockNext.onSecondCall().resolves({ header: { get: () => null } }); - const interceptor = makeInterceptor(); + const nonceCache = new DPoPNonceCache(); + const interceptor = makeInterceptor(nonceCache); await interceptor(mockNext as Parameters[0])(makeMockReq()); expect(mockNext.callCount).to.equal(2); - expect(globalNonceCache.get(ORIGIN)).to.equal(NONCE); + expect(nonceCache.get(ORIGIN)).to.equal(NONCE); // Retry request must have nonce in its DPoP proof const retryReq = mockNext.secondCall.firstArg as { header: Headers }; @@ -156,7 +151,8 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { }); it('does not retry when server returns the same nonce already cached', async () => { - globalNonceCache.set(ORIGIN, NONCE); + const nonceCache = new DPoPNonceCache(); + nonceCache.set(ORIGIN, NONCE); const mockNext = stub().callsFake(() => Promise.reject( @@ -168,11 +164,11 @@ describe('authTokenDPoPInterceptor DPoP-Nonce retry', () => { ) ); - const interceptor = makeInterceptor(); + const interceptor = makeInterceptor(nonceCache); try { await interceptor(mockNext as Parameters[0])(makeMockReq()); expect.fail('should have thrown'); - } catch (err) { + } catch { // Expected: interceptor re-throws when nonce unchanged } diff --git a/lib/tests/web/auth/dpop-rpc-nonce.test.ts b/lib/tests/web/auth/dpop-rpc-nonce.test.ts index 03c6cd04f..378812b4f 100644 --- a/lib/tests/web/auth/dpop-rpc-nonce.test.ts +++ b/lib/tests/web/auth/dpop-rpc-nonce.test.ts @@ -1,6 +1,5 @@ import { expect } from '@esm-bundle/chai'; import { clientSecretAuthProvider } from '../../../src/auth/providers.js'; -import { globalNonceCache } from '../../../src/auth/dpop-nonce.js'; import { PlatformClient } from '../../../src/platform.js'; import { generateSigningKeyPair } from '../../../tdf3/src/crypto/index.js'; import type { KeyPair } from '../../../tdf3/src/crypto/declarations.js'; @@ -22,9 +21,7 @@ describe('DPoP RS nonce retry over Connect-RPC (browser)', () => { keyPair = await generateSigningKeyPair(); }); - afterEach(() => { - globalNonceCache.clearAll(); - }); + // The provider owns its per-client nonce cache, so each test is isolated. it('ListKeyAccessServers: interceptor retries once on the RS nonce challenge and succeeds', async () => { const authProvider = await clientSecretAuthProvider({ @@ -43,6 +40,6 @@ describe('DPoP RS nonce retry over Connect-RPC (browser)', () => { expect(response.$typeName).to.equal('policy.kasregistry.ListKeyAccessServersResponse'); expect(response.keyAccessServers.map((s) => s.uri)).to.include(SERVER_ORIGIN); - expect(globalNonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); + expect(authProvider.nonceCache.get(SERVER_ORIGIN)).to.equal(RS_NONCE); }); }); diff --git a/lib/tests/web/interceptors.test.ts b/lib/tests/web/interceptors.test.ts index c72af2691..91db683d1 100644 --- a/lib/tests/web/interceptors.test.ts +++ b/lib/tests/web/interceptors.test.ts @@ -10,7 +10,7 @@ import { resolveAuthConfig, isInterceptorConfig, } from '../../src/auth/interceptors.js'; -import { globalNonceCache } from '../../src/auth/dpop-nonce.js'; +import { DPoPNonceCache } from '../../src/auth/dpop-nonce.js'; // --- helpers --- @@ -167,14 +167,15 @@ describe('authProviderInterceptor', () => { it('retries once with the server-issued DPoP-Nonce on an Unauthenticated challenge', async () => { const origin = 'https://platform.example.com'; const url = `${origin}/policy.kasregistry/ListKeyAccessServers`; - globalNonceCache.clear(origin); + const nonceCache = new DPoPNonceCache(); // Provider records the nonce it sees so we can assert the retry carried it. const seenNonces: (string | undefined)[] = []; const mockAuthProvider: AuthProvider = { updateClientPublicKey: async () => {}, + nonceCache, withCreds: async (req: HttpRequest) => { - seenNonces.push(globalNonceCache.get(new URL(req.url).origin)); + seenNonces.push(nonceCache.get(new URL(req.url).origin)); return withHeaders(req, { Authorization: 'DPoP token' }); }, }; @@ -197,8 +198,7 @@ describe('authProviderInterceptor', () => { expect(attempts).to.equal(2); expect(seenNonces).to.deep.equal([undefined, 'server-nonce-xyz']); - expect(globalNonceCache.get(origin)).to.equal('server-nonce-xyz'); - globalNonceCache.clear(origin); + expect(nonceCache.get(origin)).to.equal('server-nonce-xyz'); }); it('wraps updateClientPublicKey errors with helpful message', async () => { From d33b39b1f675e394c8451ac81aacea524070b356 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Tue, 7 Jul 2026 18:03:49 -0400 Subject: [PATCH 44/45] fix(access): surface swallowed legacy rewrap fallback error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the legacy REST rewrap fallback also fails, we still (correctly) rethrow the more meaningful RPC error — but the legacy failure was dropped entirely. Log it via console.info (matching the sibling 'v2 rewrap request error' line) so the fallback path is debuggable. Behavior is otherwise unchanged. --- lib/src/access.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 158ec86bd..9ec3dd3e2 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -85,7 +85,10 @@ export async function fetchWrappedKey( { signedRequestToken }, authProvider )) as unknown as RewrapResponse; - } catch { + } catch (legacyError) { + // Surface the (more meaningful) RPC error, but don't silently drop the + // legacy failure — log it so the fallback path is debuggable. + console.info('legacy rewrap fallback also failed', legacyError); throw rpcError; } } From 5167258a6e7648fefbfda1e7e3e2f73fa9f94171 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Wed, 8 Jul 2026 10:10:58 -0400 Subject: [PATCH 45/45] fix(cli): forward per-client DPoP nonce cache through LoggedAuthProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI wraps the OIDC provider in a LoggedAuthProvider decorator that exposed withCreds/updateClientPublicKey but not nonceCache. After the per-client cache change, the auth interceptor and Connect transport resolved `authProvider.nonceCache ?? defaultNonceCache` (=> default, since the wrapper hid it) while withCreds — delegated to the wrapped AccessToken — minted proofs from the AccessToken's own cache. The two caches diverged, so the DPoP-Nonce challenge retry (RFC 9449 §9) never carried the server nonce and js decrypt failed at ListKeyAccessServers with 401 (xtest test_dpop_happy_path_roundtrip, test_dpop_server_issued_nonce_retry). Forward nonceCache so the wrapper, its interceptor, and withCreds share one instance. --- cli/src/cli.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index fad9b695d..873679b31 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -95,6 +95,12 @@ async function processAuth( const requestLog: AuthProviders.HttpRequest[] = []; return { requestLog, + // Forward the wrapped provider's per-client DPoP-Nonce cache. Without this, + // the auth interceptor/transport fall back to the shared default cache while + // `withCreds` (delegated below) mints proofs from the wrapped provider's own + // cache — the two diverge and the DPoP-Nonce challenge retry never carries + // the server nonce (RFC 9449 §9). + nonceCache: actual.nonceCache, updateClientPublicKey: async (signingKey: KeyPair) => { actual.updateClientPublicKey(signingKey); log('DEBUG', `updateClientPublicKey: [${signingKey?.publicKey}]`);