diff --git a/e2e/tests/dpop-endpoints.test.ts b/e2e/tests/dpop-endpoints.test.ts index 88f28c1..2750f38 100644 --- a/e2e/tests/dpop-endpoints.test.ts +++ b/e2e/tests/dpop-endpoints.test.ts @@ -10,8 +10,7 @@ * * Run with: * SERVER_COMMAND="your-dpop-quickstart-start-command" PORT=your-port-number \ - * npx playwright test e2e/tests/dpop-endpoints.test.ts \ - * --config playwright.dpop-endpoints.config.ts + * yarn test:e2e:dpop-endpoints * * Prerequisites: * - A consuming quickstart application (e.g. fusionauth-quickstart-javascript-react-web) @@ -27,9 +26,14 @@ * Allowed origins, and add `DPoP` and `Authorization` to Allowed * headers. Without this, the userinfo request's CORS preflight fails * with "No 'Access-Control-Allow-Origin' header is present" + */ -import { Page, test, BrowserContext, expect } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { Page, Route, test, BrowserContext, expect } from '@playwright/test'; import { quickstartPage } from '../pages/common.page'; interface DPoPTokens { @@ -39,6 +43,17 @@ interface DPoPTokens { tokenType: string; } +interface DpopSdkConfig { + clientId: string; + serverUrl: string; + redirectUri: string; +} + +const CORE_BUNDLE_PATH = path.resolve( + __dirname, + '../../packages/core/dist/index.js', +); + async function readDpopTokens(page: Page): Promise { const evaluateTokens = () => page.evaluate(() => { @@ -65,6 +80,97 @@ async function readDpopTokens(page: Page): Promise { return raw ? JSON.parse(raw) : null; } +function decodeJwtPayload(jwt: string): Record { + const payload = jwt.split('.')[1]; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')); +} + +/** `ath` claim value per RFC 9449: `base64url(SHA-256(accessToken))`. */ +function computeAth(accessToken: string): string { + return createHash('sha256').update(accessToken).digest('base64url'); +} + +/** + * Reads `client_id`, `redirect_uri`, and the FusionAuth origin off the + * `/oauth2/authorize` URL. Call this immediately after + * `quickstart.navToLogIn()` (before `authenticate()`), while `page.url()` + * still points at the authorize redirect. + */ +function captureSdkConfig(page: Page): DpopSdkConfig { + const authorizeUrl = new URL(page.url()); + const clientId = authorizeUrl.searchParams.get('client_id'); + const redirectUri = authorizeUrl.searchParams.get('redirect_uri'); + if (!clientId || !redirectUri) { + throw new Error( + 'Expected client_id and redirect_uri on the /oauth2/authorize URL. ' + + 'Call captureSdkConfig() right after quickstart.navToLogIn().', + ); + } + return { clientId, redirectUri, serverUrl: authorizeUrl.origin }; +} + +/** + * Injects a second, independent `SDKCore` instance into the page. + * This is needed because `dpopFetch()` and `getAccessToken()` are only + * available on the SDKCore instance, and none of these tests drive them + * through UI interaction. + */ +async function injectDpopSdkCore( + page: Page, + config: DpopSdkConfig, +): Promise { + let bundleSource: string; + try { + bundleSource = fs.readFileSync(CORE_BUNDLE_PATH, 'utf-8'); + } catch { + throw new Error( + `Could not read ${CORE_BUNDLE_PATH}. Build @fusionauth-sdk/core first ` + + '(e.g. `yarn build:core`).', + ); + } + + const exportMatch = bundleSource.match(/(\S+)\s+as\s+SDKCore/); + if (!exportMatch) { + throw new Error( + `Could not locate the SDKCore export in ${CORE_BUNDLE_PATH}.`, + ); + } + const localName = exportMatch[1]; + + const script = `${bundleSource} +window.__e2eSdkCore = new ${localName}({ + clientId: ${JSON.stringify(config.clientId)}, + serverUrl: ${JSON.stringify(config.serverUrl)}, + redirectUri: ${JSON.stringify(config.redirectUri)}, + useDpop: true, + dpopTokenStorage: 'localStorage', + onTokenExpiration: () => {}, +});`; + + await page.addScriptTag({ content: script, type: 'module' }); + await page.waitForFunction(() => (window as any).__e2eSdkCore !== undefined); +} + +/** + * Answers a CORS preflight `OPTIONS` request directly and returns `true`, + * or returns `false` for any other method so the caller can run its real + * request logic. + */ +function handleCorsPreflight(route: Route): boolean { + if (route.request().method() !== 'OPTIONS') { + return false; + } + route.fulfill({ + status: 204, + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, OPTIONS', + 'access-control-allow-headers': 'Authorization, DPoP', + }, + }); + return true; +} + test.describe('DPoP Endpoint Tests', () => { test.describe.configure({ mode: 'serial' }); @@ -194,4 +300,151 @@ test.describe('DPoP Endpoint Tests', () => { expect(await readDpopTokens(page)).toBeNull(); }); + + test('dpopFetch() sends Authorization: DPoP and DPoP proof headers with a correct ath claim', async () => { + await quickstart.navToLogIn(); + const sdkConfig = captureSdkConfig(page); + await quickstart.authenticate(); + + await injectDpopSdkCore(page, sdkConfig); + + const accessToken = await page.evaluate( + () => (window as any).__e2eSdkCore.getAccessToken() as string | null, + ); + expect(accessToken).toBeTruthy(); + + let capturedAuthHeader: string | undefined; + let capturedDpopHeader: string | undefined; + + await page.route('https://api.example.com/data', route => { + if (handleCorsPreflight(route)) return; + + const headers = route.request().headers(); + capturedAuthHeader = headers['authorization']; + capturedDpopHeader = headers['dpop']; + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + }); + + const result = await page.evaluate(async () => { + const response = await (window as any).__e2eSdkCore.dpopFetch( + 'https://api.example.com/data', + { method: 'GET' }, + ); + return { status: response.status, ok: response.ok }; + }); + + expect(result.ok).toBe(true); + expect(capturedAuthHeader).toBe(`DPoP ${accessToken}`); + expect(capturedDpopHeader).toBeTruthy(); + expect(capturedDpopHeader!.split('.').length).toBe(3); + + const proofPayload = decodeJwtPayload(capturedDpopHeader!); + expect(proofPayload.ath).toBe(computeAth(accessToken!)); + expect(proofPayload.htm).toBe('GET'); + expect(new URL(proofPayload.htu as string).pathname).toBe('/data'); + + const accessTokenAfterFetch = await page.evaluate( + () => (window as any).__e2eSdkCore.getAccessToken() as string | null, + ); + expect(accessTokenAfterFetch).toBe(accessToken); + + await quickstart.logOut(); + }); + + test('dpopFetch() retries exactly once with the server nonce after a 401 use_dpop_nonce challenge', async () => { + await quickstart.navToLogIn(); + const sdkConfig = captureSdkConfig(page); + await quickstart.authenticate(); + + await injectDpopSdkCore(page, sdkConfig); + + const serverNonce = 'e2e-test-nonce-abc123'; + let requestCount = 0; + let retryDpopHeader: string | undefined; + + await page.route('https://api.example.com/nonce-protected', route => { + if (handleCorsPreflight(route)) return; + + requestCount += 1; + if (requestCount === 1) { + route.fulfill({ + status: 401, + headers: { + 'access-control-allow-origin': '*', + 'access-control-expose-headers': 'WWW-Authenticate, DPoP-Nonce', + 'www-authenticate': 'DPoP error="use_dpop_nonce"', + 'dpop-nonce': serverNonce, + }, + body: '', + }); + return; + } + retryDpopHeader = route.request().headers()['dpop']; + route.fulfill({ + status: 200, + contentType: 'application/json', + body: '{}', + }); + }); + + const result = await page.evaluate(async () => { + const response = await (window as any).__e2eSdkCore.dpopFetch( + 'https://api.example.com/nonce-protected', + { method: 'GET' }, + ); + return { status: response.status }; + }); + + expect(requestCount).toBe(2); + expect(result.status).toBe(200); + expect(retryDpopHeader).toBeTruthy(); + expect(decodeJwtPayload(retryDpopHeader!).nonce).toBe(serverNonce); + + await quickstart.logOut(); + }); + + test('dpopFetch() does not retry a second time when the retry also returns a 401 use_dpop_nonce', async () => { + await quickstart.navToLogIn(); + const sdkConfig = captureSdkConfig(page); + await quickstart.authenticate(); + + await injectDpopSdkCore(page, sdkConfig); + + let requestCount = 0; + + await page.route('https://api.example.com/always-nonce', route => { + if (handleCorsPreflight(route)) return; + + requestCount += 1; + route.fulfill({ + status: 401, + headers: { + 'access-control-allow-origin': '*', + 'access-control-expose-headers': 'WWW-Authenticate, DPoP-Nonce', + 'www-authenticate': 'DPoP error="use_dpop_nonce"', + 'dpop-nonce': `e2e-test-nonce-${requestCount}`, + }, + body: '', + }); + }); + + const result = await page.evaluate(async () => { + const response = await (window as any).__e2eSdkCore.dpopFetch( + 'https://api.example.com/always-nonce', + { method: 'GET' }, + ); + return { status: response.status }; + }); + + // Exactly the initial request plus one retry - no further retries even + // though the retry itself also returned 401 use_dpop_nonce. + expect(requestCount).toBe(2); + expect(result.status).toBe(401); + + await quickstart.logOut(); + }); }); diff --git a/package.json b/package.json index cbd98d0..d31e1eb 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "test:sdk-react": "yarn workspace @fusionauth/react-sdk test", "test:sdk-vue": "yarn workspace @fusionauth/vue-sdk test", "test:e2e": "yarn playwright test", - "test:e2e:dpop-endpoints": "yarn playwright test --config playwright.dpop-endpoints.config.ts", + "test:e2e:dpop-endpoints": "yarn build:core && yarn playwright test --config playwright.dpop-endpoints.config.ts", "lint:fix": "eslint . --ext .ts,.tsx --fix", "lint:check": "eslint . --ext .ts,.tsx --max-warnings 0", "format:fix": "prettier --write .", diff --git a/packages/core/src/SDKCore/SDKCore.ts b/packages/core/src/SDKCore/SDKCore.ts index bbab484..87108ef 100644 --- a/packages/core/src/SDKCore/SDKCore.ts +++ b/packages/core/src/SDKCore/SDKCore.ts @@ -254,9 +254,6 @@ export class SDKCore { return response; } - /** - * Performs the DPoP mode refresh token grant. - */ private async refreshDpopToken(): Promise { const refreshToken = this.dpopManager!.getRefreshToken(); if (!refreshToken) { diff --git a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts index 55512ea..f193664 100644 --- a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts +++ b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts @@ -244,7 +244,7 @@ describe('createFusionAuth', () => { expect(fusionAuth.getAccessToken?.()).toBeNull(); }); - it('isLoggedIn flips to true once the post-redirect DPoP token exchange settles', async () => { + it('isLoggedIn flips to true once the post-redirect DPoP token exchange completes', async () => { vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( {} as any, ); @@ -322,7 +322,7 @@ describe('createFusionAuth', () => { expect(isLoggedInDuringOnRedirect).toBe(true); }); - it('shouldAutoFetchUserInfo fetches userInfo once isLoggedIn flips to true after the DPoP redirect settles (not just at construction)', async () => { + it('shouldAutoFetchUserInfo fetches userInfo once isLoggedIn flips to true after the DPoP redirect completes (not just at construction)', async () => { vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( {} as any, ); @@ -368,7 +368,7 @@ describe('createFusionAuth', () => { }); // userInfo only becomes available asynchronously, well after - // construction — it is not fetched until the DPoP redirect settles. + // construction — it is not fetched until the DPoP redirect completes. await vi.waitFor(() => { expect(fusionAuth.userInfo.value).toEqual({ email: 'user@example.com',