diff --git a/e2e/tests/dpop-smoke.test.ts b/e2e/tests/dpop-smoke.test.ts index 750c762..ee29091 100644 --- a/e2e/tests/dpop-smoke.test.ts +++ b/e2e/tests/dpop-smoke.test.ts @@ -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 \ @@ -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'; @@ -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 = {}; + // @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. @@ -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 = {}; - // @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(() => { @@ -356,6 +365,7 @@ test.describe('DPoP smoke tests', () => { context = await browser.newContext(); page = await context.newPage(); manager = makeManager(); + ensureNodeBrowserPolyfills(); }); test.afterAll(async () => { @@ -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); }); diff --git a/packages/core/src/DPoP/DPoPManager.test.ts b/packages/core/src/DPoP/DPoPManager.test.ts index 6bc6742..5d4b14f 100644 --- a/packages/core/src/DPoP/DPoPManager.test.ts +++ b/packages/core/src/DPoP/DPoPManager.test.ts @@ -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(); diff --git a/packages/core/src/DPoP/DPoPManager.ts b/packages/core/src/DPoP/DPoPManager.ts index fc0c1f6..0477d55 100644 --- a/packages/core/src/DPoP/DPoPManager.ts +++ b/packages/core/src/DPoP/DPoPManager.ts @@ -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. * diff --git a/packages/core/src/SDKCore/SDKCore.test.ts b/packages/core/src/SDKCore/SDKCore.test.ts index 72f490f..109cbdb 100644 --- a/packages/core/src/SDKCore/SDKCore.test.ts +++ b/packages/core/src/SDKCore/SDKCore.test.ts @@ -364,5 +364,319 @@ describe('SDKCore', () => { expect(assignedUrl.searchParams.get('dpop_jkt')).toBeNull(); expect(assignedUrl.searchParams.get('code_challenge')).toBeNull(); }); + + describe('handlePostRedirect() in DPoP mode', () => { + const MOCK_PROOF = 'mock-dpop-proof-jwt'; + const MOCK_CODE = 'mock-authorization-code'; + const MOCK_ACCESS_TOKEN = 'mock-access-token'; + const MOCK_REFRESH_TOKEN = 'mock-refresh-token'; + const EXPIRES_IN_SECONDS = 3600; + + /** Mocks the DPoP key-pair/PKCE steps so `startLogin()` runs without WebCrypto. */ + function mockDpopLoginDependencies() { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'getThumbprint').mockResolvedValue( + MOCK_JKT, + ); + vi.spyOn(Pkce, 'generateCodeVerifier').mockReturnValue(MOCK_VERIFIER); + vi.spyOn(Pkce, 'generateCodeChallenge').mockResolvedValue( + MOCK_CHALLENGE, + ); + } + + function mockTokenResponse( + overrides: Partial<{ + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + }> = {}, + ) { + return vi.spyOn(window, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + access_token: MOCK_ACCESS_TOKEN, + refresh_token: MOCK_REFRESH_TOKEN, + expires_in: EXPIRES_IN_SECONDS, + token_type: 'DPoP', + ...overrides, + }), + { status: 200 }, + ), + ); + } + + /** + * Runs `startLogin()` (with DPoP dependencies mocked) to legitimately + * persist a `code_verifier` via `RedirectHelper`, then simulates landing + * back on the redirect URI with `?code=...` in the query string. + */ + async function primePendingRedirect(core: SDKCore) { + const location = mockWindowLocation(vi); + core.startLogin(); + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledOnce()); + location.search = `?code=${MOCK_CODE}`; + return location; + } + + it('does nothing when there is no code query param', async () => { + mockWindowLocation(vi); // default search — no code + const fetchMock = vi.spyOn(window, 'fetch'); + const core = new SDKCore(dpopConfig); + const onRedirect = vi.fn(); + + core.handlePostRedirect(onRedirect); + await Promise.resolve(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(onRedirect).not.toHaveBeenCalled(); + }); + + it('does nothing when code is present but no code_verifier was persisted', async () => { + mockWindowLocation(vi, `?code=${MOCK_CODE}`); + const fetchMock = vi.spyOn(window, 'fetch'); + const core = new SDKCore(dpopConfig); + const onRedirect = vi.fn(); + + core.handlePostRedirect(onRedirect); + await Promise.resolve(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(onRedirect).not.toHaveBeenCalled(); + }); + + it('exchanges the code with a DPoP header and stores tokens', async () => { + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + + const core = new SDKCore(dpopConfig); + await primePendingRedirect(core); + const fetchMock = mockTokenResponse(); + + const onRedirect = vi.fn(); + core.handlePostRedirect(onRedirect); + await vi.waitFor(() => expect(onRedirect).toHaveBeenCalledOnce()); + + expect(fetchMock).toHaveBeenCalledOnce(); + const call = fetchMock.mock.calls[0]; + if (!call) throw new Error('fetch was not called'); + const [url, init] = call; + expect(new URL(url.toString()).pathname).toBe('/oauth2/token'); + expect(init?.method).toBe('POST'); + + const headers = init?.headers as Record; + expect(headers['DPoP']).toBe(MOCK_PROOF); + expect(headers['Content-Type']).toBe( + 'application/x-www-form-urlencoded', + ); + + const body = new URLSearchParams(init?.body as string); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe(MOCK_CODE); + expect(body.get('code_verifier')).toBe(MOCK_VERIFIER); + expect(body.get('client_id')).toBe(dpopConfig.clientId); + expect(body.get('redirect_uri')).toBe(dpopConfig.redirectUri); + + expect(DPoPManager.prototype.generateProof).toHaveBeenCalledWith( + expect.stringContaining('/oauth2/token'), + 'POST', + ); + + expect(core.isLoggedIn).toBe(true); + }); + + it('invokes the callback with the state persisted by startLogin() and cleans up the redirect marker', async () => { + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + + const core = new SDKCore(dpopConfig); + const location = mockWindowLocation(vi); + core.startLogin('my-post-redirect-state'); + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledOnce()); + location.search = `?code=${MOCK_CODE}`; + mockTokenResponse(); + + const redirectIndicator = () => + localStorage.getItem('fa-sdk-redirect-value'); + expect(redirectIndicator()).not.toBeNull(); + + const onRedirect = vi.fn(); + core.handlePostRedirect(onRedirect); + await vi.waitFor(() => expect(onRedirect).toHaveBeenCalledOnce()); + + expect(onRedirect).toHaveBeenCalledWith('my-post-redirect-state'); + expect(redirectIndicator()).toBeNull(); + }); + + it('strips code and state from the URL via history.replaceState() after a successful exchange', async () => { + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + const replaceState = vi.spyOn(window.history, 'replaceState'); + + const core = new SDKCore(dpopConfig); + const location = mockWindowLocation(vi); + core.startLogin('my-post-redirect-state'); + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledOnce()); + location.search = `?code=${MOCK_CODE}&state=my-post-redirect-state`; + mockTokenResponse(); + + core.handlePostRedirect(); + await vi.waitFor(() => expect(core.isLoggedIn).toBe(true)); + + expect(replaceState).toHaveBeenCalledOnce(); + const [, , url] = replaceState.mock.calls[0]; + const cleanedUrl = new URL(url as string); + expect(cleanedUrl.searchParams.get('code')).toBeNull(); + expect(cleanedUrl.searchParams.get('state')).toBeNull(); + }); + + it('does not throw or report a failure when window is undefined (SSR)', async () => { + // Some framework layers (e.g. Angular's FusionAuthService) call + // handlePostRedirect() unconditionally from their constructor, which + // also runs during SSR — window is not defined in that environment. + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const onLoginFailure = vi.fn(); + const core = new SDKCore({ ...dpopConfig, onLoginFailure }); + const onRedirect = vi.fn(); + + vi.stubGlobal('window', undefined); + try { + core.handlePostRedirect(onRedirect); + await Promise.resolve(); + } finally { + vi.unstubAllGlobals(); + } + + expect(onRedirect).not.toHaveBeenCalled(); + expect(onLoginFailure).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('schedules token expiration from expires_in', async () => { + vi.useFakeTimers(); + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + + const onTokenExpiration = vi.fn(); + const core = new SDKCore({ ...dpopConfig, onTokenExpiration }); + await primePendingRedirect(core); + mockTokenResponse(); + + core.handlePostRedirect(); + await vi.waitFor(() => expect(core.isLoggedIn).toBe(true)); + + vi.advanceTimersByTime(EXPIRES_IN_SECONDS * 1000 - 1000); + expect(onTokenExpiration).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + expect(onTokenExpiration).toHaveBeenCalledTimes(1); + }); + + it('schedules auto-refresh from expires_in when shouldAutoRefresh is true', async () => { + vi.useFakeTimers(); + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + // Avoid an actual (cookie-mode) network call from the real + // refreshToken() — DPoP mode's refreshToken() is implemented in a + // later ticket. We only assert *that* a refresh was + // scheduled and fires at the right time. + const refreshToken = vi + .spyOn(SDKCore.prototype, 'refreshToken') + .mockResolvedValue(new Response(null, { status: 200 })); + + const core = new SDKCore({ + ...dpopConfig, + shouldAutoRefresh: true, + autoRefreshSecondsBeforeExpiry: 60, + }); + await primePendingRedirect(core); + mockTokenResponse(); + + core.handlePostRedirect(); + await vi.waitFor(() => expect(core.isLoggedIn).toBe(true)); + + // Refresh fires 60s before the 3600s expiry, i.e. at 3540s. + vi.advanceTimersByTime((EXPIRES_IN_SECONDS - 60) * 1000 - 1000); + expect(refreshToken).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + expect(refreshToken).toHaveBeenCalledTimes(1); + }); + + it('does not schedule auto-refresh when shouldAutoRefresh is not set', async () => { + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + const refreshToken = vi.spyOn(SDKCore.prototype, 'refreshToken'); + + const core = new SDKCore(dpopConfig); // shouldAutoRefresh defaults to false + await primePendingRedirect(core); + mockTokenResponse(); + + const onRedirect = vi.fn(); + core.handlePostRedirect(onRedirect); + await vi.waitFor(() => expect(onRedirect).toHaveBeenCalledOnce()); + + expect(refreshToken).not.toHaveBeenCalled(); + }); + + it('reports an exchange failure via onLoginFailure', async () => { + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + + const onLoginFailure = vi.fn(); + const core = new SDKCore({ ...dpopConfig, onLoginFailure }); + await primePendingRedirect(core); + vi.spyOn(window, 'fetch').mockResolvedValue( + new Response('invalid_grant', { status: 400 }), + ); + + core.handlePostRedirect(); + await vi.waitFor(() => expect(onLoginFailure).toHaveBeenCalledOnce()); + + expect(core.isLoggedIn).toBe(false); + }); + + it('falls back to console.error when an exchange failure occurs and onLoginFailure is not configured', async () => { + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const core = new SDKCore(dpopConfig); // no onLoginFailure configured + await primePendingRedirect(core); + vi.spyOn(window, 'fetch').mockResolvedValue( + new Response('invalid_grant', { status: 400 }), + ); + + core.handlePostRedirect(); + await vi.waitFor(() => + expect(consoleError).toHaveBeenCalledWith( + 'FusionAuth SDK: handlePostRedirect failed', + expect.any(Error), + ), + ); + }); + }); }); }); diff --git a/packages/core/src/SDKCore/SDKCore.ts b/packages/core/src/SDKCore/SDKCore.ts index 3a9a411..2884ff6 100644 --- a/packages/core/src/SDKCore/SDKCore.ts +++ b/packages/core/src/SDKCore/SDKCore.ts @@ -66,8 +66,7 @@ export class SDKCore { * (or `console.error` if not configured) rather than becoming an unhandled * promise rejection. * - * In cookie mode: behaves identically to the previous implementation — - * delegates to the Hosted Backend API, fully synchronously. + * In cookie mode: delegates to the Hosted Backend API, fully synchronously. * * @param state Optional OAuth2 state value echoed back post-login. */ @@ -198,12 +197,121 @@ export class SDKCore { clearTimeout(this.refreshTokenTimeout); } - handlePostRedirect(callback?: (state?: string) => void) { + /** + * Handles the return trip from a login/register redirect. + * + * In DPoP mode (`useDpop: true`), this synchronously returns after + * kicking off an async chain, otherwise continue using the Hosted + * Backend API. + */ + handlePostRedirect(callback?: (state?: string) => void): void { + if (this.dpopManager) { + this.handleDpopPostRedirect(callback).catch(error => { + if (this.config.onLoginFailure) { + this.config.onLoginFailure(error as Error); + } else { + console.error('FusionAuth SDK: handlePostRedirect failed', error); + } + }); + return; + } + if (this.isLoggedIn) { this.redirectHelper.handlePostRedirect(callback); } } + /** + * Performs the DPoP-mode authorization code exchange. The full + * step-by-step description: + * + * 1. Detects the `code` query parameter on the current URL. + * 2. Retrieves the persisted PKCE `code_verifier`. + * 3. Exchanges the code for tokens at FusionAuth's `/oauth2/token`, + * signing the request with a DPoP proof. + * 4. Stores the returned tokens via `DPoPManager.setTokens()`. + * 5. Schedules token expiration and (if `shouldAutoRefresh`) auto-refresh + * from the tokens' `expiresAt`. + * 6. Invokes `callback` with the `state` value and cleans up the + * redirect marker, via `RedirectHelper.handlePostRedirect()`. + */ + private async handleDpopPostRedirect( + callback?: (state?: string) => void, + ): Promise { + // SSR/non-browser guard + if (typeof window === 'undefined') { + return; + } + + const code = new URLSearchParams(window.location.search).get('code'); + const codeVerifier = this.redirectHelper.getCodeVerifier(); + + // No pending exchange + if (!code || !codeVerifier) { + return; + } + + const tokenUrl = this.urlHelper.getTokenUrl(); + const proof = await this.dpopManager!.generateProof( + tokenUrl.toString(), + 'POST', + ); + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + code_verifier: codeVerifier, + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + }); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: proof, + }, + body: body.toString(), + }); + + if (!response.ok) { + const errorDetails = { + status: response.status, + details: + (await response.text()) || + 'Failed to exchange authorization code for tokens', + }; + throw new Error(JSON.stringify(errorDetails)); + } + + const tokenResponse = await response.json(); + this.dpopManager!.setTokens({ + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresAt: Date.now() + tokenResponse.expires_in * 1000, + tokenType: 'DPoP', + }); + + this.clearRedirectQueryParams(); + this.scheduleTokenExpiration(); + if (this.config.shouldAutoRefresh) { + this.initAutoRefresh(); + } + + this.redirectHelper.handlePostRedirect(callback); + } + + /** + * Removes `code` and `state` from the current URL for security. + */ + private clearRedirectQueryParams(): void { + const { origin, pathname, search, hash } = window.location; + const url = new URL(`${origin}${pathname}${search}${hash}`); + url.searchParams.delete('code'); + url.searchParams.delete('state'); + window.history.replaceState(null, '', url.toString()); + } + /** * Whether the user is currently logged in. * @@ -218,8 +326,16 @@ export class SDKCore { return this.at_exp > new Date().getTime(); } - /** The moment of access token expiration in milliseconds since epoch. */ + /** + * The moment of access token expiration in milliseconds since epoch. + * + * - DPoP mode: delegates to `DPoPManager.getExpiresAt()`. + * - Cookie mode: reads the `app.at_exp` cookie (existing behavior). + */ private get at_exp(): number | -1 { + if (this.dpopManager) { + return this.dpopManager.getExpiresAt(); + } return getAccessTokenExpirationMoment( this.config.accessTokenExpireCookieName, this.config.cookieAdapter, diff --git a/packages/core/src/UrlHelper/UrlHelper.test.ts b/packages/core/src/UrlHelper/UrlHelper.test.ts index a239180..365820b 100644 --- a/packages/core/src/UrlHelper/UrlHelper.test.ts +++ b/packages/core/src/UrlHelper/UrlHelper.test.ts @@ -184,6 +184,19 @@ describe('UrlHelper', () => { expect(registerUrl.pathname).toBe('/app/register/'); }); }); + + describe('getTokenUrl', () => { + it('targets the FusionAuth /oauth2/token endpoint directly', () => { + const tokenUrl = urlHelper.getTokenUrl(); + expect(tokenUrl.origin).toBe(config.serverUrl); + expect(tokenUrl.pathname).toBe('/oauth2/token'); + }); + + it('has no query params — request params are sent in the POST body', () => { + const tokenUrl = urlHelper.getTokenUrl(); + expect(tokenUrl.search).toBe(''); + }); + }); }); function getAllUrls(urlHelper: UrlHelper) { diff --git a/packages/core/src/UrlHelper/UrlHelper.ts b/packages/core/src/UrlHelper/UrlHelper.ts index ab0bac5..ebae7d6 100644 --- a/packages/core/src/UrlHelper/UrlHelper.ts +++ b/packages/core/src/UrlHelper/UrlHelper.ts @@ -94,6 +94,17 @@ export class UrlHelper { }); } + /** + * Builds the direct `/oauth2/token` URL used in DPoP mode for the + * authorization code exchange and refresh token grant. Targets FusionAuth + * directly (not the Hosted Backend API). Request parameters are sent in + * the POST body (form-urlencoded), not the query string, so no params are + * appended here. + */ + getTokenUrl(): URL { + return this.generateUrl('/oauth2/token'); + } + private generateUrl(path: string, params?: UrlHelperQueryParams): URL { const url = new URL(this.serverUrl); url.pathname = path; diff --git a/packages/core/src/testUtils/mockWindowLocation.ts b/packages/core/src/testUtils/mockWindowLocation.ts index 9608592..f3ce4cc 100644 --- a/packages/core/src/testUtils/mockWindowLocation.ts +++ b/packages/core/src/testUtils/mockWindowLocation.ts @@ -1,8 +1,17 @@ import { VitestUtils } from 'vitest'; -function mockWindowLocation(vi: VitestUtils) { +/** + * Mocks `window.location`, stubbing `assign()` so redirects can be asserted + * on without actually navigating. + * + * @param search Optional query string (e.g. `'?code=abc123'`) to simulate + * landing on a redirect URI that carries an authorization + * `code`. Defaults to the current `window.location.search`. + */ +function mockWindowLocation(vi: VitestUtils, search?: string) { const mockedLocation = { ...window.location, + ...(search !== undefined ? { search } : {}), assign: vi.fn(), }; vi.spyOn(window, 'location', 'get').mockReturnValue(mockedLocation);