Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
856b21f
feat(web-sdk): comprehensive DPoP nonce handling and verification (DS…
dmihalcik-virtru Jun 8, 2026
c957bf2
docs: add DPoP CLI flags design spec (DSPX-3397)
dmihalcik-virtru Jun 9, 2026
8029160
docs: add DPoP CLI flags implementation plan (DSPX-3397)
dmihalcik-virtru Jun 9, 2026
7258b84
feat(cli): add DPoP key pair helpers (DSPX-3397)
dmihalcik-virtru Jun 9, 2026
3b07386
fix(cli): guard derToPem empty input, warn on RS384/RS512 downgrade
dmihalcik-virtru Jun 9, 2026
0663b87
feat(cli): change --dpop to string type, add --dpop-key option (DSPX-…
dmihalcik-virtru Jun 9, 2026
b77182d
feat(cli): wire --dpop and --dpop-key into encrypt/decrypt commands (…
dmihalcik-virtru Jun 9, 2026
ce59a53
🤖 🎨 Autoformat
dmihalcik-virtru Jun 9, 2026
9eae720
fix(dpop): address code review feedback on nonce retry logic and defe…
dmihalcik-virtru Jun 10, 2026
0757686
test(dpop): add DPoP nonce challenge to mock server and cover retry l…
dmihalcik-virtru Jun 10, 2026
4cba0ac
🤖 🎨 Autoformat
dmihalcik-virtru Jun 10, 2026
686db74
🤖 🎨 Autoformat
dmihalcik-virtru Jun 10, 2026
e528331
ci(dpop): upgrade Keycloak to 26.2 and enable DPoP nonce challenges i…
dmihalcik-virtru Jun 10, 2026
a227e7f
fix(dpop): add missing CORS headers to mock server for browser test c…
dmihalcik-virtru Jun 10, 2026
3a2e3d2
chore(scripts): add local dev helper scripts for DPoP demo
dmihalcik-virtru Jun 11, 2026
a71d56d
chore(scripts): add config-demo.sh to provision KC and start dev server
dmihalcik-virtru Jun 11, 2026
3ef7f9c
fix(scripts): create demo user1 in config-demo.sh
dmihalcik-virtru Jun 11, 2026
7168da0
fix(scripts): route OIDC through Vite proxy to avoid CORS
dmihalcik-virtru Jun 11, 2026
d5f6f45
fix(scripts): route KAS through Vite proxy; drop PLATFORM_URL
dmihalcik-virtru Jun 11, 2026
7dad52f
fix(sdk): use DPoP scheme when presenting DPoP-bound tokens
dmihalcik-virtru Jun 11, 2026
9ccebc6
test(web-app): add Playwright test capturing DPoP-protected headers
dmihalcik-virtru Jun 11, 2026
189d144
fix(dpop): make nonce challenge handling RFC 9449 compliant (DSPX-3397)
dmihalcik-virtru Jun 16, 2026
54eca29
fix(cli): harden DPoP key loading and helper utilities (DSPX-3397)
dmihalcik-virtru Jun 16, 2026
4eb2484
refactor(dpop): use ConnectError type and dedupe CLI argv handling (D…
dmihalcik-virtru Jun 16, 2026
7b93362
test(cli): cover PEM loading, key-path resolution, and CLI argv shape…
dmihalcik-virtru Jun 16, 2026
359a321
🤖 🎨 Autoformat
dmihalcik-virtru Jun 16, 2026
81c3736
fix(dpop): send DPoP proof on initial Keycloak token request (DSPX-3397)
dmihalcik-virtru Jun 17, 2026
6b83cef
🤖 🎨 Autoformat
dmihalcik-virtru Jun 17, 2026
3511081
fix(dpop): scope DPoP enablement to providers configured with a key (…
dmihalcik-virtru Jun 17, 2026
28a8280
fix(dpop): emit raw IEEE P1363 ECDSA sigs in DPoP proofs; strict mock…
dmihalcik-virtru Jun 17, 2026
99ab7ac
fix(dpop): pass full request URL to AuthProvider.withCreds (DSPX-3397)
dmihalcik-virtru Jun 24, 2026
90760ed
fix(dpop): nonce-challenge retry on legacy fetch path; strip query fr…
dmihalcik-virtru Jun 24, 2026
8f43bb7
🤖 🎨 Autoformat
dmihalcik-virtru Jun 24, 2026
dccd23a
test(dpop): enforce RFC 9449 on RPC endpoints + retry on AuthProvider…
dmihalcik-virtru Jun 24, 2026
bee3618
fix(dpop): sign rewrap request token with the dpop key's algorithm (D…
dmihalcik-virtru Jun 25, 2026
1e3ea98
fix(dpop): surface RPC rewrap error instead of legacy 404 mask (DSPX-…
dmihalcik-virtru Jun 25, 2026
95b3e07
fix(dpop): capture DPoP-Nonce at the Connect transport for rewrap ret…
dmihalcik-virtru Jun 25, 2026
da159f6
debug(dpop): temporary fetch-layer header dump for 401/400 (DSPX-3397)
dmihalcik-virtru Jun 25, 2026
4e09e0e
debug(dpop): dump rewrap proof claims + 401 body (DSPX-3397)
dmihalcik-virtru Jun 25, 2026
78ce979
fix(dpop): emit raw IEEE P1363 ECDSA sigs in rewrap request token (DS…
dmihalcik-virtru Jun 25, 2026
3247a49
Merge branch 'main' into DSPX-3397-web-sdk
dmihalcik-virtru Jun 30, 2026
dedd347
refactor(dpop): use type alias for DPoPJwtHeaderParameters
dmihalcik-virtru Jul 7, 2026
07853c3
refactor(dpop): extract shared nonce-challenge helpers
dmihalcik-virtru Jul 7, 2026
f5bee63
refactor(dpop): make DPoP nonce cache injectable per-client
dmihalcik-virtru Jul 7, 2026
d33b39b
fix(access): surface swallowed legacy rewrap fallback error
dmihalcik-virtru Jul 7, 2026
5167258
fix(cli): forward per-client DPoP nonce cache through LoggedAuthProvider
dmihalcik-virtru Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/roundtrip/config-demo-idp.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
23 changes: 2 additions & 21 deletions .github/workflows/roundtrip/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/roundtrip/encrypt-decrypt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand All @@ -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}" \
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/roundtrip/keycloak_data.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 53 additions & 16 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +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';
import { resolveDPoPFromArgs } from './dpop-helpers.js';

type AuthToProcess = {
auth?: string;
Expand Down Expand Up @@ -52,14 +53,10 @@ const parseJwtComplete = (jwt: string) => {
return { header: parseJwt(jwt, 0), payload: parseJwt(jwt) };
};

async function processAuth({
auth,
clientId,
clientSecret,
concurrencyLimit,
oidcEndpoint,
userId,
}: AuthToProcess): Promise<LoggedAuthProvider> {
async function processAuth(
{ auth, clientId, clientSecret, concurrencyLimit, oidcEndpoint, userId }: AuthToProcess,
dpopKeyPair?: KeyPair
): Promise<LoggedAuthProvider> {
log('DEBUG', 'Processing auth params');
if (!oidcEndpoint) {
throw new CLIError('CRITICAL', 'oidcEndpoint must be specified');
Expand All @@ -78,18 +75,32 @@ 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,
});
if (concurrencyLimit !== 1) {
await actual.oidcAuth.get();
}
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}]`);
Expand Down Expand Up @@ -393,8 +404,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')
Expand Down Expand Up @@ -510,6 +527,21 @@ export const handleArgs = (args: string[]) => {
description: 'output file',
})

.command(
'supports <feature>',
'Check if a feature is supported',
(yargs) => {
yargs.strict().positional('feature', {
describe: 'feature name to check',
type: 'string',
choices: ['dpop'],
});
},
async () => {
// yargs choices validation ensures feature is supported; return naturally exits 0
}
)

.command(
'inspect [file]',
'Inspect TDF and extract header information, without decrypting',
Expand Down Expand Up @@ -558,9 +590,11 @@ 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 client = new OpenTDF({
authProvider,
defaultCreateOptions: {
Expand All @@ -571,7 +605,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,
});
Expand All @@ -597,14 +632,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();
}
Expand All @@ -621,7 +656,8 @@ 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);

Expand All @@ -630,7 +666,8 @@ export const handleArgs = (args: string[]) => {
defaultCreateOptions: {
defaultKASEndpoint: argv.kasEndpoint,
},
disableDPoP: !argv.dpop,
disableDPoP: !dpopEnabled,
dpopKeys: dpopKeyPair ? Promise.resolve(dpopKeyPair) : undefined,
policyEndpoint: guessedPolicyEndpoint,
platformUrl: argv.platformUrl || guessedPolicyEndpoint,
});
Expand Down
Loading
Loading