Skip to content
218 changes: 136 additions & 82 deletions e2e/tests/dpop-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/**
* DPoP Smoke Tests — pre-SDKCore wiring + SDKCore.startLogin() integration
* DPoP Smoke Tests — pre-SDKCore wiring + SDKCore integration
*
* Exercises SDKCore.startLogin() in DPoP mode without a live
* Exercises SDKCore.startLogin() in DPoP mode with 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.
* real FusionAuth instance.
*
* Run with:
* npx playwright test e2e/tests/dpop-smoke.test.ts \
Expand All @@ -26,7 +25,7 @@
import { Page, expect, test } from '@playwright/test';
import { IDBFactory } from 'fake-indexeddb';
import { DPoPManager } from '../../packages/core/src/DPoP/DPoPManager';
import { DPoPTokens } from '../../packages/core/src/DPoP/DPoPTokenStore';
import { DPoPTokenStore } 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';
Expand Down Expand Up @@ -70,6 +69,43 @@ function makeManager(): DPoPManager {
return new DPoPManager(CLIENT_ID, 'memory');
}

/**
* Idempotently polyfills `window` and `localStorage` in the Node/Playwright
* test process so that a real `SDKCore` (and its dependencies —
* `RedirectHelper`, `DPoPTokenStore`) can run outside a browser:
* - `window.location.assign` — used by `SDKCore.startLogin()`.
* - `window.crypto` — used by `RedirectHelper.generateRandomString()`.
* - `localStorage` — used by `RedirectHelper` and `DPoPTokenStore`.
*/
function ensureNodeBrowserPolyfills(): void {
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];
},
};
}

if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = {
location: { assign: () => {} },
crypto: globalThis.crypto,
// Needed by SDKCore.clearRedirectQueryParams() (history.replaceState).
history: { replaceState: () => {} },
};
}
}

