Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 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
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
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
319 changes: 289 additions & 30 deletions e2e/tests/dpop-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/**
* DPoP Smoke Tests — pre-SDKCore wiring
* DPoP Smoke Tests — pre-SDKCore wiring + SDKCore.startLogin() integration

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.

When DPoP is fully implemented this file will be deleted and new end to end tests will be added to endpoints.test.ts

This file is a way to test the current state of the DPoP implementation with a live FusionAuth server.

*
* Exercises DPoPManager + UrlHelper directly against a real FusionAuth
* Enterprise instance. No quickstart app is needed — the tests drive
* FusionAuth's hosted login UI via Playwright, capture the authorization
* code from the redirect, and perform all token operations in the Node
* test process.
* Exercises SDKCore.startLogin() in DPoP mode without a live
* FusionAuth instance. Stubs window/localStorage/indexedDB to create a real
* SDKCore, calls startLogin(), and asserts the authorize URL shape and
* code_verifier persistence.
*
* Exercise DPoPManager + UrlHelper directly against a
* real FusionAuth Enterprise instance. No quickstart app is needed — the tests
* drive FusionAuth's hosted login UI via Playwright.
*
* Run with:
* npx playwright test e2e/tests/dpop-smoke.test.ts \
Expand All @@ -25,6 +28,12 @@ import { IDBFactory } from 'fake-indexeddb';
import { DPoPManager } from '../../packages/core/src/DPoP/DPoPManager';
import { DPoPTokens } from '../../packages/core/src/DPoP/DPoPTokenStore';
import { UrlHelper } from '../../packages/core/src/UrlHelper/UrlHelper';
import { SDKCore } from '../../packages/core/src/SDKCore/SDKCore';
import { RedirectHelper } from '../../packages/core/src/RedirectHelper/RedirectHelper';
import {
generateCodeVerifier,
generateCodeChallenge,
} from '../../packages/core/src/Pkce/Pkce';

// ---------------------------------------------------------------------------
// Config
Expand Down Expand Up @@ -54,31 +63,51 @@ function decodeJwt(jwt: string): Record<string, unknown> {
);
}

/** Generate a PKCE code_verifier and code_challenge (SHA-256 / base64url). */
async function generatePkce(): Promise<{
verifier: string;
challenge: string;
}> {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = Buffer.from(array).toString('base64url');

const hash = await crypto.subtle.digest(
'SHA-256',
Buffer.from(verifier, 'ascii'),
);
const challenge = Buffer.from(hash).toString('base64url');

return { verifier, challenge };
}

/** Build a fresh DPoPManager backed by fake-indexeddb (no browser required in Node). */
function makeManager(): DPoPManager {
// @ts-ignore — Node has no native indexedDB; fake-indexeddb fills the gap.
globalThis.indexedDB = new IDBFactory();
return new DPoPManager(CLIENT_ID, 'memory');
}

/**
* Creates a deferred `window.location.assign` stub paired with a promise
* that resolves with the assigned URL.
*
* `SDKCore.startLogin()` is synchronous (`void`) — in DPoP mode it kicks off
* an async chain (key-pair generation, PKCE, etc.) internally and does not
* return a promise the caller can await. This helper waits
* deterministically for that async chain to complete (signaled by
* `window.location.assign` being called) instead of awaiting `startLogin()`
* directly.
*/
function createAssignWaiter(timeoutMs = 5_000): {
assign: (url: string) => void;
waitForUrl: () => Promise<string>;
} {
let resolveUrl!: (url: string) => void;
const urlPromise = new Promise<string>(resolve => {
resolveUrl = resolve;
});

return {
assign: (url: string) => resolveUrl(url),
waitForUrl: () =>
Promise.race([
urlPromise,
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error('Timed out waiting for window.location.assign()'),
),
timeoutMs,
),
),
]),
};
}

