diff --git a/web-app/src/session.ts b/web-app/src/session.ts index 2bcbe1256..42fc61263 100644 --- a/web-app/src/session.ts +++ b/web-app/src/session.ts @@ -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 = { @@ -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; @@ -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); } @@ -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 }); } } 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.): 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..ec7f3d88a --- /dev/null +++ b/web-app/tests/tests/dpop-headers.spec.ts @@ -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(); +});