/**
* Creates a deferred `window.location.assign` stub paired with a promise
* that resolves with the assigned URL.
Expand Down Expand Up @@ -205,34 +241,7 @@ test.describe('SDKCore.startLogin() DPoP mode', () => {
// @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,
};
}
ensureNodeBrowserPolyfills();
});

test.afterEach(() => {
Expand Down Expand Up @@ -356,6 +365,7 @@ test.describe('DPoP smoke tests', () => {
context = await browser.newContext();
page = await context.newPage();
manager = makeManager();
ensureNodeBrowserPolyfills();
});

test.afterAll(async () => {
Expand Down Expand Up @@ -387,77 +397,121 @@ test.describe('DPoP smoke tests', () => {
await expect(page.locator('#loginId')).toBeVisible();
});

test('full authorization code exchange — token_type is DPoP, cnf.jkt matches thumbprint', async () => {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
thumbprint = await manager.getThumbprint();
test('full authorization code grant via SDKCore.startLogin() + handlePostRedirect() — token_type is DPoP, cnf.jkt matches thumbprint', async () => {
const STATE = 'e2e-state';

const urlHelper = new UrlHelper({
ensureNodeBrowserPolyfills();

// A real SDKCore in DPoP mode, running in the Node/Playwright test
// process (see ensureNodeBrowserPolyfills). `dpopTokenStorage:
// 'localStorage'` so the exchanged tokens can be read back directly —
// SDKCore.getAccessToken() doesn't exist yet (ENG-4802).
let notify:
((result: { state?: string } | { error: Error }) => void) | undefined;

const core = new SDKCore({
serverUrl: FA_URL,
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
scope: SCOPE,
useDpop: true,
dpopTokenStorage: 'localStorage',
onTokenExpiration: () => {},
// handlePostRedirect() reports exchange failures here instead of
// throwing — wire it into the same single-shot `notify` used by the
// handlePostRedirect() callback below so either outcome resolves the
// same promise.
onLoginFailure: error => notify?.({ error }),
});

const authorizeUrl = urlHelper
.getAuthorizeUrl(thumbprint, challenge)
.toString();
// startLogin() kicks off an async chain (key pair, PKCE, etc.) and
// redirects via window.location.assign() — capture the assigned URL.
const { assign, waitForUrl } = createAssignWaiter();
// @ts-ignore
globalThis.window.location = { assign };

core.startLogin(STATE);
// waitForUrl()'s declared return type is `string`, but SDKCore actually
// calls window.location.assign() with a URL object (UrlHelper.getAuthorizeUrl()
// returns URL) — stringify explicitly so page.goto() below (which requires
// a real string) doesn't silently fail navigation.
const authorizeUrl = String(await waitForUrl());

// Navigate fresh
await page.goto('about:blank');
const code = await loginAndCaptureCode(page, authorizeUrl);

// Exchange the code at the token endpoint using a real DPoP proof.
const proof = await manager.generateProof(TOKEN_ENDPOINT, 'POST');

const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: verifier,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
});

const response = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
DPoP: proof,
// Simulate landing back on the redirect URI with ?code=... in the query
// string, then let handlePostRedirect() run the real exchange.
// origin/pathname/hash are needed by SDKCore.clearRedirectQueryParams(),
// which rebuilds the URL from these parts (not .href) after a
// successful exchange, to strip code/state via history.replaceState().
const redirectUrl = new URL(REDIRECT_URI);
const replaceStateCalls: string[] = [];
// @ts-ignore
globalThis.window.location = {
assign: () => {},
origin: redirectUrl.origin,
pathname: redirectUrl.pathname,
hash: '',
search: `?code=${code}`,
};
// @ts-ignore
globalThis.window.history = {
replaceState: (_state: unknown, _title: string, url?: string | URL) => {
if (url) replaceStateCalls.push(url.toString());
},
body: body.toString(),
});
};

const responseText = await response.text();
expect(response.status, `Token exchange failed: ${responseText}`).toBe(200);
const outcome = await new Promise<{ state?: string } | { error: Error }>(
(resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error('Timed out waiting for handlePostRedirect()')),
15_000,
);
notify = result => {
clearTimeout(timeout);
resolve(result);
};
core.handlePostRedirect(state => notify?.({ state }));
},
);

const tokenResponse = JSON.parse(responseText) as {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
};
if ('error' in outcome) {
throw outcome.error;
}
// state round-trips through RedirectHelper's persisted storage.
expect(outcome.state).toBe(STATE);
expect(core.isLoggedIn).toBe(true);

// code/state were stripped from the URL via history.replaceState() once
// the exchange succeeded, so they don't linger in the address bar,
// browser history, referrers, logs, or screenshots.
expect(replaceStateCalls).toHaveLength(1);
const cleanedUrl = new URL(replaceStateCalls[0]!);
expect(cleanedUrl.searchParams.get('code')).toBeNull();
expect(cleanedUrl.searchParams.get('state')).toBeNull();

// Read the tokens SDKCore just persisted, directly via DPoPTokenStore
// (same clientId/storage mode SDKCore's internal DPoPManager used).
const tokenStore = new DPoPTokenStore(CLIENT_ID, 'localStorage');
const tokens = tokenStore.get();
expect(tokens).not.toBeNull();

// token_type must be 'DPoP' — proves FusionAuth recognised and bound the proof.
expect(tokenResponse.token_type.toLowerCase()).toBe('dpop');
expect(tokenResponse.access_token).toBeDefined();
expect(tokens!.tokenType).toBe('DPoP');
expect(tokens!.accessToken).toBeDefined();

// Decode the access token and verify cnf.jkt matches our key's thumbprint.
const atPayload = decodeJwt(tokenResponse.access_token);
const atPayload = decodeJwt(tokens!.accessToken);
expect(atPayload.cnf).toBeDefined();
expect((atPayload.cnf as { jkt: string }).jkt).toBe(thumbprint);

// Persist tokens for subsequent tests.
accessToken = tokenResponse.access_token;
refreshToken = tokenResponse.refresh_token ?? '';

const expiresAt = Date.now() + tokenResponse.expires_in * 1000;
const tokens: DPoPTokens = {
accessToken,
refreshToken: refreshToken || undefined,
expiresAt,
tokenType: 'DPoP',
};
manager.setTokens(tokens);
// Persist tokens for subsequent tests — same key pair as `manager`, so
// proofs `manager` signs for these tokens remain valid.
accessToken = tokens!.accessToken;
refreshToken = tokens!.refreshToken ?? '';
manager.setTokens(tokens!);

expect(manager.isLoggedIn).toBe(true);
});
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/DPoP/DPoPManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,29 @@ describe('fetch()', () => {
});
});

describe('getExpiresAt()', () => {
it('returns -1 when no tokens are stored', () => {
const manager = makeManager();
expect(manager.getExpiresAt()).toBe(-1);
});

it('returns the expiresAt of the stored tokens', () => {
const manager = makeManager();
const expiresAt = Date.now() + 60_000;
manager.setTokens(makeTokens({ expiresAt }));
expect(manager.getExpiresAt()).toBe(expiresAt);
});

it('returns -1 after clear()', async () => {
const manager = makeManager();
manager.setTokens(makeTokens());

await manager.clear();

expect(manager.getExpiresAt()).toBe(-1);
});
});

describe('clear()', () => {
it('removes the key pair from DPoPStorage', async () => {
const manager = makeManager();
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/DPoP/DPoPManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ export class DPoPManager {
return this.tokenStore.get()?.refreshToken ?? null;
}

/**
* Returns the expiration moment (ms since epoch) of the stored access
* token, or `-1` if no tokens are stored. Mirrors the `-1` convention used
* by `CookieHelpers.getAccessTokenExpirationMoment()` so `SDKCore` can
* schedule token expiration / auto-refresh identically in both modes.
*/
getExpiresAt(): number | -1 {
return this.tokenStore.get()?.expiresAt ?? -1;
}

/**
* Generates a signed DPoP proof JWT.
*
Expand Down
Loading
Loading