Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
110 commits
Select commit Hold shift + click to select a range
b95aba5
feat: add DPoP core storage layer (ENG-4782)
mrudatsprint Jul 15, 2026
9f3e0bc
feat: fix file formatting.
mrudatsprint Jul 15, 2026
7738bb4
fix: DPoPStorage openDb() error handling and test coverage
mrudatsprint Jul 15, 2026
d386a32
fix: fix copilot warnings.
mrudatsprint Jul 15, 2026
bd37ddf
fix: resolve DPoP transactions on tx.oncomplete, not req.onsuccess
mrudatsprint Jul 16, 2026
d91e5b7
feat: the workflow will run regardless of the branch being merged into.
mrudatsprint Jul 16, 2026
7eeb367
feat: implement DPoPManager central coordinator (ENG-4784)
mrudatsprint Jul 16, 2026
06a557e
feat: re-generate lock file.
mrudatsprint Jul 16, 2026
00a0acb
feat: re-generate lock file.
mrudatsprint Jul 16, 2026
14b9b4a
feat: update lock file.
mrudatsprint Jul 16, 2026
e9a8048
Merge branches 'miker/eng-4784/central-coordinator' and 'miker/eng-47…
mrudatsprint Jul 16, 2026
ec6372e
test: add DPoP smoke tests against real FusionAuth instance (pre-SDKC…
mrudatsprint Jul 16, 2026
324d970
feat: fix format and lint errors.
mrudatsprint Jul 17, 2026
952cd03
refactor: make DPoPStorage IndexedDB constants configurable via confi…
mrudatsprint Jul 17, 2026
b7e6feb
feature: delete contrived test to intercept a successful even and the…
mrudatsprint Jul 17, 2026
2c27865
Merge branch 'miker/eng-4782/core-storage-layer' into miker/eng-4784/…
mrudatsprint Jul 17, 2026
b5601e2
fix: update DPoPStorage constructor calls to use config object after …
mrudatsprint Jul 17, 2026
252322a
feature: update lock file
mrudatsprint Jul 17, 2026
eb01796
feat: implement SDKCore.startLogin() for DPoP authorization code gran…
mrudatsprint Jul 18, 2026
ce41545
fix: add @vitest-environment jsdom to SDKCore.test.ts; fix handlePreR…
mrudatsprint Jul 18, 2026
f29df57
fix: suppress cookie console.error noise in Tier 0 e2e tests
mrudatsprint Jul 18, 2026
7babfae
feat: update lock file.
mrudatsprint Jul 18, 2026
bfc666a
fix: update Angular onRedirect test to use 3-segment redirect-value f…
mrudatsprint Jul 18, 2026
27b9ab6
fix: update Vue and React onRedirect tests to use 3-segment redirect-…
mrudatsprint Jul 18, 2026
14c1d0c
fix: merge Request and init headers in DPoPManager.fetch() instead of…
mrudatsprint Jul 20, 2026
b1567a6
feat: fix angular and vue tests.
mrudatsprint Jul 20, 2026
f7cefc8
fix: clone Request before retry in fetch() to avoid double body consu…
mrudatsprint Jul 20, 2026
ddb5509
fix: normalize htu and htm in generateProof() per RFC 9449 (PR #202 C…
mrudatsprint Jul 20, 2026
185357c
fix: remove dead captured header variables and misleading comment in …
mrudatsprint Jul 20, 2026
098bbfb
feat: update approvers.
mrudatsprint Jul 20, 2026
13db934
feat: fix merge conflict.
mrudatsprint Jul 20, 2026
2b6e6de
feat: fix failing tests.
mrudatsprint Jul 20, 2026
920081d
test: add deterministic nonce-retry smoke test (T2-4)
mrudatsprint Jul 20, 2026
b711f2d
fix: revert startLogin() to void, address Copilot PR review comment (…
mrudatsprint Jul 20, 2026
8cfc607
docs: fix stale/ambiguous state-reconstruction description in Redirec…
mrudatsprint Jul 20, 2026
a03dd2e
fix: preserve state from legacy 2-segment redirect values (Copilot PR…
mrudatsprint Jul 21, 2026
50b852f
feat: update comments.
mrudatsprint Jul 21, 2026
8f8f5bc
feat: SDKCore: implement handlePostRedirect() authorization code exch…
mrudatsprint Jul 21, 2026
d5f4569
feat: remove references to ENG- linear issues.
mrudatsprint Jul 21, 2026
5f517f0
feat: remove redundant comments.
mrudatsprint Jul 21, 2026
1f4de60
feat: merge and remove verbose comments.
mrudatsprint Jul 22, 2026
aa7bbdc
feat: merge in the parent branch.
mrudatsprint Jul 22, 2026
9acf888
feat: remove file not needed until adding end to end tests.
mrudatsprint Jul 22, 2026
24b7337
feat: remove lengthy comment.
mrudatsprint Jul 22, 2026
e5342e3
feat: remote unnecessary comments.
mrudatsprint Jul 22, 2026
ac12953
feat: merge in the parent branch.
mrudatsprint Jul 22, 2026
81f46f4
feat: remove verbose comment.
mrudatsprint Jul 22, 2026
ab79bf8
feat: copilot review warnings.
mrudatsprint Jul 22, 2026
fd4353e
feat: failing smoke test.
mrudatsprint Jul 22, 2026
d138ca3
feat: minimize verbose comments.
mrudatsprint Jul 22, 2026
0232d82
feat: clean up comments.
mrudatsprint Jul 22, 2026
1a58211
feat: minimize verbose comments.
mrudatsprint Jul 22, 2026
44cd888
feat: SDKCore - implement startLogout() and getAccessToken() for DPoP…
mrudatsprint Jul 23, 2026
595c4e4
feat: rebuild the lock file.
mrudatsprint Jul 23, 2026
ff619a3
feat: remove verbose comments.
mrudatsprint Jul 23, 2026
16859d9
feat: remove verbose comments.
mrudatsprint Jul 23, 2026
711c709
feat: SDKCore - implement refreshToken() for DPoP mode (ENG-4801)
mrudatsprint Jul 24, 2026
e3e174a
feat: refresh token grant
mrudatsprint Jul 24, 2026
78cf20d
feat: remove verbose comments.
mrudatsprint Jul 24, 2026
5bef47a
chore: upgrade vitest to v3.2.6 for core, lexicon, and sdk-react
mrudatsprint Jul 24, 2026
20d66a8
feat: add DPoP to the React SDK, sync the version of vitest being use…
mrudatsprint Jul 24, 2026
4d8985d
Merge branch 'parent/dpop-in-the-javascript-sdk' into miker/eng-4800/…
mrudatsprint Jul 26, 2026
4a990cf
feat: copilot recommendation .
mrudatsprint Jul 27, 2026
c5e1f35
feat: cleanup comments.
mrudatsprint Jul 27, 2026
31cb1ad
feat: reduce commenting.
mrudatsprint Jul 27, 2026
46984e9
Merge branch 'miker/eng-4800/code-grant-finish' into miker/eng-4802/s…
mrudatsprint Jul 27, 2026
7861863
feat: insure logout url is called.
mrudatsprint Jul 27, 2026
ed1f063
feat: use hosted backend mode versus cookie mode in comments.
mrudatsprint Jul 27, 2026
e716fee
feat: remove verbose comments.
mrudatsprint Jul 27, 2026
fe643bf
Merge branch 'parent/dpop-in-the-javascript-sdk' into miker/eng-4802/…
mrudatsprint Jul 28, 2026
6455306
Merge branch 'miker/eng-4802/start-logout' into miker/eng-4801/refres…
mrudatsprint Jul 28, 2026
210dbf8
feat: update from the last merge.
mrudatsprint Jul 28, 2026
617826e
feat: remove comment verbosity.
mrudatsprint Jul 28, 2026
f8c2e59
feat: remove comment verbosity.
mrudatsprint Jul 28, 2026
4298efe
Merge branch 'miker/eng-4801/refresh-token' into mike/eng-4787/dpop-sdks
mrudatsprint Jul 28, 2026
a19cf50
feat: reduce verbose commenting.
mrudatsprint Jul 28, 2026
6797302
fix: address Copilot review comments on refreshDpopToken() (PR #206)
mrudatsprint Jul 28, 2026
f90d545
feat: preserve the existing refresh token, if needed.
mrudatsprint Jul 28, 2026
3960681
fix: reapply refreshDpopToken() Copilot fixes lost in the eng-4801 merge
mrudatsprint Jul 28, 2026
b5e1de4
fix: DPoP mode startLogout() targets /oauth2/logout directly
mrudatsprint Jul 28, 2026
d14345a
test: add DPoP endpoint e2e tests (dpop-endpoints.test.ts)
mrudatsprint Jul 28, 2026
c8ada31
feat: SDKCore.fetchUserInfo() is DPoP aware (ENG-4931)
mrudatsprint Jul 29, 2026
12d466c
feat: cleanup
mrudatsprint Jul 29, 2026
e0cca73
feat: test user info endpoint.
mrudatsprint Jul 29, 2026
4b3a38d
fix: React SDK auto-fetches userInfo after async DPoP login, not just…
mrudatsprint Jul 29, 2026
d4263c7
test: merge userinfo check into the auto-refresh test
mrudatsprint Jul 29, 2026
02ed28a
debug: temporary diagnostics for the /oauth2/userinfo investigation
mrudatsprint Jul 29, 2026
f9c6ffe
Revert "debug: temporary diagnostics for the /oauth2/userinfo investi…
mrudatsprint Jul 29, 2026
0d90540
docs: document the FusionAuth CORS prerequisite for /oauth2/userinfo
mrudatsprint Jul 29, 2026
0258a16
feat: remove verbose comments.
mrudatsprint Jul 29, 2026
af93031
fix: prevent duplicate DPoP authorization code exchange on concurrent…
mrudatsprint Jul 29, 2026
cef6017
feat: cleanup duplicate code grant exchanges being tracket.
mrudatsprint Jul 29, 2026
bea8a4b
Merge branch 'parent/dpop-in-the-javascript-sdk' into miker/eng-4801/…
mrudatsprint Jul 29, 2026
37c3c35
fix: dpop-smoke.test.ts refresh token test — undefined var + wrong order
mrudatsprint Jul 29, 2026
3417fc2
feat: parent merge
mrudatsprint Jul 29, 2026
2924951
feat: The Angular Framework has is using the DPoP functionality in th…
mrudatsprint Jul 30, 2026
0b98e9c
feat: The Vue Framework using DPoP.
mrudatsprint Jul 30, 2026
52188c8
feat: the VUE framework using DPoP.
mrudatsprint Jul 30, 2026
1cce4e5
feat: remove verbose comments.
mrudatsprint Jul 31, 2026
9c97883
feat: remove verbose comments.
mrudatsprint Aug 1, 2026
f0ca26c
feat: remove test duplication.
mrudatsprint Aug 1, 2026
89f588d
test: add dpopFetch resource-access and nonce-retry e2e coverage (ENG…
mrudatsprint Aug 1, 2026
4893e00
fix: expose WWW-Authenticate/DPoP-Nonce headers on mocked cross-origi…
mrudatsprint Aug 2, 2026
0aa919d
feat: remove verbose comments.
mrudatsprint Aug 2, 2026
dba3da8
feat: remove verbose comments.
mrudatsprint Aug 2, 2026
06425d8
fix: address Copilot review comments on PR #208
mrudatsprint Aug 2, 2026
744627d
feat: remove verbose comments.
mrudatsprint Aug 2, 2026
26c41f1
fix: build @fusionauth-sdk/core before running dpop-endpoints e2e suite
mrudatsprint Aug 2, 2026
af86a09
feat: remove verbose comments.
mrudatsprint Aug 2, 2026
7e2924f
Merge branch 'parent/dpop-in-the-javascript-sdk' into miker/eng-4790
mrudatsprint Aug 3, 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
259 changes: 256 additions & 3 deletions e2e/tests/dpop-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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',
);
Comment thread
mrudatsprint marked this conversation as resolved.

async function readDpopTokens(page: Page): Promise<DPoPTokens | null> {
const evaluateTokens = () =>
page.evaluate(() => {
Expand All @@ -65,6 +80,97 @@ async function readDpopTokens(page: Page): Promise<DPoPTokens | null> {
return raw ? JSON.parse(raw) : null;
}

function decodeJwtPayload(jwt: string): Record<string, unknown> {
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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added three dropFetch() tests. I believe they provide coverage for when a Resource Server API is not directly available.

* 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<void> {
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' });

Expand Down Expand Up @@ -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 => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note, the re-try logic is in dropFetch()

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();
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
3 changes: 0 additions & 3 deletions packages/core/src/SDKCore/SDKCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,6 @@ export class SDKCore {
return response;
}

/**
* Performs the DPoP mode refresh token grant.
*/
private async refreshDpopToken(): Promise<Response> {
const refreshToken = this.dpopManager!.getRefreshToken();
if (!refreshToken) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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',
Expand Down
Loading