Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 29 additions & 10 deletions web-app/src/session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { decodeJwt } from 'jose';
import { default as dpopFn } from 'dpop';
import { base64 } from '@opentdf/sdk/encodings';
import { AuthProvider, HttpRequest, withHeaders } from '@opentdf/sdk';
import {
AuthProvider,
DPoPNonceCache,
HttpRequest,
sendWithNonceRetry,
withHeaders,
} from '@opentdf/sdk';
import { type KeyPair, WebCryptoService } from '@opentdf/sdk/singlecontainer';

export type OpenidConfiguration = {
Expand Down Expand Up @@ -174,6 +180,7 @@ export class OidcClient implements AuthProvider {
scope: string;
sessionIdentifier: string;
_sessions?: Sessions;
readonly nonceCache = new DPoPNonceCache();
// Store as opaque KeyPair
private signingKey?: KeyPair;

Expand Down Expand Up @@ -463,13 +470,24 @@ export class OidcClient implements AuthProvider {
publicKey: publicKeyPem,
privateKey: privateKeyPem,
});
headers.DPoP = await dpopFn(cryptoPair, config.token_endpoint, 'POST');
const response = await fetch(config.token_endpoint, {
method: 'POST',
headers,
body: params,
credentials: 'include',
});
// Keycloak answers the first proof with 400 + DPoP-Nonce when nonces are
// required (RFC 9449 §8); sendWithNonceRetry re-mints around the challenge.
const tokenOrigin = new URL(config.token_endpoint).origin;
const response = await sendWithNonceRetry(
this.nonceCache,
tokenOrigin,
'web-app token endpoint',
async (nonce) => {
headers.DPoP = await dpopFn(cryptoPair, config.token_endpoint, 'POST', nonce);
return fetch(config.token_endpoint, {
method: 'POST',
headers,
body: params,
credentials: 'include',
});
}
);

if (!response.ok) {
throw new Error(response.statusText);
}
Expand Down Expand Up @@ -516,14 +534,15 @@ export class OidcClient implements AuthProvider {
publicKey: publicKeyPem,
privateKey: privateKeyPem,
});
const requestOrigin = new URL(httpReq.url).origin;
const dpopToken = await dpopFn(
cryptoPair,
httpReq.url,
httpReq.method,
/* nonce */ undefined,
this.nonceCache.get(requestOrigin),
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 });
}
}
4 changes: 4 additions & 0 deletions web-app/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.):
Expand Down
71 changes: 71 additions & 0 deletions web-app/tests/tests/dpop-headers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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'],
});
}
});

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;

// 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();
}
}

// Decode one proof header to confirm it is a well-formed DPoP proof (RFC 9449 §4.2).
const proof = captured.find((r) => r.url.includes('/kas') && r.dpop)?.dpop;
expect(proof, 'a KAS request should carry a DPoP proof').toBeTruthy();
const header = JSON.parse(Buffer.from(proof!.split('.')[0], 'base64url').toString('utf8'));
expect(header.typ).toBe('dpop+jwt');

// The test environment requires a server-issued nonce. At least one retried
// token or KAS request must therefore carry that nonce in its proof.
const nonceProof = captured.find((r) => {
if (!r.dpop) return false;
const payload = JSON.parse(Buffer.from(r.dpop.split('.')[1], 'base64url').toString('utf8'));
return typeof payload.nonce === 'string' && payload.nonce.length > 0;
});
expect(nonceProof, 'a retried DPoP proof should carry the server nonce').toBeTruthy();
});