diff --git a/e2e/tests/dpop-smoke.test.ts b/e2e/tests/dpop-smoke.test.ts index 77da50a..a41c48b 100644 --- a/e2e/tests/dpop-smoke.test.ts +++ b/e2e/tests/dpop-smoke.test.ts @@ -1,17 +1,20 @@ /** - * DPoP Smoke Tests — pre-SDKCore wiring + * DPoP Smoke Tests — pre-SDKCore wiring + SDKCore.startLogin() integration * - * 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. + * Tier 0 (new): 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. + * + * Tier 1–2 (existing): 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 \ * --config playwright.dpop.config.ts * - * Prerequisites: + * Prerequisites (Tier 1–2 only): * - FusionAuth Enterprise instance running at http://localhost:9011 * - Application baf3d520-40d7-4000-9b62-e6a7d0091102 configured with: * proofKeyForCodeExchangePolicy: Required @@ -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 @@ -54,24 +63,6 @@ function decodeJwt(jwt: string): Record { ); } -/** 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. @@ -79,6 +70,44 @@ function makeManager(): DPoPManager { 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 lets Tier 0 tests wait + * 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; +} { + let resolveUrl!: (url: string) => void; + const urlPromise = new Promise(resolve => { + resolveUrl = resolve; + }); + + return { + assign: (url: string) => resolveUrl(url), + waitForUrl: () => + Promise.race([ + urlPromise, + new Promise((_, 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. @@ -158,7 +187,168 @@ async function loginAndCaptureCode( } // --------------------------------------------------------------------------- -// Tests +// Tier 0 — SDKCore.startLogin() DPoP mode (no live FusionAuth required) +// --------------------------------------------------------------------------- + +test.describe('Tier 0: SDKCore.startLogin() DPoP mode', () => { + // SDKConfig used by every T0 test. The cookieAdapter returning undefined + // prevents SDKCore from calling document.cookie (which doesn't exist in the + // Node/Playwright process) and eliminates the "Error accessing cookies" + // console.error noise from CookieHelpers.getAccessTokenExpirationMoment(). + 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 = {}; + // @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('T0-1: 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('T0-2: 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('T0-3: 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'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Tier 1 — Authorization code flow // --------------------------------------------------------------------------- test.describe('DPoP smoke tests', () => { @@ -191,7 +381,8 @@ test.describe('DPoP smoke tests', () => { test('T1-1: 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, @@ -213,7 +404,8 @@ test.describe('DPoP smoke tests', () => { }); test('T1-2: 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({ @@ -437,6 +629,88 @@ test.describe('DPoP smoke tests', () => { } }); + test('T2-4: 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"). T2-3 above can only assert + // structurally against a real FusionAuth endpoint because it can never + // reliably force the challenge. + // + // 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 faithful substitute for a real RS response). + // It uses its own fresh DPoPManager so it does not depend on shared + // state/order from the Tier 1 tests above. + + 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 => { + 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; + } + }); + // ------------------------------------------------------------------------- // Tier 1 — Logout / clear // ------------------------------------------------------------------------- diff --git a/packages/core/src/Pkce/Pkce.test.ts b/packages/core/src/Pkce/Pkce.test.ts new file mode 100644 index 0000000..2ac09b6 --- /dev/null +++ b/packages/core/src/Pkce/Pkce.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment node +// crypto.subtle.digest requires a spec-compliant SubtleCrypto implementation. +// The jsdom environment's crypto only provides getRandomValues/randomUUID. +// The node environment has a full WebCrypto implementation, matching the +// browser target for which this code is written. + +import { describe, it, expect } from 'vitest'; +import { generateCodeVerifier, generateCodeChallenge } from './Pkce'; + +// --------------------------------------------------------------------------- +// RFC 7636 Appendix B test vector +// verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk +// challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM +// (Same vector referenced in UrlHelper.test.ts) +// --------------------------------------------------------------------------- +const RFC_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; +const RFC_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + +describe('Pkce', () => { + describe('generateCodeVerifier', () => { + it('returns a base64url string of 43 characters (32 bytes encoded)', () => { + const verifier = generateCodeVerifier(); + expect(verifier).toHaveLength(43); + }); + + it('uses only base64url characters [A-Za-z0-9\\-_]', () => { + const verifier = generateCodeVerifier(); + expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/); + }); + + it('contains no base64 padding characters', () => { + const verifier = generateCodeVerifier(); + expect(verifier).not.toContain('='); + }); + + it('produces a different value on each call (collision-resistant)', () => { + const a = generateCodeVerifier(); + const b = generateCodeVerifier(); + expect(a).not.toBe(b); + }); + }); + + describe('generateCodeChallenge', () => { + it('matches the RFC 7636 Appendix B test vector', async () => { + const challenge = await generateCodeChallenge(RFC_VERIFIER); + expect(challenge).toBe(RFC_CHALLENGE); + }); + + it('returns a base64url string with no padding', async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/); + expect(challenge).not.toContain('='); + }); + + it('returns a 43-character string (SHA-256 → 32 bytes → base64url)', async () => { + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + expect(challenge).toHaveLength(43); + }); + + it('produces the same challenge for the same verifier (deterministic)', async () => { + const verifier = generateCodeVerifier(); + const a = await generateCodeChallenge(verifier); + const b = await generateCodeChallenge(verifier); + expect(a).toBe(b); + }); + + it('produces different challenges for different verifiers', async () => { + const a = await generateCodeChallenge(generateCodeVerifier()); + const b = await generateCodeChallenge(generateCodeVerifier()); + expect(a).not.toBe(b); + }); + }); +}); diff --git a/packages/core/src/Pkce/Pkce.ts b/packages/core/src/Pkce/Pkce.ts new file mode 100644 index 0000000..fc05cbb --- /dev/null +++ b/packages/core/src/Pkce/Pkce.ts @@ -0,0 +1,51 @@ +/** + * PKCE (Proof Key for Code Exchange) utilities — RFC 7636. + * + * Uses the bare global `crypto` object (available as `globalThis.crypto` in + * browsers, Node 19+, and the vitest node environment) rather than + * `window.crypto` so that both browser and non-browser environments can call + * these functions without mocking. + */ + +/** + * Generates a cryptographically random PKCE `code_verifier`. + * + * The verifier is 32 random bytes encoded as base64url (43 characters), + * satisfying RFC 7636 §4.1 (43–128 characters from the unreserved character + * set `[A-Z a-z 0-9 - . _ ~]`). + */ +export function generateCodeVerifier(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64urlEncode(array); +} + +/** + * Computes the PKCE `code_challenge` from a `code_verifier`. + * + * `code_challenge = BASE64URL(SHA-256(ASCII(code_verifier)))` — RFC 7636 §4.2. + * + * @param verifier A `code_verifier` generated by {@link generateCodeVerifier}. + */ +export async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return base64urlEncode(new Uint8Array(hash)); +} + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/** Encodes a `Uint8Array` as a base64url string (no padding). */ +function base64urlEncode(bytes: Uint8Array): string { + // Convert bytes to a binary string, then btoa, then convert to base64url. + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} diff --git a/packages/core/src/Pkce/index.ts b/packages/core/src/Pkce/index.ts new file mode 100644 index 0000000..5b82d5c --- /dev/null +++ b/packages/core/src/Pkce/index.ts @@ -0,0 +1 @@ +export * from './Pkce'; diff --git a/packages/core/src/RedirectHelper/RedirectHelper.test.ts b/packages/core/src/RedirectHelper/RedirectHelper.test.ts new file mode 100644 index 0000000..21af7d6 --- /dev/null +++ b/packages/core/src/RedirectHelper/RedirectHelper.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RedirectHelper } from './RedirectHelper'; + +describe('RedirectHelper', () => { + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------- + // Cookie-mode (non-DPoP) — backward-compatibility + // --------------------------------------------------------------------------- + + describe('handlePreRedirect / handlePostRedirect (cookie mode)', () => { + it('stores a redirect marker in localStorage', () => { + const helper = new RedirectHelper(); + expect(localStorage.getItem('fa-sdk-redirect-value')).toBeNull(); + + helper.handlePreRedirect(); + expect(localStorage.getItem('fa-sdk-redirect-value')).not.toBeNull(); + }); + + it('invokes the callback with undefined when no state was provided', () => { + const helper = new RedirectHelper(); + const callback = vi.fn(); + + helper.handlePreRedirect(); + helper.handlePostRedirect(callback); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith(undefined); + }); + + it('invokes the callback with the stored state', () => { + const helper = new RedirectHelper(); + const callback = vi.fn(); + + helper.handlePreRedirect('my-state'); + helper.handlePostRedirect(callback); + + expect(callback).toHaveBeenCalledWith('my-state'); + }); + + it('preserves state values that contain colons', () => { + const helper = new RedirectHelper(); + const callback = vi.fn(); + const stateWithColons = 'return:/dashboard?tab=2'; + + helper.handlePreRedirect(stateWithColons); + helper.handlePostRedirect(callback); + + expect(callback).toHaveBeenCalledWith(stateWithColons); + }); + + it('removes the redirect marker from localStorage after post-redirect', () => { + const helper = new RedirectHelper(); + + helper.handlePreRedirect('some-state'); + expect(localStorage.getItem('fa-sdk-redirect-value')).not.toBeNull(); + + helper.handlePostRedirect(); + expect(localStorage.getItem('fa-sdk-redirect-value')).toBeNull(); + }); + + it('does not invoke callback when no redirect was initiated', () => { + const helper = new RedirectHelper(); + const callback = vi.fn(); + + helper.handlePostRedirect(callback); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not throw when no callback is provided to handlePostRedirect', () => { + const helper = new RedirectHelper(); + helper.handlePreRedirect('state'); + expect(() => helper.handlePostRedirect()).not.toThrow(); + }); + }); + + // --------------------------------------------------------------------------- + // DPoP mode — code_verifier persistence + // --------------------------------------------------------------------------- + + describe('handlePreRedirect with codeVerifier (DPoP mode)', () => { + it('persists the code_verifier and returns it via getCodeVerifier()', () => { + const helper = new RedirectHelper(); + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + + helper.handlePreRedirect(undefined, verifier); + + expect(helper.getCodeVerifier()).toBe(verifier); + }); + + it('persists both code_verifier and state; both are retrievable', () => { + const helper = new RedirectHelper(); + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const state = 'my-state'; + const callback = vi.fn(); + + helper.handlePreRedirect(state, verifier); + + expect(helper.getCodeVerifier()).toBe(verifier); + helper.handlePostRedirect(callback); + expect(callback).toHaveBeenCalledWith(state); + }); + + it('preserves state with colons alongside a code_verifier', () => { + const helper = new RedirectHelper(); + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const stateWithColons = 'return:/dashboard?tab=2'; + const callback = vi.fn(); + + helper.handlePreRedirect(stateWithColons, verifier); + + expect(helper.getCodeVerifier()).toBe(verifier); + helper.handlePostRedirect(callback); + expect(callback).toHaveBeenCalledWith(stateWithColons); + }); + + it('returns undefined from getCodeVerifier() when no redirect was initiated', () => { + const helper = new RedirectHelper(); + expect(helper.getCodeVerifier()).toBeUndefined(); + }); + + it('returns undefined from getCodeVerifier() when no verifier was stored (cookie mode)', () => { + const helper = new RedirectHelper(); + helper.handlePreRedirect('some-state'); + expect(helper.getCodeVerifier()).toBeUndefined(); + }); + + it('getCodeVerifier() still works after a handlePostRedirect call clears storage', () => { + const helper = new RedirectHelper(); + helper.handlePreRedirect('state', 'verifier-value'); + + // Post-redirect removes the marker. + helper.handlePostRedirect(); + + // After cleanup, getCodeVerifier() should return undefined. + expect(helper.getCodeVerifier()).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // Legacy 2-segment format (nonce:state) backward-compatibility + // + // Pre-DPoP SDK versions wrote `${nonce}:${state}` (2 segments) instead of + // the current `${nonce}:${codeVerifier}:${state}` (3 segments). A value in + // this legacy format can still be sitting in localStorage if a user + // initiates a login redirect on an older SDK version and the app is + // upgraded to a newer version before they land back (e.g. a deploy that + // happens while they're on FusionAuth's hosted login page). These tests + // seed localStorage directly with the legacy format to simulate that. + // --------------------------------------------------------------------------- + + describe('legacy 2-segment format backward-compatibility', () => { + it('handlePostRedirect() invokes the callback with the legacy state value', () => { + const helper = new RedirectHelper(); + const callback = vi.fn(); + + localStorage.setItem( + 'fa-sdk-redirect-value', + 'legacy-nonce:legacy-state', + ); + helper.handlePostRedirect(callback); + + expect(callback).toHaveBeenCalledWith('legacy-state'); + }); + + it('handlePostRedirect() invokes the callback with undefined for an empty legacy state', () => { + const helper = new RedirectHelper(); + const callback = vi.fn(); + + localStorage.setItem('fa-sdk-redirect-value', 'legacy-nonce:'); + helper.handlePostRedirect(callback); + + expect(callback).toHaveBeenCalledWith(undefined); + }); + + it('removes the legacy redirect marker from localStorage after post-redirect', () => { + localStorage.setItem( + 'fa-sdk-redirect-value', + 'legacy-nonce:legacy-state', + ); + + new RedirectHelper().handlePostRedirect(); + + expect(localStorage.getItem('fa-sdk-redirect-value')).toBeNull(); + }); + + it('getCodeVerifier() returns undefined for a legacy value (never carried a verifier)', () => { + localStorage.setItem( + 'fa-sdk-redirect-value', + 'legacy-nonce:legacy-state', + ); + + expect(new RedirectHelper().getCodeVerifier()).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/RedirectHelper/RedirectHelper.ts b/packages/core/src/RedirectHelper/RedirectHelper.ts index b2e9323..a0c25f1 100644 --- a/packages/core/src/RedirectHelper/RedirectHelper.ts +++ b/packages/core/src/RedirectHelper/RedirectHelper.ts @@ -1,6 +1,20 @@ -/** A class responsible for storing a redirect value in localStorage and cleanup afterward. */ +/** + * A class responsible for storing pre-redirect values in localStorage and + * cleaning them up afterward. + * + * Storage format: `${randomNonce}:${codeVerifier ?? ''}:${state ?? ''}` + * + * - `randomNonce` — prevents replay; marks that a redirect was initiated. + * - `codeVerifier` — PKCE `code_verifier` persisted for DPoP token exchange. + * Empty string when not in DPoP mode. + * - `state` — optional caller-supplied OAuth2 state value. May contain + * colons; retrieved by skipping the nonce and codeVerifier + * segments (indices 0 and 1) and joining all remaining + * segments (index 2 onward) with `:`. + */ export class RedirectHelper { private readonly REDIRECT_VALUE = 'fa-sdk-redirect-value'; + private get storage(): Storage { try { return localStorage; @@ -9,15 +23,24 @@ export class RedirectHelper { return { /* eslint-disable */ setItem(_key: string, _value: string) {}, - getItem(_key: string) {}, + getItem(_key: string) { + return null; + }, removeItem(_key: string) {}, /* eslint-enable */ } as Storage; } } - handlePreRedirect(state?: string) { - const valueForStorage = `${this.generateRandomString()}:${state ?? ''}`; + /** + * Persists a redirect marker, an optional PKCE `code_verifier`, and an + * optional `state` value to localStorage before a redirect is initiated. + * + * @param state Optional OAuth2 state string echoed back post-login. + * @param codeVerifier Optional PKCE `code_verifier` (DPoP mode only). + */ + handlePreRedirect(state?: string, codeVerifier?: string) { + const valueForStorage = `${this.generateRandomString()}:${codeVerifier ?? ''}:${state ?? ''}`; this.storage.setItem(this.REDIRECT_VALUE, valueForStorage); } @@ -32,15 +55,63 @@ export class RedirectHelper { this.storage.removeItem(this.REDIRECT_VALUE); } + /** + * Returns the PKCE `code_verifier` that was persisted by + * {@link handlePreRedirect}, or `undefined` if none was stored (cookie mode) + * or if no redirect has been initiated. + */ + getCodeVerifier(): string | undefined { + const raw = this.storage.getItem(this.REDIRECT_VALUE); + if (!raw) return undefined; + + // Legacy 2-segment format (nonce:state) from pre-DPoP SDK versions never + // carried a code_verifier. Guard against misreading a fragment of a + // legacy state value as a verifier — see the `state` getter below for the + // full rationale of this legacy-format detection. + if (raw.split(':').length === 2) return undefined; + + // Format: randomNonce:codeVerifier:state + const firstColon = raw.indexOf(':'); + if (firstColon === -1) return undefined; + + const afterNonce = raw.slice(firstColon + 1); + const secondColon = afterNonce.indexOf(':'); + if (secondColon === -1) return undefined; + + const verifier = afterNonce.slice(0, secondColon); + return verifier || undefined; + } + private get state() { const redirectValue = this.storage.getItem(this.REDIRECT_VALUE); + if (!redirectValue) return undefined; - if (!redirectValue) { - return; + const segments = redirectValue.split(':'); + + // Legacy 2-segment format (nonce:state) from pre-DPoP SDK versions. + // Exactly one colon can only be this legacy format — the current writer + // always produces at least two colons (it always includes a + // codeVerifier segment, even when empty). This preserves `state` for + // users who initiate a login redirect on an older SDK version and land + // back on a newer one (e.g. an app deploy that happens while they're on + // FusionAuth's hosted login page). + // + // Known limitation: if a legacy state value itself contained a colon + // (e.g. old state "return:/page" stored as "nonce:return:/page"), it is + // indistinguishable from a current-format value with a non-empty + // codeVerifier and simple state. This ambiguity is inherent to a + // delimiter-based format without a version marker and is accepted as an + // edge case, given the narrow window (a single redirect round-trip) and + // that state values are typically simple opaque strings/paths. + if (segments.length === 2) { + return segments[1] || undefined; } - const [, ...stateValue] = redirectValue.split(':'); - return stateValue.join(':') || undefined; + // Current format: randomNonce:codeVerifier:state + // Skip the nonce segment and the codeVerifier segment; join remainder with + // ':' to correctly reconstruct state values that themselves contain colons. + const [, , ...stateSegments] = segments; + return stateSegments.join(':') || undefined; } private generateRandomString() { diff --git a/packages/core/src/SDKConfig/SDKConfig.ts b/packages/core/src/SDKConfig/SDKConfig.ts index d2a0eed..f4797a7 100644 --- a/packages/core/src/SDKConfig/SDKConfig.ts +++ b/packages/core/src/SDKConfig/SDKConfig.ts @@ -112,4 +112,14 @@ export interface SDKConfig { * Defaults to `'localStorage'`. */ dpopTokenStorage?: 'localStorage' | 'memory'; + + /** + * Callback invoked if `startLogin()` fails in DPoP mode (e.g. the DPoP key + * pair could not be generated/loaded, or PKCE parameter generation + * failed). `startLogin()` is synchronous (`void`), so this is the only way + * to observe an async failure in the DPoP login flow. Defaults to logging + * the error via `console.error` if not provided. Only relevant when + * `useDpop: true`. + */ + onLoginFailure?: (error: Error) => void; } diff --git a/packages/core/src/SDKCore/SDKCore.test.ts b/packages/core/src/SDKCore/SDKCore.test.ts index a427fd3..71b104b 100644 --- a/packages/core/src/SDKCore/SDKCore.test.ts +++ b/packages/core/src/SDKCore/SDKCore.test.ts @@ -1,8 +1,16 @@ +// @vitest-environment jsdom +// SDKCore uses document.cookie (via CookieHelpers), window.location.assign, +// and localStorage — all browser-only globals that jsdom provides. +// This annotation is explicit so that importing DPoPManager (which has its own +// @vitest-environment node override) does not cause vitest to run this file in +// the node environment when the full test suite is executed together. import { afterEach, describe, it, expect, vi } from 'vitest'; import { SDKConfig } from '../SDKConfig'; import { SDKCore } from '.'; import { RedirectHelper } from '../RedirectHelper'; +import { DPoPManager } from '../DPoP'; +import * as Pkce from '../Pkce'; import { mockIsLoggedIn, mockWindowLocation, removeAt_expCookie } from '..'; @@ -131,6 +139,8 @@ describe('SDKCore', () => { expect(handlePreRedirect).toHaveBeenCalledTimes(0); + // startLogin is synchronous in cookie mode — side-effects + // (handlePreRedirect, window.location.assign) fire immediately. core.startLogin('/login'); core.startRegister(); @@ -165,4 +175,207 @@ describe('SDKCore', () => { expect(onRedirect).not.toHaveBeenCalled(); }); + + // --------------------------------------------------------------------------- + // DPoP mode + // + // DPoPManager methods and Pkce functions are mocked here because jsdom's + // crypto implementation lacks `crypto.subtle`, which is required for both + // DPoP key-pair generation and PKCE SHA-256 challenge derivation. The + // per-module unit tests (DPoPManager.test.ts, Pkce.test.ts) run under + // @vitest-environment node where real WebCrypto is available and verify the + // cryptographic correctness of those operations. + // --------------------------------------------------------------------------- + + describe('DPoP mode', () => { + const MOCK_JKT = 'mock-dpop-jkt-thumbprint'; + const MOCK_VERIFIER = 'mock-code-verifier-43-chars-xxxxxxxxxxxxxxxx'; + const MOCK_CHALLENGE = 'mock-code-challenge-43-chars-xxxxxxxxxxxx'; + + const dpopConfig: SDKConfig = { + ...config, + useDpop: true, + serverUrl: 'http://my-fusionauth-server', + }; + + it('constructs a DPoPManager when useDpop is true', () => { + // The constructor call itself is the assertion: if it throws, the test + // fails. We also verify the DPoP-mode isLoggedIn path is used (not cookie). + 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); + + // DPoPManager.isLoggedIn returns false by default (no tokens stored). + const core = new SDKCore(dpopConfig); + expect(core.isLoggedIn).toBe(false); + }); + + it('does not construct a DPoPManager when useDpop is false (default)', () => { + // isLoggedIn should fall back to cookie path (no tokens, no cookie → false) + const core = new SDKCore(config); + expect(core.isLoggedIn).toBe(false); + }); + + it('isLoggedIn reads from DPoPTokenStore (not app.at_exp cookie) in DPoP mode', () => { + // Cookie is present but DPoP mode should NOT consult it. + mockIsLoggedIn(); // sets app.at_exp cookie → cookie-mode isLoggedIn = true + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + + const core = new SDKCore(dpopConfig); + + // DPoP tokens are not stored, so isLoggedIn is false even though the + // app.at_exp cookie says the user is logged in. + expect(core.isLoggedIn).toBe(false); + }); + + it('startLogin() in DPoP mode redirects to /oauth2/authorize with dpop_jkt and code_challenge', async () => { + 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); + const location = mockWindowLocation(vi); + + const core = new SDKCore(dpopConfig); + // startLogin() is synchronous (void) — the DPoP chain runs async + // internally. Wait for the redirect to happen before asserting. + core.startLogin(); + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledOnce()); + + const assignedUrl = new URL( + (location.assign as ReturnType).mock.calls[0][0], + ); + expect(assignedUrl.pathname).toBe('/oauth2/authorize'); + expect(assignedUrl.searchParams.get('dpop_jkt')).toBe(MOCK_JKT); + expect(assignedUrl.searchParams.get('code_challenge')).toBe( + MOCK_CHALLENGE, + ); + expect(assignedUrl.searchParams.get('code_challenge_method')).toBe( + 'S256', + ); + expect(assignedUrl.searchParams.get('response_type')).toBe('code'); + }); + + it('startLogin() in DPoP mode persists code_verifier via RedirectHelper', async () => { + 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); + const location = mockWindowLocation(vi); + + const core = new SDKCore(dpopConfig); + core.startLogin(); + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledOnce()); + + const redirectHelper = new RedirectHelper(); + expect(redirectHelper.getCodeVerifier()).toBe(MOCK_VERIFIER); + }); + + it('startLogin() in DPoP mode includes state in the authorize URL', async () => { + 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); + const location = mockWindowLocation(vi); + + const core = new SDKCore(dpopConfig); + core.startLogin('my-state'); + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledOnce()); + + const assignedUrl = new URL( + (location.assign as ReturnType).mock.calls[0][0], + ); + expect(assignedUrl.searchParams.get('state')).toBe('my-state'); + }); + + it('startLogin() in DPoP mode calls getOrCreateKeyPair and getThumbprint', async () => { + const getOrCreateKeyPair = vi + .spyOn(DPoPManager.prototype, 'getOrCreateKeyPair') + .mockResolvedValue({} as any); + const getThumbprint = vi + .spyOn(DPoPManager.prototype, 'getThumbprint') + .mockResolvedValue(MOCK_JKT); + vi.spyOn(Pkce, 'generateCodeVerifier').mockReturnValue(MOCK_VERIFIER); + vi.spyOn(Pkce, 'generateCodeChallenge').mockResolvedValue(MOCK_CHALLENGE); + const location = mockWindowLocation(vi); + + const core = new SDKCore(dpopConfig); + core.startLogin(); + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledOnce()); + + expect(getOrCreateKeyPair).toHaveBeenCalledOnce(); + expect(getThumbprint).toHaveBeenCalledOnce(); + }); + + it('reports a DPoP startLogin() failure via onLoginFailure instead of an unhandled rejection', async () => { + const failure = new Error('crypto.subtle unavailable'); + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockRejectedValue( + failure, + ); + mockWindowLocation(vi); + + const onLoginFailure = vi.fn(); + const core = new SDKCore({ ...dpopConfig, onLoginFailure }); + + core.startLogin(); + await vi.waitFor(() => expect(onLoginFailure).toHaveBeenCalledOnce()); + + expect(onLoginFailure).toHaveBeenCalledWith(failure); + }); + + it('falls back to console.error when a DPoP startLogin() failure occurs and onLoginFailure is not configured', async () => { + const failure = new Error('IndexedDB blocked'); + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockRejectedValue( + failure, + ); + mockWindowLocation(vi); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const core = new SDKCore(dpopConfig); // no onLoginFailure configured + core.startLogin(); + await vi.waitFor(() => + expect(consoleError).toHaveBeenCalledWith( + 'FusionAuth SDK: startLogin failed', + failure, + ), + ); + }); + + it('startLogin() in cookie mode is unaffected by useDpop: false', () => { + const location = mockWindowLocation(vi); + const core = new SDKCore(config); // no useDpop + + // Cookie mode is fully synchronous — no waiting required. + core.startLogin('some-state'); + + expect(location.assign).toHaveBeenCalledOnce(); + const assignedUrl = new URL( + (location.assign as ReturnType).mock.calls[0][0], + ); + // Cookie mode uses the app server login path, not /oauth2/authorize. + expect(assignedUrl.pathname).not.toBe('/oauth2/authorize'); + expect(assignedUrl.searchParams.get('dpop_jkt')).toBeNull(); + expect(assignedUrl.searchParams.get('code_challenge')).toBeNull(); + }); + }); }); diff --git a/packages/core/src/SDKCore/SDKCore.ts b/packages/core/src/SDKCore/SDKCore.ts index f8629a4..3a9a411 100644 --- a/packages/core/src/SDKCore/SDKCore.ts +++ b/packages/core/src/SDKCore/SDKCore.ts @@ -3,6 +3,8 @@ import { SDKConfig } from '../SDKConfig'; import { UserInfo } from '../SDKContext'; import { RedirectHelper } from '../RedirectHelper'; import { getAccessTokenExpirationMoment } from '../CookieHelpers'; +import { DPoPManager } from '../DPoP'; +import * as Pkce from '../Pkce'; /** A class containing framework-agnostic SDK methods */ export class SDKCore { @@ -12,6 +14,7 @@ export class SDKCore { private tokenExpirationTimeout?: NodeJS.Timeout; private refreshTokenTimeout?: NodeJS.Timeout; private isDisposed = false; + private dpopManager?: DPoPManager; constructor(config: SDKConfig) { this.config = config; @@ -28,6 +31,14 @@ export class SDKCore { tokenRefreshPath: config.tokenRefreshPath, postLogoutRedirectUri: config.postLogoutRedirectUri, }); + + if (config.useDpop) { + this.dpopManager = new DPoPManager( + config.clientId, + config.dpopTokenStorage, + ); + } + this.scheduleTokenExpiration(); } @@ -37,11 +48,63 @@ export class SDKCore { this.isDisposed = true; } - startLogin(state?: string) { + /** + * Initiates the login flow. + * + * In DPoP mode (`useDpop: true`), this synchronously returns after kicking + * off an async chain that: + * 1. Loads or generates the DPoP key pair. + * 2. Computes `dpop_jkt` (JWK SHA-256 thumbprint of the public key). + * 3. Generates a PKCE `code_verifier` and derives `code_challenge`. + * 4. Persists `code_verifier` via `RedirectHelper` for later token exchange. + * 5. Redirects to FusionAuth `/oauth2/authorize` directly with `dpop_jkt` + * and `code_challenge` parameters. + * + * `startLogin()` itself is `void` (not `async`) so its signature matches + * the public `SDKContext`/framework wrapper types exactly. If the async + * DPoP chain fails, the error is reported via `SDKConfig.onLoginFailure` + * (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. + * + * @param state Optional OAuth2 state value echoed back post-login. + */ + startLogin(state?: string): void { + if (this.dpopManager) { + this.startDpopLogin(state).catch(error => { + if (this.config.onLoginFailure) { + this.config.onLoginFailure(error as Error); + } else { + console.error('FusionAuth SDK: startLogin failed', error); + } + }); + return; + } + + // Cookie mode — unchanged behavior. this.redirectHelper.handlePreRedirect(state); window.location.assign(this.urlHelper.getLoginUrl(state)); } + /** + * Performs the DPoP-mode login flow. See {@link startLogin} for the full + * step-by-step description. Split out as its own async method so that + * `startLogin()` itself can remain synchronous (`void`) while still + * performing the necessary async key-pair/PKCE work before redirecting. + */ + private async startDpopLogin(state?: string): Promise { + await this.dpopManager!.getOrCreateKeyPair(); + const dpopJkt = await this.dpopManager!.getThumbprint(); + const codeVerifier = Pkce.generateCodeVerifier(); + const codeChallenge = await Pkce.generateCodeChallenge(codeVerifier); + this.redirectHelper.handlePreRedirect(state, codeVerifier); + window.location.assign( + this.urlHelper.getAuthorizeUrl(dpopJkt, codeChallenge, state), + ); + } + startRegister(state?: string) { this.redirectHelper.handlePreRedirect(state); window.location.assign(this.urlHelper.getRegisterUrl(state)); @@ -141,7 +204,17 @@ export class SDKCore { } } + /** + * Whether the user is currently logged in. + * + * - DPoP mode: delegates to `DPoPManager.isLoggedIn` which checks whether + * the stored tokens exist and have not expired. + * - Cookie mode: reads the `app.at_exp` cookie (existing behavior). + */ get isLoggedIn() { + if (this.dpopManager) { + return this.dpopManager.isLoggedIn; + } return this.at_exp > new Date().getTime(); } diff --git a/packages/core/src/UrlHelper/UrlHelper.ts b/packages/core/src/UrlHelper/UrlHelper.ts index b22880f..ab0bac5 100644 --- a/packages/core/src/UrlHelper/UrlHelper.ts +++ b/packages/core/src/UrlHelper/UrlHelper.ts @@ -75,7 +75,7 @@ export class UrlHelper { /** * Builds the direct `/oauth2/authorize` URL used in DPoP mode. - * Targets FusionAuth directly (not the companion app server). + * Targets FusionAuth directly (not the Hosted Backend API). * * @param dpopJkt The DPoP public key JWK thumbprint for the `dpop_jkt` parameter. * @param codeChallenge The PKCE code challenge value. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9137da2..4d9e6f4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,3 +4,4 @@ export { type SDKContext } from './SDKContext'; export * from './testUtils'; export { type CookieAdapter } from './CookieHelpers'; export * from './DPoP'; +export * from './Pkce'; diff --git a/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.spec.ts b/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.spec.ts index e48d08b..753b410 100644 --- a/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.spec.ts +++ b/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.spec.ts @@ -132,7 +132,8 @@ describe('FusionAuthService', () => { mockIsLoggedIn(); const stateValue = '/welcome-page'; - localStorage.setItem('fa-sdk-redirect-value', `abc123:${stateValue}`); + // Format: nonce:codeVerifier:state (empty verifier in cookie mode) + localStorage.setItem('fa-sdk-redirect-value', `abc123::${stateValue}`); const onRedirect = vi.fn(); configureTestingModule({ ...config, onRedirect }); diff --git a/packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx b/packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx index e162335..9bcf21b 100644 --- a/packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx +++ b/packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx @@ -121,12 +121,13 @@ describe('FusionAuthProvider', () => { mockIsLoggedIn(); const stateValue = 'hello-world'; - localStorage.setItem('fa-sdk-redirect-value', `abc123:${stateValue}`); + // Format: nonce:codeVerifier:state (empty verifier in cookie mode) + localStorage.setItem('fa-sdk-redirect-value', `abc123::${stateValue}`); const onRedirect = vi.fn(); renderWithWrapper({ ...TEST_CONFIG, onRedirect }); - expect(onRedirect).toHaveBeenCalled(); + expect(onRedirect).toHaveBeenCalledWith(stateValue); }); test('Will not invoke onRedirect if no redirect value is found in localStorage', () => { diff --git a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts index ec14ab3..26e72be 100644 --- a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts +++ b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts @@ -64,9 +64,10 @@ describe('createFusionAuth', () => { mockIsLoggedIn(); const onRedirect = vi.fn(); const expectedStateValue = 'redirect-callback-test'; + // Format: nonce:codeVerifier:state (empty verifier in cookie mode) localStorage.setItem( 'fa-sdk-redirect-value', - `rAnd0mStR1ng:${expectedStateValue}`, + `rAnd0mStR1ng::${expectedStateValue}`, ); createFusionAuth({ ...config, onRedirect });