/**
* Drive FusionAuth's hosted login page through Playwright and return the
* authorization `code` captured from the redirect to REDIRECT_URI.
Expand Down Expand Up @@ -157,9 +186,158 @@ async function loginAndCaptureCode(
]);
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe('SDKCore.startLogin() DPoP mode', () => {
const DPOP_CONFIG = {
serverUrl: FA_URL,
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
scope: SCOPE,
useDpop: true as const,
dpopTokenStorage: 'memory' as const,
onTokenExpiration: () => {},
cookieAdapter: { at_exp: () => undefined },
};

// Provide browser-API polyfills required by SDKCore and its dependencies
// when running in Node (Playwright's test process is Node, not a browser).
test.beforeAll(() => {
// IndexedDB — required by DPoPStorage (via DPoPManager).
// @ts-ignore
globalThis.indexedDB = new IDBFactory();

// localStorage — required by RedirectHelper and DPoPTokenStore.
if (typeof globalThis.localStorage === 'undefined') {
const store: Record<string, string> = {};
// @ts-ignore
globalThis.localStorage = {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => {
store[k] = v;
},
removeItem: (k: string) => {
delete store[k];
},
clear: () => {
for (const k in store) delete store[k];
},
};
}

// window — SDKCore calls window.location.assign and DPoPManager uses
// indexedDB / crypto globals that browsers expose via window. We stub
// window with the minimal surface SDKCore touches.
if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = {
location: { assign: () => {} },
crypto: globalThis.crypto,
};
}
});

test.afterEach(() => {
// Clear localStorage between tests so each starts clean.
globalThis.localStorage.clear();
// Fresh IndexedDB so key-pair state doesn't leak across tests.
// @ts-ignore
globalThis.indexedDB = new IDBFactory();
});

test('startLogin() redirects to /oauth2/authorize with dpop_jkt and code_challenge', async () => {
const { assign, waitForUrl } = createAssignWaiter();
// @ts-ignore
globalThis.window.location = { assign };

// startLogin() is synchronous (void) — fire and wait for the redirect.
new SDKCore(DPOP_CONFIG).startLogin();
const assignedUrl = await waitForUrl();

const url = new URL(assignedUrl);

expect(url.origin).toBe(FA_URL);
expect(url.pathname).toBe('/oauth2/authorize');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe(CLIENT_ID);
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI);
expect(url.searchParams.get('code_challenge_method')).toBe('S256');

const dpopJkt = url.searchParams.get('dpop_jkt');
const codeChallenge = url.searchParams.get('code_challenge');

// dpop_jkt: base64url JWK thumbprint — 43 chars, valid base64url charset.
expect(dpopJkt).not.toBeNull();
expect(dpopJkt).toMatch(/^[A-Za-z0-9\-_]{43}$/);

// code_challenge: base64url SHA-256 — 43 chars, valid base64url charset.
expect(codeChallenge).not.toBeNull();
expect(codeChallenge).toMatch(/^[A-Za-z0-9\-_]{43}$/);
});

test('startLogin() persists code_verifier and state via RedirectHelper', async () => {
const STATE = 'e2e-smoke-state';
const { assign, waitForUrl } = createAssignWaiter();
// @ts-ignore
globalThis.window.location = { assign };

const core = new SDKCore(DPOP_CONFIG);

core.startLogin(STATE);
const assignedUrl = await waitForUrl();

const url = new URL(assignedUrl);

// State is included in the authorize URL.
expect(url.searchParams.get('state')).toBe(STATE);

// code_verifier is persisted via RedirectHelper so the post-redirect
// handler (ENG-4800) can retrieve it for the token exchange.
const redirectHelper = new RedirectHelper();
const storedVerifier = redirectHelper.getCodeVerifier();
expect(storedVerifier).not.toBeUndefined();
expect(storedVerifier).toMatch(/^[A-Za-z0-9\-_]{43}$/);

// The stored code_verifier must produce the code_challenge in the URL.
const expectedChallenge = await generateCodeChallenge(storedVerifier!);
expect(url.searchParams.get('code_challenge')).toBe(expectedChallenge);
});

test('two startLogin() calls produce different key pairs and PKCE values', async () => {
const core1 = new SDKCore(DPOP_CONFIG);

// Each SDKCore gets its own DPoPManager with its own key pair.
// @ts-ignore
globalThis.indexedDB = new IDBFactory();

const core2 = new SDKCore(DPOP_CONFIG);

// Wait for core1's full async chain (including its key pair being
// written to the *first* IndexedDB instance) to complete before
// swapping IndexedDB out for core2 — startLogin() is fire-and-forget, so
// this ordering must be enforced explicitly rather than relying on
// sequential awaits on startLogin() itself.
const waiter1 = createAssignWaiter();
// @ts-ignore
globalThis.window.location = { assign: waiter1.assign };
core1.startLogin();
const url1 = new URL(await waiter1.waitForUrl());

globalThis.localStorage.clear();
// @ts-ignore
globalThis.indexedDB = new IDBFactory();

const waiter2 = createAssignWaiter();
// @ts-ignore
globalThis.window.location = { assign: waiter2.assign };
core2.startLogin();
const url2 = new URL(await waiter2.waitForUrl());

// Different key pairs → different dpop_jkt.
// Different PKCE verifiers → different code_challenge.
expect(url1.searchParams.get('code_challenge')).not.toBe(
url2.searchParams.get('code_challenge'),
);
});
});

test.describe('DPoP smoke tests', () => {
test.describe.configure({ mode: 'serial' });
Expand Down Expand Up @@ -187,7 +365,8 @@ test.describe('DPoP smoke tests', () => {

test('getAuthorizeUrl() produces a URL FusionAuth accepts (login page rendered)', async () => {
thumbprint = await manager.getThumbprint();
const { challenge } = await generatePkce();
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);

const urlHelper = new UrlHelper({
serverUrl: FA_URL,
Expand All @@ -209,7 +388,8 @@ test.describe('DPoP smoke tests', () => {
});

test('full authorization code exchange — token_type is DPoP, cnf.jkt matches thumbprint', async () => {
const { verifier, challenge } = await generatePkce();
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
thumbprint = await manager.getThumbprint();

const urlHelper = new UrlHelper({
Expand Down Expand Up @@ -322,7 +502,6 @@ test.describe('DPoP smoke tests', () => {
const atPayload = decodeJwt(tokenResponse.access_token);
expect((atPayload.cnf as { jkt: string }).jkt).toBe(thumbprint);

// Update shared state for Tier 2.
accessToken = tokenResponse.access_token;
refreshToken = tokenResponse.refresh_token ?? refreshToken;

Expand Down Expand Up @@ -429,6 +608,86 @@ test.describe('DPoP smoke tests', () => {
}
});

test('nonce retry (deterministic) — DPoPManager.fetch() retries with the correct nonce claim when the resource server issues a use_dpop_nonce challenge', async () => {
// FusionAuth (as the Authorization Server) never issues a use_dpop_nonce
// challenge itself — nonce enforcement is explicitly a Resource Server
// responsibility that your own APIs implement (see FusionAuth's DPoP
// docs: "FusionAuth currently does not require nonce handling, but your
// APIs may require one for resource access").
//
// This test simulates a Resource Server that DOES require a nonce, by
// mocking globalThis.fetch (DPoPManager.fetch() calls the native fetch
// directly, so this is a substitute for a real RS response).
// It uses its own fresh DPoPManager so it does not depend on shared
// state/order.

const FAKE_RESOURCE_URL = 'https://fake-resource-server.example.com/data';
const SERVER_NONCE = 'server-issued-nonce-abc123';

const nonceManager = makeManager();

let callCount = 0;
let firstProof: string | null = null;
let secondProof: string | null = null;

const originalFetch = globalThis.fetch;
globalThis.fetch = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
callCount++;
const dpopHeader = new Headers(init?.headers).get('DPoP');

if (callCount === 1) {
firstProof = dpopHeader;
// Simulate a Resource Server that requires a fresh nonce — per
// RFC 9449 §8, a 401 with a WWW-Authenticate header containing
// 'use_dpop_nonce' and a DPoP-Nonce response header.
return new Response(null, {
status: 401,
headers: {
'WWW-Authenticate':
'DPoP error="use_dpop_nonce", error_description="Resource server requires a nonce"',
'DPoP-Nonce': SERVER_NONCE,
},
});
}

secondProof = dpopHeader;
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};

try {
const response = await nonceManager.fetch(FAKE_RESOURCE_URL);

expect(response.status).toBe(200);
// Exactly one retry — original call + single nonce retry, no more.
expect(callCount).toBe(2);

expect(firstProof).not.toBeNull();
expect(secondProof).not.toBeNull();

// The first proof (before the server ever provided a nonce) must NOT
// carry a nonce claim.
const firstPayload = decodeJwt(firstProof!);
expect(firstPayload.nonce).toBeUndefined();

// The retried proof MUST carry the server-issued nonce claim, proving
// DPoPManager cached it from the DPoP-Nonce response header and used
// it to regenerate the proof before retrying.
const secondPayload = decodeJwt(secondProof!);
expect(secondPayload.nonce).toBe(SERVER_NONCE);

// Both proofs must otherwise target the same resource/method.
expect(firstPayload.htu).toBe(FAKE_RESOURCE_URL);
expect(secondPayload.htu).toBe(FAKE_RESOURCE_URL);
expect(firstPayload.htm).toBe('GET');
expect(secondPayload.htm).toBe('GET');
} finally {
globalThis.fetch = originalFetch;
}
});

test('clear() removes key pair, tokens, and nonces — isLoggedIn becomes false', async () => {
expect(manager.isLoggedIn).toBe(true);

Expand Down
Loading
Loading