diff --git a/README.md b/README.md index 6aca384..0e1acb1 100644 --- a/README.md +++ b/README.md @@ -91,15 +91,9 @@ Tests import these page objects to perform actions, ensuring that if the UI chan ### DPoP E2E tests -There are two separate DPoP-related test files, each with their own prerequisites: - -- `e2e/tests/dpop-smoke.test.ts` — exercises `DPoPManager`/`UrlHelper`/`SDKCore` directly against a live FusionAuth instance. No consuming quickstart application is required. Run with: - ``` - npx playwright test e2e/tests/dpop-smoke.test.ts --config playwright.dpop.config.ts - ``` -- `e2e/tests/dpop-endpoints.test.ts` — mirrors `endpoints.test.ts`, but drives a consuming quickstart application configured with `useDpop: true` through its UI. Since DPoP mode has no hosted backend mode (`SDKCore` talks directly to FusionAuth), this only covers the login / authorization-code-exchange flow — see the file header for the current coverage gaps (Logout/Register/Refresh/user-info aren't DPoP-aware in `SDKCore` yet). Run with a DPoP-enabled quickstart instance: +`e2e/tests/dpop-endpoints.test.ts` — mirrors `endpoints.test.ts`, but drives a consuming quickstart application configured with `useDpop: true` through its UI. Since DPoP mode has no hosted backend mode (`SDKCore` talks directly to FusionAuth), this validates the *direct* calls to `/oauth2/authorize`, `/oauth2/token` (both the authorization code exchange and the refresh token grant), `/oauth2/userinfo`, and `/oauth2/logout`: ``` - SERVER_COMMAND="your-dpop-quickstart-start-command" PORT=your-port-number npx playwright test e2e/tests/dpop-endpoints.test.ts + SERVER_COMMAND="your-dpop-quickstart-start-command" PORT=your-port-number npx playwright test e2e/tests/dpop-endpoints.test.ts --config playwright.dpop-endpoints.config.ts ``` This must be run on its own — it cannot be combined with `endpoints.test.ts` / `cookies.test.ts` in the same invocation, since those require a hosted backend mode quickstart instance instead. diff --git a/e2e/pages/common.page.ts b/e2e/pages/common.page.ts index 3afec66..6b14dbb 100644 --- a/e2e/pages/common.page.ts +++ b/e2e/pages/common.page.ts @@ -40,10 +40,8 @@ export class quickstartPage { await this.locators.passwordInput.clear(); await this.locators.passwordInput.fill('password'); await this.locators.submitBtn.click(); - // Wait for the full OAuth callback chain to complete (form POST → /app/callback - // code exchange → redirect back to the app). Without this, webkit doesn't finish - // committing the session cookies before the test body reads them. await expect(this.locators.logOutBtn).toBeVisible(); + await this.page.waitForLoadState('load'); } async navToRegister() { @@ -52,7 +50,16 @@ export class quickstartPage { } async logOut() { + const logoutNavigationPromise = this.page.waitForURL( + url => /\/(oauth2|app)\/logout/.test(url.pathname), + { timeout: 10_000 }, + ); + await this.locators.logOutBtn.click(); + await logoutNavigationPromise; + await expect(this.locators.logInBtn.nth(0)).toBeVisible(); + // See the comment in authenticate() above. + await this.page.waitForLoadState('load'); } } diff --git a/e2e/tests/dpop-endpoints.test.ts b/e2e/tests/dpop-endpoints.test.ts new file mode 100644 index 0000000..88f28c1 --- /dev/null +++ b/e2e/tests/dpop-endpoints.test.ts @@ -0,0 +1,197 @@ +/** + * DPoP Endpoint Tests + * + * Mirrors `endpoints.test.ts`, but for a consuming quickstart application + * configured with `useDpop: true`. Since DPoP mode has no hosted backend to + * proxy through (`SDKCore` talks directly to FusionAuth), these tests + * validate the *direct* calls to FusionAuth's `/oauth2/authorize`, + * `/oauth2/token`, `/oauth2/userinfo`, and `/oauth2/logout` endpoints, and + * check for tokens in `localStorage` instead of `app.*` HttpOnly cookies. + * + * Run with: + * SERVER_COMMAND="your-dpop-quickstart-start-command" PORT=your-port-number \ + * npx playwright test e2e/tests/dpop-endpoints.test.ts \ + * --config playwright.dpop-endpoints.config.ts + * + * Prerequisites: + * - A consuming quickstart application (e.g. fusionauth-quickstart-javascript-react-web) + * configured with `useDpop: true`, `shouldAutoRefresh: true`, and + * `shouldAutoFetchUserInfo: true` + * - short access token (JWT) lifetime configured — e.g. 30-60 seconds — + * so the auto-refresh test below doesn't need a long wall-clock wait. + * Set `autoRefreshSecondsBeforeExpiry` so the refresh fires comfortably + * before expiry (e.g. 20s before a 30s token lifetime). + * - CORS must be configured in FusionAuth (Settings -> System -> CORS) + * to allow the quickstart's origin (e.g. http://localhost:3000) to call + * `/oauth2/userinfo` directly: enable the filter, add the origin to + * Allowed origins, and add `DPoP` and `Authorization` to Allowed + * headers. Without this, the userinfo request's CORS preflight fails + * with "No 'Access-Control-Allow-Origin' header is present" + */ + +import { Page, test, BrowserContext, expect } from '@playwright/test'; +import { quickstartPage } from '../pages/common.page'; + +interface DPoPTokens { + accessToken: string; + refreshToken?: string; + expiresAt: number; + tokenType: string; +} + +async function readDpopTokens(page: Page): Promise { + const evaluateTokens = () => + page.evaluate(() => { + const key = Object.keys(localStorage).find(k => + k.startsWith('fusionauth-sdk:tokens:'), + ); + return key ? localStorage.getItem(key) : null; + }); + + let raw: string | null; + try { + raw = await evaluateTokens(); + } catch (error) { + if ( + error instanceof Error && + error.message.includes('Execution context was destroyed') + ) { + await page.waitForLoadState('load'); + raw = await evaluateTokens(); + } else { + throw error; + } + } + return raw ? JSON.parse(raw) : null; +} + +test.describe('DPoP Endpoint Tests', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let quickstart: quickstartPage; + let browserContext: BrowserContext; + + test.beforeAll(async ({ browser }) => { + browserContext = await browser.newContext(); + page = await browserContext.newPage(); + quickstart = new quickstartPage(page); + }); + + test.afterAll(async () => { + await page?.close(); + await browserContext?.close(); + }); + + test.beforeEach(async () => { + await page.goto('/'); + }); + + test('Login redirects directly to /oauth2/authorize with dpop_jkt and code_challenge, then exchanges the code at /oauth2/token', async () => { + await quickstart.navToLogIn(); + + const authorizeUrl = new URL(page.url()); + expect(authorizeUrl.pathname).toBe('/oauth2/authorize'); + expect(authorizeUrl.searchParams.get('response_type')).toBe('code'); + expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256'); + + const dpopJkt = authorizeUrl.searchParams.get('dpop_jkt'); + const codeChallenge = authorizeUrl.searchParams.get('code_challenge'); + expect(dpopJkt).toBeTruthy(); + expect(codeChallenge).toBeTruthy(); + + const tokenExchangeResponsePromise = page.waitForResponse( + response => + response.url().includes('/oauth2/token') && + response.request().method() === 'POST', + ); + + await quickstart.authenticate(); + + const tokenExchangeResponse = await tokenExchangeResponsePromise; + const tokenExchangeRequest = tokenExchangeResponse.request(); + expect(new URL(tokenExchangeRequest.url()).pathname).toBe('/oauth2/token'); + expect(tokenExchangeRequest.headers()['dpop']).toBeTruthy(); + + const body = new URLSearchParams(tokenExchangeRequest.postData() ?? ''); + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code_verifier')).toBeTruthy(); + + const tokens = await readDpopTokens(page); + expect(tokens).not.toBeNull(); + expect(tokens!.tokenType).toBe('DPoP'); + expect(tokens!.accessToken).toBeTruthy(); + + const cookies = await browserContext.cookies(); + ['app.at', 'app.idt', 'app.rt', 'app.at_exp'].forEach(name => { + expect(cookies.find(cookie => cookie.name === name)).toBeUndefined(); + }); + + await quickstart.logOut(); + }); + + test('User info is fetched after login, and the access token auto-refreshes via a direct /oauth2/token refresh_token grant', async () => { + // The refresh window depends on the FusionAuth Application's configured + // access token lifetime and the quickstart's autoRefreshSecondsBeforeExpiry. + test.setTimeout(90_000); + + await quickstart.navToLogIn(); + + const userInfoResponsePromise = page.waitForResponse(response => + response.url().includes('/oauth2/userinfo'), + ); + + await quickstart.authenticate(); + + const userInfoResponse = await userInfoResponsePromise; + const userInfoRequest = userInfoResponse.request(); + expect(new URL(userInfoRequest.url()).pathname).toBe('/oauth2/userinfo'); + expect(userInfoRequest.headers()['dpop']).toBeTruthy(); + expect(userInfoResponse.ok()).toBe(true); + + await expect(page.getByText('richard@example.com')).toBeVisible(); + + const initialTokens = await readDpopTokens(page); + expect(initialTokens).not.toBeNull(); + + const refreshResponse = await page.waitForResponse( + response => + response.url().includes('/oauth2/token') && + (response.request().postData() ?? '').includes( + 'grant_type=refresh_token', + ), + { timeout: 60_000 }, + ); + + const refreshRequest = refreshResponse.request(); + expect(refreshRequest.headers()['dpop']).toBeTruthy(); + const body = new URLSearchParams(refreshRequest.postData() ?? ''); + expect(body.get('refresh_token')).toBeTruthy(); + + const refreshedTokens = await readDpopTokens(page); + expect(refreshedTokens).not.toBeNull(); + expect(refreshedTokens!.accessToken).not.toBe(initialTokens!.accessToken); + + await quickstart.logOut(); + }); + + test('Logout redirects directly to /oauth2/logout and clears local DPoP state', async () => { + await quickstart.navToLogIn(); + await quickstart.authenticate(); + + expect(await readDpopTokens(page)).not.toBeNull(); + + const logoutRequestPromise = page.waitForRequest(request => + request.url().includes('/oauth2/logout'), + ); + + await quickstart.logOut(); + + const logoutRequest = await logoutRequestPromise; + const logoutUrl = new URL(logoutRequest.url()); + expect(logoutUrl.pathname).toBe('/oauth2/logout'); + expect(logoutUrl.searchParams.get('client_id')).toBeTruthy(); + + expect(await readDpopTokens(page)).toBeNull(); + }); +}); diff --git a/e2e/tests/dpop-smoke.test.ts b/e2e/tests/dpop-smoke.test.ts deleted file mode 100644 index 1d089df..0000000 --- a/e2e/tests/dpop-smoke.test.ts +++ /dev/null @@ -1,742 +0,0 @@ -/** - * DPoP Smoke Tests — pre-SDKCore wiring + SDKCore.startLogin() integration - * - * Stubs window/localStorage/indexedDB to create a real SDKCore. - * - * 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: - * - FusionAuth Enterprise instance running at http://localhost:9011 - * - Application baf3d520-40d7-4000-9b62-e6a7d0091102 configured with: - * proofKeyForCodeExchangePolicy: Required - * clientAuthenticationPolicy: NotRequired (public client) - * redirectUri: https://www.example.com registered - * - Test user: mike@fusionauth.io / password - */ - -import { Page, expect, test } from '@playwright/test'; -import { IDBFactory } from 'fake-indexeddb'; -import { DPoPManager } from '../../packages/core/src/DPoP/DPoPManager'; -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'; -import { - generateCodeVerifier, - generateCodeChallenge, -} from '../../packages/core/src/Pkce/Pkce'; - -// --------------------------------------------------------------------------- -// Config -// --------------------------------------------------------------------------- - -const FA_URL = 'http://localhost:9011'; -const CLIENT_ID = 'baf3d520-40d7-4000-9b62-e6a7d0091102'; -const REDIRECT_URI = 'https://www.example.com'; -const USERINFO_ENDPOINT = `${FA_URL}/oauth2/userinfo`; -const TEST_EMAIL = 'mike@fusionauth.io'; -const TEST_PASSWORD = 'password'; -const SCOPE = 'openid offline_access email profile'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Decode the payload of a JWT without verifying the signature. */ -function decodeJwt(jwt: string): Record { - const [, payload] = jwt.split('.'); - return JSON.parse( - Buffer.from( - payload.replace(/-/g, '+').replace(/_/g, '/'), - 'base64', - ).toString('utf8'), - ); -} - -/** 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'); -} - -/** - * 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. - */ -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. - * - * `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; -} { - 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. - * - * Handles two paths: - * - Fresh session: FusionAuth shows the login form; we fill and submit it. - * - SSO session active: FusionAuth redirects immediately without showing - * the form. - * - * The code is captured by intercepting the FusionAuth 302 redirect response - * whose Location header contains the code — this works regardless of whether - * Chromium can actually reach the redirect_uri (https://www.example.com). - */ -async function loginAndCaptureCode( - page: Page, - authorizeUrl: string, -): Promise { - let capturedCode: string | null = null; - - // Listen for any response whose Location header points to REDIRECT_URI. - // This fires on the FusionAuth 302 before Chromium follows it. - const codePromise = new Promise((resolve, reject) => { - const handler = (response: { - url: () => string; - status: () => number; - headers: () => Record; - }) => { - const location = response.headers()['location']; - if (location?.startsWith(REDIRECT_URI)) { - const url = new URL(location); - const code = url.searchParams.get('code'); - if (code) { - capturedCode = code; - page.off('response', handler as Parameters[1]); - resolve(code); - } else { - reject(new Error(`Redirect Location had no 'code': ${location}`)); - } - } - }; - page.on('response', handler as Parameters[1]); - }); - - // Navigate to the authorize URL. - await page.goto(authorizeUrl).catch(() => { - // May throw if Chromium can't reach https://www.example.com after redirect — that's fine. - }); - - // If the code was already captured during goto() (SSO path), return it. - if (capturedCode) return capturedCode; - - // Otherwise fill in the login form (fresh-session path). - const isFormVisible = await page - .locator('#loginId') - .isVisible({ timeout: 3_000 }) - .catch(() => false); - - if (isFormVisible) { - await page.locator('#loginId').fill(TEST_EMAIL); - await page.locator('#password').fill(TEST_PASSWORD); - await page - .locator('#submit-button') - .click() - .catch(() => {}); - } - - // Wait for the code from the response listener. - return Promise.race([ - codePromise, - new Promise((_, reject) => - setTimeout( - () => reject(new Error('Timed out waiting for authorization code')), - 15_000, - ), - ), - ]); -} - -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(); - - ensureNodeBrowserPolyfills(); - }); - - 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. - 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' }); - - let page: Page; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let context: any; - let manager: DPoPManager; - - // Shared state populated by earlier tests and used by later ones. - let accessToken: string; - let thumbprint: string; - let core: SDKCore; - - test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); - page = await context.newPage(); - manager = makeManager(); - ensureNodeBrowserPolyfills(); - thumbprint = await manager.getThumbprint(); - }); - - test.afterAll(async () => { - await page?.close(); - await context?.close(); - }); - - test('getAuthorizeUrl() produces a URL FusionAuth accepts (login page rendered)', async () => { - const verifier = generateCodeVerifier(); - const challenge = await generateCodeChallenge(verifier); - - const urlHelper = new UrlHelper({ - serverUrl: FA_URL, - clientId: CLIENT_ID, - redirectUri: REDIRECT_URI, - scope: SCOPE, - }); - - const authorizeUrl = urlHelper - .getAuthorizeUrl(thumbprint, challenge) - .toString(); - - // FusionAuth should respond with its hosted login page (200), not an error. - const response = await page.goto(authorizeUrl); - expect(response?.status()).toBe(200); - - // The login form's username/email input should be visible. - await expect(page.locator('#loginId')).toBeVisible(); - }); - - test('full authorization code grant via SDKCore.startLogin() + handlePostRedirect() — token_type is DPoP, cnf.jkt matches thumbprint', async () => { - const STATE = 'e2e-state'; - - ensureNodeBrowserPolyfills(); - - let notify: - ((result: { state?: string } | { error: Error }) => void) | undefined; - - core = new SDKCore({ - serverUrl: FA_URL, - clientId: CLIENT_ID, - redirectUri: REDIRECT_URI, - scope: SCOPE, - useDpop: true, - dpopTokenStorage: 'localStorage', - onTokenExpiration: () => {}, - onLoginFailure: error => notify?.({ error }), - }); - - // 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); - - // 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()); - }, - }; - - 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 })); - }, - ); - - 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(tokens!.tokenType).toBe('DPoP'); - expect(tokens!.accessToken).toBeDefined(); - - // Decode the access token and verify cnf.jkt matches our key's thumbprint. - const atPayload = decodeJwt(tokens!.accessToken); - expect(atPayload.cnf).toBeDefined(); - expect((atPayload.cnf as { jkt: string }).jkt).toBe(thumbprint); - - // Persist tokens for subsequent tests — same key pair as `manager`, so - // proofs `manager` signs for these tokens remain valid. - accessToken = tokens!.accessToken; - manager.setTokens(tokens!); - - expect(manager.isLoggedIn).toBe(true); - }); - - test('refresh token grant — issues new DPoP-bound tokens', async () => { - test.skip(!accessToken, 'No access token from previous test'); - - expect(core.isLoggedIn).toBe(true); - const previousAccessToken = accessToken; - - const response = await core.refreshToken(); - expect(response.ok).toBe(true); - - expect(core.isLoggedIn).toBe(true); - const newAccessToken = core.getAccessToken(); - expect(newAccessToken).toBeDefined(); - expect(newAccessToken).not.toBe(previousAccessToken); - - // verify cnf.jkt still matches our key's thumbprint — - // proves FusionAuth bound the refreshed token to the - // same DPoP key pair. - const atPayload = decodeJwt(newAccessToken!); - expect(atPayload.cnf).toBeDefined(); - expect((atPayload.cnf as { jkt: string }).jkt).toBe(thumbprint); - - const tokenStore = new DPoPTokenStore(CLIENT_ID, 'localStorage'); - const tokens = tokenStore.get(); - expect(tokens).not.toBeNull(); - expect(tokens!.tokenType).toBe('DPoP'); - expect(tokens!.accessToken).toBe(newAccessToken); - expect(tokens!.expiresAt).toBeGreaterThan(Date.now()); - - accessToken = newAccessToken!; - }); - - test('startLogout() clears DPoP state and redirects to the logout URL', async () => { - test.skip(!accessToken, 'No access token from previous test'); - - expect(core.isLoggedIn).toBe(true); - expect(core.getAccessToken()).toBe(accessToken); - - const { assign, waitForUrl } = createAssignWaiter(); - // @ts-ignore - globalThis.window.location = { assign }; - - core.startLogout(); - const assignedUrl = new URL(String(await waitForUrl())); - - expect(assignedUrl.origin).toBe(FA_URL); - expect(assignedUrl.pathname).toBe('/app/logout/'); - expect(assignedUrl.searchParams.get('client_id')).toBe(CLIENT_ID); - expect(assignedUrl.searchParams.get('post_logout_redirect_uri')).toBe( - REDIRECT_URI, - ); - - expect(core.isLoggedIn).toBe(false); - expect(core.getAccessToken()).toBeNull(); - - const tokenStore = new DPoPTokenStore(CLIENT_ID, 'localStorage'); - expect(tokenStore.get()).toBeNull(); - }); - - test('DPoPManager.fetch() calls /oauth2/userinfo with correct DPoP headers and gets user claims', async () => { - test.skip(!accessToken, 'No access token from previous test'); - - // DPoPManager.fetch() runs in the Node test process, not the browser page, - // so Playwright route interception can't observe its outgoing headers. - // Correctness is validated end-to-end instead: FusionAuth verifies ath, - // cnf.jkt, htu, and htm server-side, so a 200 here proves the real proof - // was accepted. - const fetchResponse = await manager.fetch(USERINFO_ENDPOINT); - - expect( - fetchResponse.status, - `Userinfo request failed — status ${fetchResponse.status}`, - ).toBe(200); - - const userInfo = (await fetchResponse.json()) as Record; - - // The userinfo response must contain the authenticated user's email. - expect(userInfo.email).toBe(TEST_EMAIL); - expect(userInfo.sub).toBeDefined(); - }); - - test('DPoPManager.fetch() proof carries correct htu and ath claims', async () => { - test.skip(!accessToken, 'No access token from previous test'); - - // We can validate proof claims by asking DPoPManager to generate a proof - // directly and decoding the JWT payload — this is the same proof - // DPoPManager.fetch() would use, just inspected explicitly here. - const proof = await manager.generateProof( - USERINFO_ENDPOINT, - 'GET', - accessToken, - ); - const proofPayload = decodeJwt(proof); - - expect(proofPayload.htu).toBe(USERINFO_ENDPOINT); - expect(proofPayload.htm).toBe('GET'); - expect(proofPayload.ath).toBeDefined(); - - // ath must be a non-empty base64url string (SHA-256 of access token). - expect(typeof proofPayload.ath).toBe('string'); - expect((proofPayload.ath as string).length).toBeGreaterThan(0); - - // Verify the ath value matches base64url(SHA-256(accessToken)). - const expectedAth = Buffer.from( - await crypto.subtle.digest('SHA-256', Buffer.from(accessToken, 'ascii')), - ).toString('base64url'); - expect(proofPayload.ath).toBe(expectedAth); - }); - - test('nonce retry — DPoPManager.fetch() retries once if /oauth2/userinfo challenges with use_dpop_nonce', async () => { - test.skip(!accessToken, 'No access token from previous test'); - - // Whether FusionAuth /oauth2/userinfo actually issues a nonce challenge is - // version/config dependent. We attempt the call and check the behaviour: - // - If FusionAuth does NOT challenge: the first call succeeds (200) — skip. - // - If FusionAuth DOES challenge: DPoPManager.fetch() must retry and succeed. - // - // Either outcome is a pass for this smoke test; the assertion is structural - // (≤ 2 fetch calls, final response is 200). - - let callCount = 0; - const originalFetch = globalThis.fetch; - globalThis.fetch = async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - callCount++; - return originalFetch(input, init); - }; - - try { - const response = await manager.fetch(USERINFO_ENDPOINT); - - expect(response.status).toBe(200); - // DPoPManager must never make more than 2 calls (original + at most one retry). - expect(callCount).toBeLessThanOrEqual(2); - - if (callCount === 2) { - // A retry happened — FusionAuth issued a nonce challenge. The second - // call must have carried a nonce claim in its proof. - console.log( - 'ℹ️ FusionAuth issued a use_dpop_nonce challenge — retry path exercised.', - ); - } else { - console.log( - 'ℹ️ FusionAuth did not issue a nonce challenge on this request — direct success path.', - ); - } - } finally { - globalThis.fetch = originalFetch; - } - }); - - 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. - // - // 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). - - 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; - } - }); - - test('clear() removes key pair, tokens, and nonces — isLoggedIn becomes false', async () => { - expect(manager.isLoggedIn).toBe(true); - - await manager.clear(); - - expect(manager.isLoggedIn).toBe(false); - expect(manager.getRefreshToken()).toBeNull(); - - // After clear(), a new key pair is generated on next use — thumbprint changes. - const newThumbprint = await manager.getThumbprint(); - expect(newThumbprint).not.toBe(thumbprint); - }); -}); diff --git a/package.json b/package.json index 6a9687b..cbd98d0 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "test:sdk-react": "yarn workspace @fusionauth/react-sdk test", "test:sdk-vue": "yarn workspace @fusionauth/vue-sdk test", "test:e2e": "yarn playwright test", + "test:e2e:dpop-endpoints": "yarn playwright test --config playwright.dpop-endpoints.config.ts", "lint:fix": "eslint . --ext .ts,.tsx --fix", "lint:check": "eslint . --ext .ts,.tsx --max-warnings 0", "format:fix": "prettier --write .", diff --git a/packages/core/package.json b/packages/core/package.json index 8a45740..0e91c74 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,6 +24,6 @@ "typescript": "^5.2.2", "vite": "^5.2.0", "vite-plugin-dts": "^3.8.0", - "vitest": "^1.4.0" + "vitest": "^3.2.6" } } diff --git a/packages/core/src/SDKContext/SDKContext.ts b/packages/core/src/SDKContext/SDKContext.ts index 216b043..ea0169f 100644 --- a/packages/core/src/SDKContext/SDKContext.ts +++ b/packages/core/src/SDKContext/SDKContext.ts @@ -55,6 +55,33 @@ export interface SDKContext { * This is handled automatically if the SDK is configured with `shouldAutoRefresh`. */ initAutoRefresh: () => void; + + /** + * Fetch wrapper that automatically attaches DPoP proof headers. + * Present only when `useDpop: true`. + */ + dpopFetch?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise; + + /** + * Returns a signed DPoP proof JWT for use with axios or other + * HTTP libraries. Present only when `useDpop: true`. + */ + generateProof?: ( + htu: string, + htm: string, + accessToken?: string, + nonce?: string, + ) => Promise; + + /** + * Returns the stored DPoP access token, or `null` if not logged in. + * Throws a descriptive error when `useDpop: false`. + * Present only when `useDpop: true`. + */ + getAccessToken?: () => string | null; } /** diff --git a/packages/core/src/SDKCore/SDKCore.test.ts b/packages/core/src/SDKCore/SDKCore.test.ts index af0ea87..be7204d 100644 --- a/packages/core/src/SDKCore/SDKCore.test.ts +++ b/packages/core/src/SDKCore/SDKCore.test.ts @@ -290,6 +290,31 @@ describe('SDKCore', () => { ); }); + it('startLogout() in DPoP mode clears DPoPManager state and redirects to /oauth2/logout directly', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + const clearSpy = vi + .spyOn(DPoPManager.prototype, 'clear') + .mockResolvedValue(undefined); + const location = mockWindowLocation(vi); + + const core = new SDKCore(dpopConfig); + core.startLogout(); + await vi.waitFor(() => expect(location.assign).toHaveBeenCalledOnce()); + + expect(clearSpy).toHaveBeenCalledOnce(); + + const assignedUrl = new URL( + String((location.assign as ReturnType).mock.calls[0][0]), + ); + + expect(assignedUrl.pathname).toBe('/oauth2/logout'); + expect(assignedUrl.searchParams.get('client_id')).toBe( + dpopConfig.clientId, + ); + }); + it('getAccessToken() returns the stored access token when useDpop: true', () => { vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( {} as any, @@ -324,6 +349,126 @@ describe('SDKCore', () => { ); }); + it('dpopFetch() delegates to DPoPManager.fetch() when useDpop: true', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + const mockResponse = new Response(null, { status: 200 }); + const fetchSpy = vi + .spyOn(DPoPManager.prototype, 'fetch') + .mockResolvedValue(mockResponse); + + const core = new SDKCore(dpopConfig); + const init = { method: 'GET' }; + const response = await core.dpopFetch( + 'https://api.example.com/data', + init, + ); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/data', + init, + ); + expect(response).toBe(mockResponse); + }); + + it('dpopFetch() throws when useDpop: false', async () => { + const core = new SDKCore(config); // no useDpop + + await expect( + core.dpopFetch('https://api.example.com/data'), + ).rejects.toThrow( + 'dpopFetch() is only available in DPoP mode. In hosted backend mode, use fetch() with credentials: "include" instead.', + ); + }); + + it('generateProof() delegates to DPoPManager.generateProof() when useDpop: true', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + const generateProofSpy = vi + .spyOn(DPoPManager.prototype, 'generateProof') + .mockResolvedValue('mock-dpop-proof-jwt'); + + const core = new SDKCore(dpopConfig); + const proof = await core.generateProof( + 'https://api.example.com/data', + 'POST', + 'mock-access-token', + 'mock-nonce', + ); + + expect(generateProofSpy).toHaveBeenCalledWith( + 'https://api.example.com/data', + 'POST', + 'mock-access-token', + 'mock-nonce', + ); + expect(proof).toBe('mock-dpop-proof-jwt'); + }); + + it('generateProof() throws when useDpop: false', async () => { + const core = new SDKCore(config); // no useDpop + + await expect( + core.generateProof('https://api.example.com/data', 'POST'), + ).rejects.toThrow( + 'generateProof() is only available in DPoP mode. In hosted backend mode, tokens are stored in HttpOnly cookies and DPoP proofs are not applicable.', + ); + }); + + describe('fetchUserInfo() in DPoP mode', () => { + function seedAccessToken( + core: SDKCore, + accessToken = 'mock-access-token', + ) { + const dpopManager = (core as any).dpopManager as DPoPManager; + dpopManager.setTokens({ + accessToken, + refreshToken: undefined, + expiresAt: Date.now() + 60_000, + tokenType: 'DPoP', + }); + } + + it('calls DPoPManager.fetch() targeting /oauth2/userinfo and returns the claims', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + const core = new SDKCore(dpopConfig); + seedAccessToken(core); + + const userInfoClaims = { sub: 'mock-sub', email: 'user@example.com' }; + const fetchSpy = vi + .spyOn(DPoPManager.prototype, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify(userInfoClaims), { status: 200 }), + ); + + const userInfo = await core.fetchUserInfo(); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const requestedUrl = fetchSpy.mock.calls[0]?.[0]; + expect(new URL(String(requestedUrl)).pathname).toBe('/oauth2/userinfo'); + expect(userInfo).toEqual(userInfoClaims); + }); + + it('hosted backend mode fetchUserInfo() is unaffected', async () => { + vi.spyOn(window, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ sub: 'mock-sub' }), { status: 200 }), + ); + + const core = new SDKCore(config); // no useDpop + const userInfo = await core.fetchUserInfo(); + + expect(userInfo).toEqual({ sub: 'mock-sub' }); + expect(window.fetch).toHaveBeenCalledWith( + expect.objectContaining({ pathname: '/app/me/' }), + { credentials: 'include' }, + ); + }); + }); + describe('handlePostRedirect() in DPoP mode', () => { const MOCK_PROOF = 'mock-dpop-proof-jwt'; const MOCK_CODE = 'mock-authorization-code'; @@ -448,6 +593,26 @@ describe('SDKCore', () => { expect(core.isLoggedIn).toBe(true); }); + it('does not exchange the code twice when called concurrently)', async () => { + mockDpopLoginDependencies(); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + + const core = new SDKCore(dpopConfig); + await primePendingRedirect(core); + const fetchMock = mockTokenResponse(); + + const first = core.handlePostRedirect(); + const second = core.handlePostRedirect(); + + expect(second).toBe(first); + + await Promise.all([first, second]); + + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it('invokes the callback with the state persisted by startLogin() and cleans up the redirect marker', async () => { mockDpopLoginDependencies(); vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( diff --git a/packages/core/src/SDKCore/SDKCore.ts b/packages/core/src/SDKCore/SDKCore.ts index 4c6e80b..bbab484 100644 --- a/packages/core/src/SDKCore/SDKCore.ts +++ b/packages/core/src/SDKCore/SDKCore.ts @@ -15,6 +15,7 @@ export class SDKCore { private refreshTokenTimeout?: NodeJS.Timeout; private isDisposed = false; private dpopManager?: DPoPManager; + private postRedirectPromise?: Promise; constructor(config: SDKConfig) { this.config = config; @@ -102,6 +103,9 @@ export class SDKCore { * In hosted backend mode, the flow is synchronous. */ startLogout(): void { + clearTimeout(this.tokenExpirationTimeout); + this.stopAutoRefresh(); + if (this.dpopManager) { this.startDpopLogout().catch(error => { console.error('FusionAuth SDK: startLogout failed', error); @@ -120,7 +124,7 @@ export class SDKCore { try { await this.dpopManager!.clear(); } finally { - window.location.assign(this.urlHelper.getLogoutUrl()); + window.location.assign(this.urlHelper.getOAuth2LogoutUrl()); } } @@ -144,7 +148,50 @@ export class SDKCore { return this.dpopManager.getAccessToken(); } - async fetchUserInfo() { + /** + * DPoP-aware `fetch()` wrapper. Automatically attaches `Authorization: DPoP + * ` and `DPoP: ` headers to the outgoing request. + * + * @throws {Error} if called in hosted backend mode (`useDpop: false`). + */ + async dpopFetch( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + if (!this.dpopManager) { + throw new Error( + 'dpopFetch() is only available in DPoP mode. In hosted backend mode, use fetch() with credentials: "include" instead.', + ); + } + return this.dpopManager.fetch(input, init); + } + + /** + * Generates a signed DPoP proof JWT for the given request, for use cases + * (e.g. axios or other HTTP libraries) that can't use + * {@link dpopFetch}). + * + * @throws {Error} if called in hosted backend mode (`useDpop: false`). + */ + async generateProof( + htu: string, + htm: string, + accessToken?: string, + nonce?: string, + ): Promise { + if (!this.dpopManager) { + throw new Error( + 'generateProof() is only available in DPoP mode. In hosted backend mode, tokens are stored in HttpOnly cookies and DPoP proofs are not applicable.', + ); + } + return this.dpopManager.generateProof(htu, htm, accessToken, nonce); + } + + async fetchUserInfo(): Promise { + if (this.dpopManager) { + return this.fetchDpopUserInfo(); + } + const userInfoResponse = await fetch(this.urlHelper.getMeUrl(), { credentials: 'include', }); @@ -159,6 +206,27 @@ export class SDKCore { return userInfo; } + private async fetchDpopUserInfo(): Promise { + const accessToken = this.dpopManager!.getAccessToken(); + if (!accessToken) { + throw new Error( + 'No access token available. Have you called startLogin()?', + ); + } + + const userInfoResponse = await this.dpopManager!.fetch( + this.urlHelper.getUserInfoUrl(), + ); + + if (!userInfoResponse.ok) { + throw new Error( + `Unable to fetch userInfo. Request failed with status code ${userInfoResponse?.status}`, + ); + } + + return (await userInfoResponse.json()) as T; + } + async refreshToken(): Promise { if (this.dpopManager) { return this.refreshDpopToken(); @@ -181,8 +249,6 @@ export class SDKCore { throw new Error(JSON.stringify(errorDetails)); } - // a successful request means that app_exp was bumped into the future. - // reschedule the access token expiration event. this.scheduleTokenExpiration(); return response; @@ -292,21 +358,30 @@ export class SDKCore { * kicking off an async chain, otherwise continue using Hosted * Backend Mode. */ - handlePostRedirect(callback?: (state?: string) => void): void { + handlePostRedirect(callback?: (state?: string) => void): Promise { + if (this.postRedirectPromise) { + return this.postRedirectPromise; + } + 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; + this.postRedirectPromise = this.handleDpopPostRedirect(callback).catch( + error => { + if (this.config.onLoginFailure) { + this.config.onLoginFailure(error as Error); + } else { + console.error('FusionAuth SDK: handlePostRedirect failed', error); + } + }, + ); + return this.postRedirectPromise; } if (this.isLoggedIn) { this.redirectHelper.handlePostRedirect(callback); } + + this.postRedirectPromise = Promise.resolve(); + return this.postRedirectPromise; } /** diff --git a/packages/core/src/UrlHelper/UrlHelper.test.ts b/packages/core/src/UrlHelper/UrlHelper.test.ts index 365820b..e019fda 100644 --- a/packages/core/src/UrlHelper/UrlHelper.test.ts +++ b/packages/core/src/UrlHelper/UrlHelper.test.ts @@ -197,6 +197,39 @@ describe('UrlHelper', () => { expect(tokenUrl.search).toBe(''); }); }); + + describe('getOAuth2LogoutUrl', () => { + it('targets the FusionAuth /oauth2/logout endpoint directly', () => { + const logoutUrl = urlHelper.getOAuth2LogoutUrl(); + expect(logoutUrl.origin).toBe(config.serverUrl); + expect(logoutUrl.pathname).toBe('/oauth2/logout'); + expect(logoutUrl.searchParams.get('client_id')).toBe(config.clientId); + expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe( + config.postLogoutRedirectUri, + ); + }); + + it('defaults post_logout_redirect_uri to redirectUri when not configured', () => { + const urlHelperWithoutPostLogoutRedirectUri = new UrlHelper({ + serverUrl: 'http://my-server', + clientId: 'abc123', + redirectUri: 'http://my-client', + }); + const logoutUrl = + urlHelperWithoutPostLogoutRedirectUri.getOAuth2LogoutUrl(); + expect(logoutUrl.searchParams.get('post_logout_redirect_uri')).toBe( + 'http://my-client', + ); + }); + }); + + describe('getUserInfoUrl', () => { + it('targets the FusionAuth /oauth2/userinfo endpoint directly', () => { + const userInfoUrl = urlHelper.getUserInfoUrl(); + expect(userInfoUrl.origin).toBe(config.serverUrl); + expect(userInfoUrl.pathname).toBe('/oauth2/userinfo'); + }); + }); }); function getAllUrls(urlHelper: UrlHelper) { diff --git a/packages/core/src/UrlHelper/UrlHelper.ts b/packages/core/src/UrlHelper/UrlHelper.ts index d060fd5..66e9324 100644 --- a/packages/core/src/UrlHelper/UrlHelper.ts +++ b/packages/core/src/UrlHelper/UrlHelper.ts @@ -67,6 +67,16 @@ export class UrlHelper { }); } + /** + * Builds the direct `/oauth2/logout` URL used in DPoP mode. + */ + getOAuth2LogoutUrl(): URL { + return this.generateUrl('/oauth2/logout', { + client_id: this.clientId, + post_logout_redirect_uri: this.postLogoutRedirectUri || this.redirectUri, + }); + } + getAccountManagementUrl(): URL { return this.generateUrl('/account/', { client_id: this.clientId, @@ -96,15 +106,19 @@ 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 Hosted Backend Mode). Request parameters are sent in - * the POST body (form-urlencoded), not the query string, so no params are - * appended here. + * authorization code exchange and refresh token grant. */ getTokenUrl(): URL { return this.generateUrl('/oauth2/token'); } + /** + * Builds the direct `/oauth2/userinfo` URL used in DPoP mode. + */ + getUserInfoUrl(): URL { + return this.generateUrl('/oauth2/userinfo'); + } + private generateUrl(path: string, params?: UrlHelperQueryParams): URL { const url = new URL(this.serverUrl); url.pathname = path; diff --git a/packages/lexicon/package.json b/packages/lexicon/package.json index 031d465..6a87842 100644 --- a/packages/lexicon/package.json +++ b/packages/lexicon/package.json @@ -27,6 +27,6 @@ "@types/node": "^20.11.5", "vite": "^5.0.12", "vite-plugin-dts": "^3.7.1", - "vitest": "^1.2.1" + "vitest": "^3.2.6" } } diff --git a/packages/sdk-angular/CHANGES.md b/packages/sdk-angular/CHANGES.md index c208e89..3cb30e7 100644 --- a/packages/sdk-angular/CHANGES.md +++ b/packages/sdk-angular/CHANGES.md @@ -1,5 +1,9 @@ @fusionauth/angular-sdk Changes +Changes in 2.1.0 + +- Added support for DPoP mode. Configure with `useDpop: true` and `dpopTokenStorage`. `FusionAuthService` now exposes `dpopFetch()`, `generateProof()`, and `getAccessToken()`. + Changes in 2.0.0 - Upgraded to Angular 22. Angular 17 through 21 are no longer supported. diff --git a/packages/sdk-angular/README.md b/packages/sdk-angular/README.md index c1bc352..693ea5d 100644 --- a/packages/sdk-angular/README.md +++ b/packages/sdk-angular/README.md @@ -11,6 +11,7 @@ An SDK for using FusionAuth in Angular applications. - [Pre-built buttons](#pre-built-buttons) - [State Parameter](#state-parameter) - [SSR](#ssr) + - [DPoP Mode](#dpop-mode) - [Known issues](#known-issues) - [Documentation](#documentation) - [Releases](#releases) @@ -94,6 +95,7 @@ import { FusionAuthModule } from '@fusionauth/angular-sdk'; serverUrl: '', // The base URL of the server that performs the token exchange redirectUri: '', // The URI that the user is directed to after the login/register/logout action shouldAutoRefresh: true // option to configure the SDK to automatically handle token refresh. Defaults to false if not specified here. + // useDpop: true, // Opt-in to DPoP mode. See "DPoP Mode" below. Defaults to false. }), ], providers: [], @@ -185,6 +187,40 @@ user can be returned to that location after a successful authentication. The SDK supports Angular applications using SSR. No additional configuration is needed. +### DPoP Mode + +By default, the SDK calls a Hosted Backend that stores tokens in HttpOnly cookies (`useDpop: false`, the default). In DPoP mode, the SDK instead calls FusionAuth endpoints directly and binds tokens to a private key generated in the browser. Enable it by setting `useDpop: true` on `FusionAuthConfig`: + +```typescript +FusionAuthModule.forRoot({ + clientId: '', + serverUrl: '', + redirectUri: '', + useDpop: true, // Opt-in to DPoP mode. + dpopTokenStorage: 'localStorage', // 'localStorage' (default, persists across reloads) or 'memory'. +}), +``` + +When `useDpop: true`, `FusionAuthService` additionally exposes `dpopFetch()`, `generateProof()`, and `getAccessToken()`. These throw a descriptive error if called when `useDpop` is not enabled. + +```typescript +class AppComponent { + private fusionAuthService: FusionAuthService = inject(FusionAuthService); + + async callApi() { + // Recommended — handles attaching DPoP headers (and nonce retries) automatically. + const response = await this.fusionAuthService.dpopFetch('https://api.example.com/data', { method: 'GET' }); + + // For axios or other HTTP libraries that can't use dpopFetch. + const accessToken = this.fusionAuthService.getAccessToken(); + const proof = await this.fusionAuthService.generateProof('https://api.example.com/data', 'GET', accessToken ?? undefined); + // axios.get('https://api.example.com/data', { + // headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof } + // }); + } +} +``` + ### Known Issues None. diff --git a/packages/sdk-angular/projects/fusionauth-angular-sdk/ng-package.json b/packages/sdk-angular/projects/fusionauth-angular-sdk/ng-package.json index ccbfd22..1efe30c 100644 --- a/packages/sdk-angular/projects/fusionauth-angular-sdk/ng-package.json +++ b/packages/sdk-angular/projects/fusionauth-angular-sdk/ng-package.json @@ -3,5 +3,6 @@ "dest": "../../dist/fusionauth-angular-sdk", "lib": { "entryFile": "src/public-api.ts" - } + }, + "allowedNonPeerDependencies": ["dpop"] } diff --git a/packages/sdk-angular/projects/fusionauth-angular-sdk/package.json b/packages/sdk-angular/projects/fusionauth-angular-sdk/package.json index 066fe15..466def9 100644 --- a/packages/sdk-angular/projects/fusionauth-angular-sdk/package.json +++ b/packages/sdk-angular/projects/fusionauth-angular-sdk/package.json @@ -1,12 +1,13 @@ { "name": "@fusionauth/angular-sdk", - "version": "2.0.0", + "version": "2.1.0", "peerDependencies": { "@angular/common": ">=22.0.0", "@angular/core": ">=22.0.0" }, "dependencies": { - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "dpop": "^2.1.1" }, "sideEffects": false, "repository": { 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 d821c3c..eb0ca9e 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 @@ -5,7 +5,12 @@ import { take } from 'rxjs'; import { FusionAuthConfig } from './types'; import { FusionAuthService } from './fusion-auth.service'; import { FusionAuthModule } from './fusion-auth.module'; -import { mockIsLoggedIn, removeAt_expCookie } from '../sdkcore'; +import { + mockIsLoggedIn, + removeAt_expCookie, + mockWindowLocation, + DPoPManager, +} from '../sdkcore'; const config: FusionAuthConfig = { clientId: 'a-client-id', @@ -13,6 +18,32 @@ const config: FusionAuthConfig = { serverUrl: 'http://localhost:9011', }; +const dpopConfig: FusionAuthConfig = { + ...config, + useDpop: true, +}; + +function seedDpopTokens( + clientId: string, + overrides: Partial<{ + accessToken: string; + refreshToken: string | undefined; + expiresAt: number; + tokenType: string; + }> = {}, +) { + localStorage.setItem( + `fusionauth-sdk:tokens:${clientId}`, + JSON.stringify({ + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + expiresAt: Date.now() + 60_000, + tokenType: 'DPoP', + ...overrides, + }), + ); +} + function configureTestingModule(config: FusionAuthConfig) { TestBed.configureTestingModule({ imports: [FusionAuthModule.forRoot(config)], @@ -140,4 +171,125 @@ describe('FusionAuthService', () => { expect(onRedirect).toHaveBeenCalledWith('/welcome-page'); }); + + describe('DPoP mode', () => { + it('dpopFetch() delegates to DPoPManager when useDpop: true', async () => { + const mockResponse = new Response(null, { status: 200 }); + const fetchSpy = vi + .spyOn(DPoPManager.prototype, 'fetch') + .mockResolvedValue(mockResponse); + + const service = configureTestingModule(dpopConfig); + const init = { method: 'GET' }; + const response = await service.dpopFetch( + 'https://api.example.com/data', + init, + ); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/data', + init, + ); + expect(response).toBe(mockResponse); + }); + + it('dpopFetch() throws when useDpop is not enabled', async () => { + const service = configureTestingModule(config); + + await expect( + service.dpopFetch('https://api.example.com/data'), + ).rejects.toThrow( + 'dpopFetch() is only available in DPoP mode. In hosted backend mode, use fetch() with credentials: "include" instead.', + ); + }); + + it('generateProof() delegates to DPoPManager when useDpop: true', async () => { + const generateProofSpy = vi + .spyOn(DPoPManager.prototype, 'generateProof') + .mockResolvedValue('mock-dpop-proof-jwt'); + + const service = configureTestingModule(dpopConfig); + const proof = await service.generateProof( + 'https://api.example.com/data', + 'POST', + 'mock-access-token', + 'mock-nonce', + ); + + expect(generateProofSpy).toHaveBeenCalledWith( + 'https://api.example.com/data', + 'POST', + 'mock-access-token', + 'mock-nonce', + ); + expect(proof).toBe('mock-dpop-proof-jwt'); + }); + + it('generateProof() throws when useDpop is not enabled', async () => { + const service = configureTestingModule(config); + + await expect( + service.generateProof('https://api.example.com/data', 'POST'), + ).rejects.toThrow( + 'generateProof() is only available in DPoP mode. In hosted backend mode, tokens are stored in HttpOnly cookies and DPoP proofs are not applicable.', + ); + }); + + it('getAccessToken() returns the stored access token when useDpop: true', () => { + seedDpopTokens(dpopConfig.clientId, { + accessToken: 'mock-stored-access-token', + }); + + const service = configureTestingModule(dpopConfig); + + expect(service.getAccessToken()).toBe('mock-stored-access-token'); + }); + + it('getAccessToken() returns null when logged out in DPoP mode', () => { + const service = configureTestingModule(dpopConfig); + + expect(service.getAccessToken()).toBeNull(); + }); + + it('getAccessToken() throws when useDpop is not enabled', () => { + const service = configureTestingModule(config); + + expect(() => service.getAccessToken()).toThrow( + 'getAccessToken() is only available in DPoP mode. In hosted backend mode, tokens are stored in HttpOnly cookies and are not accessible to JavaScript.', + ); + }); + + it('isLoggedInSignal reflects true once the post-redirect DPoP token exchange completes', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + 'mock-dpop-proof-jwt', + ); + mockWindowLocation(vi, '?code=mock-authorization-code'); + localStorage.setItem( + 'fa-sdk-redirect-value', + JSON.stringify({ codeVerifier: 'mock-code-verifier' }), + ); + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 3600, + token_type: 'DPoP', + }), + { status: 200 }, + ), + ); + + const service = configureTestingModule(dpopConfig); + + expect(service.isLoggedInSignal()).toBe(false); + + await vi.waitFor(() => { + expect(service.isLoggedInSignal()).toBe(true); + }); + }); + }); }); diff --git a/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.ts b/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.ts index 85cc3ee..817b92a 100644 --- a/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.ts +++ b/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.ts @@ -1,4 +1,12 @@ -import { Injectable, Inject, PLATFORM_ID } from '@angular/core'; +import { + Injectable, + Inject, + PLATFORM_ID, + NgZone, + Signal, + ApplicationRef, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { isPlatformBrowser } from '@angular/common'; import { Observable, catchError, BehaviorSubject } from 'rxjs'; @@ -21,28 +29,49 @@ export class FusionAuthService { constructor( @Inject(FUSIONAUTH_SERVICE_CONFIG) config: FusionAuthConfig, @Inject(PLATFORM_ID) platformId: Object, + private ngZone: NgZone, + private appRef: ApplicationRef, ) { this.core = new SDKCore({ ...config, onTokenExpiration: () => { - this.isLoggedInSubject.next(false); + this.runInZoneAndTick(() => this.isLoggedInSubject.next(false)); }, cookieAdapter: new SSRCookieAdapter(isPlatformBrowser(platformId)), }); this.isLoggedInSubject = new BehaviorSubject(this.core.isLoggedIn); this.isLoggedIn$ = this.isLoggedInSubject.asObservable(); + this.isLoggedInSignal = toSignal(this.isLoggedIn$, { + initialValue: this.core.isLoggedIn, + }); - this.core.handlePostRedirect(config.onRedirect); + this.core.handlePostRedirect(config.onRedirect).then(() => { + this.runInZoneAndTick(() => + this.isLoggedInSubject.next(this.core.isLoggedIn), + ); + }); if (config.shouldAutoRefresh && this.core.isLoggedIn) { this.initAutoRefresh(); } } + private runInZoneAndTick(fn: () => void): void { + this.ngZone.run(fn); + if (!this.appRef.destroyed) { + this.appRef.tick(); + } + } + /** An observable representing whether the user is logged in. */ isLoggedIn$: Observable; + /** + * A Signal representing whether the user is logged in. + */ + isLoggedInSignal: Signal; + /** A function that returns whether the user is logged in. This returned value is non-observable. */ isLoggedIn() { return this.core.isLoggedIn; @@ -80,13 +109,13 @@ export class FusionAuthService { this.core .fetchUserInfo() .then(userInfo => { - observer.next(userInfo); + this.runInZoneAndTick(() => observer.next(userInfo)); }) .catch(error => { - observer.error(error); + this.runInZoneAndTick(() => observer.error(error)); }) .finally(() => { - callbacks?.onDone?.(); + this.runInZoneAndTick(() => callbacks?.onDone?.()); }); }).pipe( catchError(error => { @@ -100,7 +129,13 @@ export class FusionAuthService { * @throws {Error} - if an error occurred while fetching. */ async getUserInfo(): Promise { - return await this.core.fetchUserInfo(); + return this.core.fetchUserInfo().then(userInfo => { + let result!: T; + this.runInZoneAndTick(() => { + result = userInfo; + }); + return result; + }); } /** @@ -133,4 +168,38 @@ export class FusionAuthService { manageAccount(): void { this.core.manageAccount(); } + + /** + * DPoP mode `fetch()` wrapper that automatically attaches DPoP proof + * headers. + * @throws {Error} if called when `useDpop` is not enabled. + */ + async dpopFetch( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise { + return this.core.dpopFetch(input, init); + } + + /** + * Returns a signed DPoP proof JWT for use with axios or other + * HTTP libraries that can't use {@link dpopFetch}. + * @throws {Error} if called when `useDpop` is not enabled. + */ + async generateProof( + htu: string, + htm: string, + accessToken?: string, + nonce?: string, + ): Promise { + return this.core.generateProof(htu, htm, accessToken, nonce); + } + + /** + * Returns the stored DPoP access token, or `null` if not logged in. + * @throws {Error} if called when `useDpop` is not enabled. + */ + getAccessToken(): string | null { + return this.core.getAccessToken(); + } } diff --git a/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/types.ts b/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/types.ts index 1a5e8bd..adaf990 100644 --- a/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/types.ts +++ b/packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/types.ts @@ -71,6 +71,19 @@ export interface FusionAuthConfig { * The path to the me endpoint. */ mePath?: string; + + /** + * Opt-in to DPoP mode. When `true`, the SDK calls FusionAuth endpoints + * directly and stores tokens in JavaScript-accessible storage instead of + * relying on the Hosted Backend's HttpOnly cookies. Defaults to `false`. + */ + useDpop?: boolean; + + /** + * Token storage location in DPoP mode. Only meaningful when `useDpop: true`. + * Defaults to `'localStorage'`. + */ + dpopTokenStorage?: 'localStorage' | 'memory'; } export interface UserInfo { diff --git a/packages/sdk-react/CHANGES.md b/packages/sdk-react/CHANGES.md index f3844f0..0fc2849 100644 --- a/packages/sdk-react/CHANGES.md +++ b/packages/sdk-react/CHANGES.md @@ -1,5 +1,9 @@ FusionAuth React SDK Changes +Changes in 2.7.0 + +- Added support for DPoP mode. Configure with `useDpop: true` and `dpopTokenStorage`. `useFusionAuth()` now exposes `dpopFetch`, `generateProof`, and `getAccessToken` when DPoP mode is enabled. + Changes in 2.6.0 - Upgrade to React 19. The SDK is now built and tested against React 19 (^19.2.0) while maintaining compatibility with React 18.2+. diff --git a/packages/sdk-react/README.md b/packages/sdk-react/README.md index 67e2710..4e4e2b3 100644 --- a/packages/sdk-react/README.md +++ b/packages/sdk-react/README.md @@ -13,6 +13,7 @@ An SDK for using FusionAuth in React applications. - [State Parameter](#state-parameter) - [Protecting content](#protecting-content) - [UI Components](#ui-components) + - [DPoP Mode](#dpop-mode) - [Known issues](#known-issues) - [Documentation](#documentation) - [Formatting](#formatting) @@ -96,6 +97,7 @@ const config: FusionAuthProviderConfig = { shouldAutoFetchUserInfo: true, // Automatically fetch userInfo when logged in. Defaults to false. shouldAutoRefresh: true, // Enables automatic token refresh. Defaults to false. onRedirect: (state?: string) => { }, // Optional callback invoked upon redirect back from login or register. + // useDpop: true, // Opt-in to DPoP mode. See "DPoP Mode" below. Defaults to false. }; ReactDOM.createRoot(document.getElementById("my-app")).render( @@ -228,6 +230,36 @@ export const AccountPage = () => ( ); ``` +### DPoP Mode + +By default, the SDK calls a Hosted Backend that stores tokens in HttpOnly cookies (`useDpop: false`, the default). In DPoP mode, the SDK instead calls FusionAuth endpoints directly and binds tokens to a private key generated in the browser, per [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449). Enable it by setting `useDpop: true` on `FusionAuthProviderConfig`: + +```jsx +const config: FusionAuthProviderConfig = { + clientId: "", + redirectUri: "", + serverUrl: "", + useDpop: true, // Opt-in to DPoP mode. + dpopTokenStorage: 'localStorage', // 'localStorage' (default, persists across reloads) or 'memory'. +}; +``` + +When `useDpop: true`, `useFusionAuth()` additionally returns `dpopFetch`, `generateProof`, and `getAccessToken`. These are `undefined` when `useDpop` is `false` or not set. + +```jsx +const { dpopFetch, generateProof, getAccessToken } = useFusionAuth(); + +// Recommended — handles attaching DPoP headers (and nonce retries) automatically. +const response = await dpopFetch('https://api.example.com/data', { method: 'GET' }); + +// Advanced — for axios or other HTTP libraries that can't use dpopFetch. +const accessToken = getAccessToken(); +const proof = await generateProof('https://api.example.com/data', 'GET', accessToken); +// axios.get('https://api.example.com/data', { +// headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof } +// }); +``` + ### Known Issues None. diff --git a/packages/sdk-react/package.json b/packages/sdk-react/package.json index 847ad70..9785612 100644 --- a/packages/sdk-react/package.json +++ b/packages/sdk-react/package.json @@ -1,6 +1,6 @@ { "name": "@fusionauth/react-sdk", - "version": "2.6.0", + "version": "2.7.0", "description": "FusionAuth solves the problem of building essential security without adding risk or distracting from your primary application", "type": "module", "scripts": { @@ -61,6 +61,6 @@ "typescript": "^5.2.2", "vite": "^5.4.17", "vite-plugin-dts": "^3.7.3", - "vitest": "^1.6.1" + "vitest": "^3.2.6" } } diff --git a/packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx b/packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx index 7dc1a29..7e62409 100644 --- a/packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx +++ b/packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx @@ -13,6 +13,7 @@ import { mockIsLoggedIn, removeAt_expCookie, mockWindowLocation, + DPoPManager, } from '@fusionauth-sdk/core'; import { TEST_CONFIG, @@ -27,11 +28,34 @@ function renderWithWrapper(config: FusionAuthProviderConfig) { }); } +/** Seeds `localStorage` with a valid, unexpired DPoP token set for `clientId`. */ +function seedDpopTokens( + clientId: string, + overrides: Partial<{ + accessToken: string; + refreshToken: string | undefined; + expiresAt: number; + tokenType: string; + }> = {}, +) { + localStorage.setItem( + `fusionauth-sdk:tokens:${clientId}`, + JSON.stringify({ + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + expiresAt: Date.now() + 60_000, + tokenType: 'DPoP', + ...overrides, + }), + ); +} + describe('FusionAuthProvider', () => { afterEach(() => { removeAt_expCookie(); localStorage.clear(); vi.clearAllMocks(); + vi.useRealTimers(); }); test('Redirects to the correct login url', () => { @@ -328,4 +352,150 @@ describe('FusionAuthProvider', () => { ), ); }); + + describe('DPoP mode', () => { + test('dpopFetch, generateProof, and getAccessToken are functions when useDpop: true', () => { + const { result } = renderWithWrapper({ ...TEST_CONFIG, useDpop: true }); + + expect(typeof result.current.dpopFetch).toBe('function'); + expect(typeof result.current.generateProof).toBe('function'); + expect(typeof result.current.getAccessToken).toBe('function'); + }); + + test('dpopFetch, generateProof, and getAccessToken are undefined when useDpop is false', () => { + const { result } = renderWithWrapper({ ...TEST_CONFIG, useDpop: false }); + + expect(result.current.dpopFetch).toBeUndefined(); + expect(result.current.generateProof).toBeUndefined(); + expect(result.current.getAccessToken).toBeUndefined(); + }); + + test('dpopFetch, generateProof, and getAccessToken are undefined when useDpop is not set', () => { + const { result } = renderWithWrapper(TEST_CONFIG); + + expect(result.current.dpopFetch).toBeUndefined(); + expect(result.current.generateProof).toBeUndefined(); + expect(result.current.getAccessToken).toBeUndefined(); + }); + + test('getAccessToken() returns the stored access token after login', () => { + seedDpopTokens(TEST_CONFIG.clientId, { + accessToken: 'mock-stored-access-token', + }); + + const { result } = renderWithWrapper({ ...TEST_CONFIG, useDpop: true }); + + expect(result.current.getAccessToken?.()).toBe( + 'mock-stored-access-token', + ); + }); + + test('getAccessToken() returns null when logged out', () => { + const { result } = renderWithWrapper({ ...TEST_CONFIG, useDpop: true }); + + expect(result.current.getAccessToken?.()).toBeNull(); + }); + + test('isLoggedIn reflects DPoP token store state, not the app.at_exp cookie', () => { + seedDpopTokens(TEST_CONFIG.clientId); + // Explicitly confirm no cookie-based login signal is present. + removeAt_expCookie(); + + const { result } = renderWithWrapper({ ...TEST_CONFIG, useDpop: true }); + + expect(result.current.isLoggedIn).toBe(true); + }); + + test('isLoggedIn is false in DPoP mode when no tokens are stored, even if the app.at_exp cookie is set', () => { + mockIsLoggedIn(); // sets app.at_exp cookie — must be ignored in DPoP mode. + + const { result } = renderWithWrapper({ ...TEST_CONFIG, useDpop: true }); + + expect(result.current.isLoggedIn).toBe(false); + }); + + test('isLoggedIn flips to true once the post-redirect DPoP token exchange completes', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + 'mock-dpop-proof-jwt', + ); + mockWindowLocation(vi, '?code=mock-authorization-code'); + localStorage.setItem( + 'fa-sdk-redirect-value', + JSON.stringify({ codeVerifier: 'mock-code-verifier' }), + ); + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 3600, + token_type: 'DPoP', + }), + { status: 200 }, + ), + ); + + const { result } = renderWithWrapper({ ...TEST_CONFIG, useDpop: true }); + + expect(result.current.isLoggedIn).toBe(false); + + await waitFor(() => { + expect(result.current.isLoggedIn).toBe(true); + }); + expect(result.current.getAccessToken?.()).toBe('mock-access-token'); + }); + + test('shouldAutoFetchUserInfo fetches userInfo once isLoggedIn flips to true after the DPoP redirect completes', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + 'mock-dpop-proof-jwt', + ); + mockWindowLocation(vi, '?code=mock-authorization-code'); + localStorage.setItem( + 'fa-sdk-redirect-value', + JSON.stringify({ codeVerifier: 'mock-code-verifier' }), + ); + + // First response is the code exchange (/oauth2/token); second is the + // subsequent /oauth2/userinfo call triggered by shouldAutoFetchUserInfo. + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 3600, + token_type: 'DPoP', + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ email: 'user@example.com' }), { + status: 200, + }), + ); + + const { result } = renderWithWrapper({ + ...TEST_CONFIG, + useDpop: true, + shouldAutoFetchUserInfo: true, + }); + + expect(result.current.isLoggedIn).toBe(false); + + await waitFor(() => { + expect(result.current.isLoggedIn).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userInfo).toEqual({ email: 'user@example.com' }); + }); + }); + }); }); diff --git a/packages/sdk-react/src/components/providers/FusionAuthProvider.tsx b/packages/sdk-react/src/components/providers/FusionAuthProvider.tsx index 45d8fec..6d27961 100644 --- a/packages/sdk-react/src/components/providers/FusionAuthProvider.tsx +++ b/packages/sdk-react/src/components/providers/FusionAuthProvider.tsx @@ -4,6 +4,7 @@ import { useMemo, useState, useRef, + useCallback, } from 'react'; import { SDKConfig, SDKCore } from '@fusionauth-sdk/core'; @@ -14,6 +15,7 @@ import { useRedirecting, useUserInfo, useCookieAdapter, + useDpop, } from './hooks'; import { FusionAuthContext, UserInfo as DefaultUserInfo } from './Context'; import { FusionAuthProviderContext } from './FusionAuthProviderContext'; @@ -41,6 +43,8 @@ function FusionAuthProvider( mePath: props.mePath, accessTokenExpireCookieName: props.accessTokenExpireCookieName, onAutoRefreshFailure: props.onAutoRefreshFailure, + useDpop: props.useDpop, + dpopTokenStorage: props.dpopTokenStorage, }), [ props.serverUrl, @@ -60,6 +64,8 @@ function FusionAuthProvider( props.mePath, props.accessTokenExpireCookieName, props.onAutoRefreshFailure, + props.useDpop, + props.dpopTokenStorage, ], ); @@ -96,12 +102,17 @@ function FusionAuthProvider( const [isLoggedIn, setIsLoggedIn] = useState(core.isLoggedIn); + const syncIsLoggedIn = useCallback(() => { + setIsLoggedIn(core.isLoggedIn); + }, [core]); + const { manageAccount, startLogin, startLogout, startRegister } = - useRedirecting(core, config.onRedirect); + useRedirecting(core, config.onRedirect, syncIsLoggedIn); const { isFetchingUserInfo, userInfo, fetchUserInfo, error } = useUserInfo( core, config.shouldAutoFetchUserInfo ?? false, + isLoggedIn, ); const { refreshToken, initAutoRefresh } = useTokenRefresh( @@ -109,6 +120,11 @@ function FusionAuthProvider( config.shouldAutoRefresh ?? false, ); + const { dpopFetch, generateProof, getAccessToken } = useDpop( + core, + config.useDpop ?? false, + ); + const providerValue: FusionAuthProviderContext = { startLogin, startRegister, @@ -121,6 +137,9 @@ function FusionAuthProvider( initAutoRefresh, fetchUserInfo, manageAccount, + dpopFetch, + generateProof, + getAccessToken, }; return ( diff --git a/packages/sdk-react/src/components/providers/FusionAuthProviderConfig.ts b/packages/sdk-react/src/components/providers/FusionAuthProviderConfig.ts index 6161bb3..011328d 100644 --- a/packages/sdk-react/src/components/providers/FusionAuthProviderConfig.ts +++ b/packages/sdk-react/src/components/providers/FusionAuthProviderConfig.ts @@ -96,4 +96,17 @@ export interface FusionAuthProviderConfig { * Only set this if you are hosting server that uses a custom name for the 'app.at_exp' cookie. */ accessTokenExpireCookieName?: string; + + /** + * Opt-in to DPoP mode. When `true`, the SDK calls FusionAuth endpoints + * directly and stores tokens in JavaScript-accessible storage instead of + * relying on the Hosted Backend's HttpOnly cookies. Defaults to `false`. + */ + useDpop?: boolean; + + /** + * Token storage location in DPoP mode. Only meaningful when `useDpop: true`. + * Defaults to `'localStorage'`. + */ + dpopTokenStorage?: 'localStorage' | 'memory'; } diff --git a/packages/sdk-react/src/components/providers/FusionAuthProviderContext.ts b/packages/sdk-react/src/components/providers/FusionAuthProviderContext.ts index fde4193..e761964 100644 --- a/packages/sdk-react/src/components/providers/FusionAuthProviderContext.ts +++ b/packages/sdk-react/src/components/providers/FusionAuthProviderContext.ts @@ -62,4 +62,31 @@ export interface FusionAuthProviderContext { * This is handled automatically if the SDK is configured with `shouldAutoRefresh`. */ initAutoRefresh: () => void; + + /** + * Fetch wrapper that automatically attaches DPoP proof headers. + * Present only when `useDpop: true`. + */ + dpopFetch?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise; + + /** + * Returns a signed DPoP proof JWT for use with axios or other + * HTTP libraries. Present only when `useDpop: true`. + */ + generateProof?: ( + htu: string, + htm: string, + accessToken?: string, + nonce?: string, + ) => Promise; + + /** + * Returns the stored DPoP access token, or `null` if not logged in. + * Throws a descriptive error when `useDpop: false`. + * Present only when `useDpop: true`. + */ + getAccessToken?: () => string | null; } diff --git a/packages/sdk-react/src/components/providers/hooks/index.ts b/packages/sdk-react/src/components/providers/hooks/index.ts index 6dd7c14..49d9ebf 100644 --- a/packages/sdk-react/src/components/providers/hooks/index.ts +++ b/packages/sdk-react/src/components/providers/hooks/index.ts @@ -2,3 +2,4 @@ export * from './useRedirecting'; export * from './useTokenRefresh'; export * from './useUserInfo'; export * from './useCookieAdapter'; +export * from './useDpop'; diff --git a/packages/sdk-react/src/components/providers/hooks/useDpop.ts b/packages/sdk-react/src/components/providers/hooks/useDpop.ts new file mode 100644 index 0000000..c818cd2 --- /dev/null +++ b/packages/sdk-react/src/components/providers/hooks/useDpop.ts @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; +import { SDKCore } from '@fusionauth-sdk/core'; + +/** + * Exposes `dpopFetch`, `generateProof`, and `getAccessToken` from `core` + * when DPoP mode is enabled. Each is `undefined` when `useDpop` is false, + * matching `FusionAuthProviderContext`'s optional fields. + */ +export function useDpop(core: SDKCore, enabled: boolean) { + const dpopFetch = useCallback( + (input: RequestInfo | URL, init?: RequestInit) => + core.dpopFetch(input, init), + [core], + ); + + const generateProof = useCallback( + (htu: string, htm: string, accessToken?: string, nonce?: string) => + core.generateProof(htu, htm, accessToken, nonce), + [core], + ); + + const getAccessToken = useCallback(() => core.getAccessToken(), [core]); + + if (!enabled) { + return { + dpopFetch: undefined, + generateProof: undefined, + getAccessToken: undefined, + }; + } + + return { dpopFetch, generateProof, getAccessToken }; +} diff --git a/packages/sdk-react/src/components/providers/hooks/useRedirecting.ts b/packages/sdk-react/src/components/providers/hooks/useRedirecting.ts index 4635c52..80d6dd7 100644 --- a/packages/sdk-react/src/components/providers/hooks/useRedirecting.ts +++ b/packages/sdk-react/src/components/providers/hooks/useRedirecting.ts @@ -4,6 +4,7 @@ import { SDKCore } from '@fusionauth-sdk/core'; export function useRedirecting( core: SDKCore, onRedirect?: (state?: string) => void, + onPostRedirectSettled?: () => void, ) { const manageAccount = useCallback(() => core.manageAccount(), [core]); const startLogin = useCallback( @@ -17,8 +18,10 @@ export function useRedirecting( const startLogout = useCallback(() => core.startLogout(), [core]); useEffect(() => { - core.handlePostRedirect(onRedirect); - }, [core, onRedirect]); + core.handlePostRedirect(onRedirect).then(() => { + onPostRedirectSettled?.(); + }); + }, [core, onRedirect, onPostRedirectSettled]); return { manageAccount, diff --git a/packages/sdk-react/src/components/providers/hooks/useUserInfo.ts b/packages/sdk-react/src/components/providers/hooks/useUserInfo.ts index 3bb0569..cc18dfc 100644 --- a/packages/sdk-react/src/components/providers/hooks/useUserInfo.ts +++ b/packages/sdk-react/src/components/providers/hooks/useUserInfo.ts @@ -5,6 +5,7 @@ import { SDKCore } from '@fusionauth-sdk/core'; export function useUserInfo( core: SDKCore, shouldAutoFetchUserInfo: boolean, + isLoggedIn: boolean, ) { const [isFetchingUserInfo, setIsFetchingUserInfo] = useState(false); const [userInfo, setUserInfo] = useState(null); @@ -28,17 +29,19 @@ export function useUserInfo( const didAttemptAutoFetch = useRef(false); const handleAutoFetch = useCallback(() => { - if (!shouldAutoFetchUserInfo || didAttemptAutoFetch.current) { + if ( + !shouldAutoFetchUserInfo || + didAttemptAutoFetch.current || + !isLoggedIn + ) { return; } // ensures this effect does not run multiple times if we fail to fetch the user didAttemptAutoFetch.current = true; - if (core.isLoggedIn) { - fetchUserInfo(); - } - }, [core, fetchUserInfo, shouldAutoFetchUserInfo]); + fetchUserInfo(); + }, [fetchUserInfo, shouldAutoFetchUserInfo, isLoggedIn]); useEffect(() => { handleAutoFetch(); diff --git a/packages/sdk-react/src/testing-tools/mocks/createContextMock.ts b/packages/sdk-react/src/testing-tools/mocks/createContextMock.ts index bb14de7..d416ef0 100644 --- a/packages/sdk-react/src/testing-tools/mocks/createContextMock.ts +++ b/packages/sdk-react/src/testing-tools/mocks/createContextMock.ts @@ -19,4 +19,7 @@ export const createContextMock = ( function () { return Promise.resolve({}); }, + dpopFetch: context.dpopFetch, + generateProof: context.generateProof, + getAccessToken: context.getAccessToken, }); diff --git a/packages/sdk-vue/CHANGES.md b/packages/sdk-vue/CHANGES.md index 7d4b400..d3c91a7 100644 --- a/packages/sdk-vue/CHANGES.md +++ b/packages/sdk-vue/CHANGES.md @@ -1,5 +1,9 @@ fusionauth-vue-sdk Changes +Changes in 1.4.0 + +- Added support for DPoP mode. Configure with `useDpop: true` and `dpopTokenStorage`. `useFusionAuth()` now exposes `dpopFetch`, `generateProof`, and `getAccessToken` when DPoP mode is enabled. + Changes in 1.3.0 - Upgraded to Vue 3.5.38. The minimum supported Vue peer dependency is now `>=3.5.0`. diff --git a/packages/sdk-vue/README.md b/packages/sdk-vue/README.md index cf3c2ef..ae8333d 100644 --- a/packages/sdk-vue/README.md +++ b/packages/sdk-vue/README.md @@ -11,6 +11,7 @@ An SDK for using FusionAuth in Vue applications. - [Configuring with Nuxt](#configuring-with-nuxt) - [useFusionAuth Composable](#usefusionauth-composable) - [State parameter](#state-parameter) + - [DPoP Mode](#dpop-mode) - [UI Components](#ui-components) - [Protecting Content](#protecting-content) - [Pre-built buttons](#pre-built-buttons) @@ -98,6 +99,7 @@ const config: FusionAuthConfig = { shouldAutoFetchUserInfo: true, // Automatically fetch userInfo when logged in. Defaults to false. shouldAutoRefresh: true, // Enables automatic token refresh. Defaults to false. onRedirect: (state?: string) => { }, // Optional callback invoked upon redirect back from login or register. + // useDpop: true, // Opt-in to DPoP mode. See "DPoP Mode" below. Defaults to false. } const app = createApp(App); @@ -192,6 +194,48 @@ const welcomeMessage = computed(() => { The `login` and `register` functions accept an optional string parameter: `state`, which will be passed back to the optional `onRedirect` callback specified on your `FusionAuthConfig`. Though you may pass any value you would like for the state parameter, it is often used to indicate which page the user was on before redirecting to login or registration, so that the user can be returned to that location after a successful authentication. +#### DPoP Mode + +By default, the SDK calls a Hosted Backend that stores tokens in HttpOnly cookies (`useDpop: false`, the default). In DPoP mode, the SDK instead calls FusionAuth endpoints directly and binds tokens to a private key generated in the browser. Enable it by setting `useDpop: true` on `FusionAuthConfig`: + +```typescript +const config: FusionAuthConfig = { + clientId: "", + redirectUri: "", + serverUrl: "", + useDpop: true, // Opt-in to DPoP mode. + dpopTokenStorage: 'localStorage', // 'localStorage' (default, persists across reloads) or 'memory'. +} +``` + +When `useDpop: true`, `useFusionAuth()` additionally returns `dpopFetch`, `generateProof`, and `getAccessToken`. These are `undefined` when `useDpop` is `false` or not set. + +```html + +``` + +In DPoP mode, the login/register redirect round trip finishes asynchronously (there's no Hosted Backend to set cookies before the app reloads). `onRedirect` fires only after `isLoggedIn` and tokens are fully updated, so it's a reliable place to hook in post-login navigation: + +```typescript +const config: FusionAuthConfig = { + // ... + useDpop: true, + onRedirect: () => router.push('/account'), +} +``` + ### UI Components #### Protecting Content diff --git a/packages/sdk-vue/package.json b/packages/sdk-vue/package.json index 6f79ca5..5604ac5 100644 --- a/packages/sdk-vue/package.json +++ b/packages/sdk-vue/package.json @@ -1,6 +1,6 @@ { "name": "@fusionauth/vue-sdk", - "version": "1.3.0", + "version": "1.4.0", "description": "FusionAuth solves the problem of building essential security without adding risk or distracting from your primary application", "type": "module", "scripts": { diff --git a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts index f8b8a37..55512ea 100644 --- a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts +++ b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts @@ -6,6 +6,7 @@ import { mockWindowLocation, mockIsLoggedIn, removeAt_expCookie, + DPoPManager, } from '@fusionauth-sdk/core'; const config: FusionAuthConfig = { @@ -15,6 +16,28 @@ const config: FusionAuthConfig = { scope: 'openid offline_access', }; +/** Seeds `localStorage` with a valid, unexpired DPoP token set for `clientId`. */ +function seedDpopTokens( + clientId: string, + overrides: Partial<{ + accessToken: string; + refreshToken: string | undefined; + expiresAt: number; + tokenType: string; + }> = {}, +) { + localStorage.setItem( + `fusionauth-sdk:tokens:${clientId}`, + JSON.stringify({ + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + expiresAt: Date.now() + 60_000, + tokenType: 'DPoP', + ...overrides, + }), + ); +} + describe('createFusionAuth', () => { afterEach(() => { removeAt_expCookie(); @@ -187,4 +210,170 @@ describe('createFusionAuth', () => { expect(mockedLocation.assign).toHaveBeenCalledWith(expectedUrl); }); + + describe('DPoP mode', () => { + it('dpopFetch, generateProof, and getAccessToken are functions when useDpop: true', () => { + const fusionAuth = createFusionAuth({ ...config, useDpop: true }); + + expect(typeof fusionAuth.dpopFetch).toBe('function'); + expect(typeof fusionAuth.generateProof).toBe('function'); + expect(typeof fusionAuth.getAccessToken).toBe('function'); + }); + + it('dpopFetch, generateProof, and getAccessToken are undefined when useDpop is not set', () => { + const fusionAuth = createFusionAuth(config); + + expect(fusionAuth.dpopFetch).toBeUndefined(); + expect(fusionAuth.generateProof).toBeUndefined(); + expect(fusionAuth.getAccessToken).toBeUndefined(); + }); + + it('getAccessToken() returns the stored access token after login', () => { + seedDpopTokens(config.clientId, { + accessToken: 'mock-stored-access-token', + }); + + const fusionAuth = createFusionAuth({ ...config, useDpop: true }); + + expect(fusionAuth.getAccessToken?.()).toBe('mock-stored-access-token'); + }); + + it('getAccessToken() returns null when logged out', () => { + const fusionAuth = createFusionAuth({ ...config, useDpop: true }); + + expect(fusionAuth.getAccessToken?.()).toBeNull(); + }); + + it('isLoggedIn flips to true once the post-redirect DPoP token exchange settles', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + 'mock-dpop-proof-jwt', + ); + mockWindowLocation(vi, '?code=mock-authorization-code'); + localStorage.setItem( + 'fa-sdk-redirect-value', + JSON.stringify({ codeVerifier: 'mock-code-verifier' }), + ); + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 3600, + token_type: 'DPoP', + }), + { status: 200 }, + ), + ); + + const fusionAuth = createFusionAuth({ ...config, useDpop: true }); + + expect(fusionAuth.isLoggedIn.value).toBe(false); + + await vi.waitFor(() => { + expect(fusionAuth.isLoggedIn.value).toBe(true); + }); + expect(fusionAuth.getAccessToken?.()).toBe('mock-access-token'); + }); + + it('onRedirect is invoked after isLoggedIn is already true, so a handler that navigates based on isLoggedIn.value (e.g. a router guard) sees the up-to-date value', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + 'mock-dpop-proof-jwt', + ); + mockWindowLocation(vi, '?code=mock-authorization-code'); + localStorage.setItem( + 'fa-sdk-redirect-value', + JSON.stringify({ + codeVerifier: 'mock-code-verifier', + state: 'redirect-state', + }), + ); + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 3600, + token_type: 'DPoP', + }), + { status: 200 }, + ), + ); + + let isLoggedInDuringOnRedirect: boolean | undefined; + const onRedirect = vi.fn((state?: string) => { + isLoggedInDuringOnRedirect = fusionAuth.isLoggedIn.value; + expect(state).toBe('redirect-state'); + }); + + const fusionAuth = createFusionAuth({ + ...config, + useDpop: true, + onRedirect, + }); + + await vi.waitFor(() => expect(onRedirect).toHaveBeenCalledOnce()); + + expect(isLoggedInDuringOnRedirect).toBe(true); + }); + + it('shouldAutoFetchUserInfo fetches userInfo once isLoggedIn flips to true after the DPoP redirect settles (not just at construction)', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + 'mock-dpop-proof-jwt', + ); + mockWindowLocation(vi, '?code=mock-authorization-code'); + localStorage.setItem( + 'fa-sdk-redirect-value', + JSON.stringify({ codeVerifier: 'mock-code-verifier' }), + ); + + // First response is the code exchange (/oauth2/token); second is the + // subsequent /oauth2/userinfo call triggered by shouldAutoFetchUserInfo. + vi.spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 3600, + token_type: 'DPoP', + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ email: 'user@example.com' }), { + status: 200, + }), + ); + + const fusionAuth = createFusionAuth({ + ...config, + useDpop: true, + shouldAutoFetchUserInfo: true, + }); + + expect(fusionAuth.isLoggedIn.value).toBe(false); + + await vi.waitFor(() => { + expect(fusionAuth.isLoggedIn.value).toBe(true); + }); + + // userInfo only becomes available asynchronously, well after + // construction — it is not fetched until the DPoP redirect settles. + await vi.waitFor(() => { + expect(fusionAuth.userInfo.value).toEqual({ + email: 'user@example.com', + }); + }); + }); + }); }); diff --git a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts index 0bc8522..9bcc832 100644 --- a/packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts +++ b/packages/sdk-vue/src/createFusionAuth/createFusionAuth.ts @@ -63,15 +63,54 @@ export const createFusionAuth = ( core.manageAccount(); } - if (config.shouldAutoFetchUserInfo && core.isLoggedIn === true) { + async function dpopFetch(input: RequestInfo | URL, init?: RequestInit) { + return core.dpopFetch(input, init); + } + + async function generateProof( + htu: string, + htm: string, + accessToken?: string, + nonce?: string, + ) { + return core.generateProof(htu, htm, accessToken, nonce); + } + + function getAccessToken() { + return core.getAccessToken(); + } + + let didAttemptAutoFetch = false; + + function syncIsLoggedIn() { + isLoggedIn.value = core.isLoggedIn; + } + + function maybeAutoFetchUserInfo() { + if ( + !config.shouldAutoFetchUserInfo || + didAttemptAutoFetch || + !core.isLoggedIn + ) { + return; + } + + // ensures this does not run multiple times if we fail to fetch the user + didAttemptAutoFetch = true; getUserInfo(); } + maybeAutoFetchUserInfo(); + if (config.shouldAutoRefresh && core.isLoggedIn === true) { core.initAutoRefresh(); } - core.handlePostRedirect(config.onRedirect); + core.handlePostRedirect(state => { + syncIsLoggedIn(); + maybeAutoFetchUserInfo(); + config.onRedirect?.(state); + }); return { isLoggedIn, @@ -85,5 +124,8 @@ export const createFusionAuth = ( manageAccount, refreshToken, initAutoRefresh, + dpopFetch: config.useDpop ? dpopFetch : undefined, + generateProof: config.useDpop ? generateProof : undefined, + getAccessToken: config.useDpop ? getAccessToken : undefined, }; }; diff --git a/packages/sdk-vue/src/types.ts b/packages/sdk-vue/src/types.ts index fecd5c1..39bd545 100644 --- a/packages/sdk-vue/src/types.ts +++ b/packages/sdk-vue/src/types.ts @@ -79,6 +79,19 @@ export interface FusionAuthConfig { * The path to the me endpoint. */ mePath?: string; + + /** + * Opt-in to DPoP mode. When `true`, the SDK calls FusionAuth endpoints + * directly and stores tokens in JavaScript-accessible storage instead of + * relying on the Hosted Backend's HttpOnly cookies. Defaults to `false`. + */ + useDpop?: boolean; + + /** + * Token storage location in DPoP mode. Only meaningful when `useDpop: true`. + * Defaults to `'localStorage'`. + */ + dpopTokenStorage?: 'localStorage' | 'memory'; } /** @@ -167,4 +180,31 @@ export interface FusionAuth { * Refresh is scheduled to happen at the configured `autoRefreshSecondsBeforeExpiry`. */ initAutoRefresh: () => NodeJS.Timeout | undefined; + + /** + * Fetch wrapper that automatically attaches DPoP proof headers. + * Present only when `useDpop: true`. + */ + dpopFetch?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise; + + /** + * Returns a signed DPoP proof JWT for use with axios or other + * HTTP libraries. Present only when `useDpop: true`. + */ + generateProof?: ( + htu: string, + htm: string, + accessToken?: string, + nonce?: string, + ) => Promise; + + /** + * Returns the stored DPoP access token, or `null` if not logged in. + * Throws a descriptive error when `useDpop: false`. + * Present only when `useDpop: true`. + */ + getAccessToken?: () => string | null; } diff --git a/packages/sdk-vue/web-types.json b/packages/sdk-vue/web-types.json index 0573328..276e218 100644 --- a/packages/sdk-vue/web-types.json +++ b/packages/sdk-vue/web-types.json @@ -1,7 +1,7 @@ { "framework": "vue", "name": "@fusionauth/vue-sdk", - "version": "1.3.0", + "version": "1.4.0", "contributions": { "html": { "description-markup": "markdown", diff --git a/playwright.config.ts b/playwright.config.ts index 6857c98..8217059 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', + testIgnore: ['**/dpop-endpoints.test.ts'], /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/playwright.dpop-endpoints.config.ts b/playwright.dpop-endpoints.config.ts new file mode 100644 index 0000000..3edea71 --- /dev/null +++ b/playwright.dpop-endpoints.config.ts @@ -0,0 +1,40 @@ +/** + * Playwright config for DPoP endpoint tests. + */ + +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/dpop-endpoints.test.ts', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + use: { + baseURL: `http://localhost:${process.env.PORT}`, + screenshot: 'on', + }, + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + webServer: { + command: `${process.env.SERVER_COMMAND}`, + url: `http://localhost:${process.env.PORT}`, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/playwright.dpop.config.ts b/playwright.dpop.config.ts deleted file mode 100644 index cfcddce..0000000 --- a/playwright.dpop.config.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Playwright config for DPoP smoke tests. - * - * Unlike the main playwright.config.ts, this does NOT require a running - * quickstart app (no SERVER_COMMAND / PORT env vars). The smoke tests talk - * directly to FusionAuth at http://localhost:9011 and bypass SDKCore entirely. - * - * Usage: - * npx playwright test e2e/tests/dpop-smoke.test.ts \ - * --config playwright.dpop.config.ts - */ - -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './e2e', - testMatch: '**/dpop-smoke.test.ts', - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: 0, - workers: 1, - use: { - screenshot: 'on', - // No baseURL — tests construct all URLs explicitly using FA_URL. - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], -}); diff --git a/yarn.lock b/yarn.lock index 7dd325c..dac7ce9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1275,13 +1275,6 @@ dependencies: minipass "^7.0.4" -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== - dependencies: - "@sinclair/typebox" "^0.27.8" - "@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" @@ -3027,11 +3020,6 @@ dependencies: "@simple-git/args-pathspec" "^1.0.3" -"@sinclair/typebox@^0.27.8": - version "0.27.12" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.12.tgz#0cacd3cff047a32936b1ace47ea7c86eaab60a7f" - integrity sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g== - "@sindresorhus/is@^7.0.2": version "7.2.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-7.2.0.tgz#7c594e1a64336d2008d99d814056d459421504d4" @@ -3443,15 +3431,6 @@ dependencies: "@rolldown/pluginutils" "^1.0.1" -"@vitest/expect@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.6.1.tgz#b90c213f587514a99ac0bf84f88cff9042b0f14d" - integrity sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog== - dependencies: - "@vitest/spy" "1.6.1" - "@vitest/utils" "1.6.1" - chai "^4.3.10" - "@vitest/expect@3.2.7": version "3.2.7" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.7.tgz#70a34158383d008c3bf5d802e2643317f09df6d8" @@ -3507,15 +3486,6 @@ dependencies: tinyrainbow "^3.1.0" -"@vitest/runner@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.6.1.tgz#10f5857c3e376218d58c2bfacfea1161e27e117f" - integrity sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA== - dependencies: - "@vitest/utils" "1.6.1" - p-limit "^5.0.0" - pathe "^1.1.1" - "@vitest/runner@3.2.7": version "3.2.7" resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.7.tgz#c0c080228189f1fa6cda40f59be09d746b0aca51" @@ -3533,15 +3503,6 @@ "@vitest/utils" "4.1.10" pathe "^2.0.3" -"@vitest/snapshot@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.6.1.tgz#90414451a634bb36cd539ccb29ae0d048a8c0479" - integrity sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ== - dependencies: - magic-string "^0.30.5" - pathe "^1.1.1" - pretty-format "^29.7.0" - "@vitest/snapshot@3.2.7": version "3.2.7" resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.7.tgz#a3a7e1950ce99ec4cf02395e20ddca403b6c818e" @@ -3561,13 +3522,6 @@ magic-string "^0.30.21" pathe "^2.0.3" -"@vitest/spy@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.6.1.tgz#33376be38a5ed1ecd829eb986edaecc3e798c95d" - integrity sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw== - dependencies: - tinyspy "^2.2.0" - "@vitest/spy@3.2.7": version "3.2.7" resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.7.tgz#ca7fbee44019523ca450395d9a2284ce9ece1f31" @@ -3580,16 +3534,6 @@ resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.10.tgz#5c0bfa97b56bba9e37403c976db776ff6ab56f65" integrity sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw== -"@vitest/utils@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.6.1.tgz#6d2f36cb6d866f2bbf59da854a324d6bf8040f17" - integrity sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g== - dependencies: - diff-sequences "^29.6.3" - estree-walker "^3.0.3" - loupe "^2.3.7" - pretty-format "^29.7.0" - "@vitest/utils@3.2.7": version "3.2.7" resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.7.tgz#302c8126211ac4dfea87b3b5085c098d6d22e89e" @@ -3905,19 +3849,12 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.3.2: - version "8.3.5" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.5.tgz#8a6b8ca8fc5b34685af15dabb44118663c296496" - integrity sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw== - dependencies: - acorn "^8.11.0" - acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.11.0, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.6.0, acorn@^8.9.0: +acorn@^8.15.0, acorn@^8.16.0, acorn@^8.6.0, acorn@^8.9.0: version "8.18.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== @@ -4229,11 +4166,6 @@ assert-never@^1.2.1: resolved "https://registry.yarnpkg.com/assert-never/-/assert-never-1.4.0.tgz#b0d4988628c87f35eb94716cc54422a63927e175" integrity sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA== -assertion-error@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" - integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== - assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -4365,9 +4297,9 @@ base64-js@^1.3.1: integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== baseline-browser-mapping@^2.10.44: - version "2.11.5" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz#eca24c8ff0c24fcedef86260e2aae8c76b8dbce8" - integrity sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A== + version "2.11.6" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz#56934c812026ae4fcdb039fc790a94b9a7d81d63" + integrity sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw== beasties@0.4.2: version "0.4.2" @@ -4581,19 +4513,6 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001806: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz#1bc8e502b723fa393455dfbedd5ccec0c29bb74e" integrity sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw== -chai@^4.3.10: - version "4.5.0" - resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8" - integrity sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== - dependencies: - assertion-error "^1.1.0" - check-error "^1.0.3" - deep-eql "^4.1.3" - get-func-name "^2.0.2" - loupe "^2.3.6" - pathval "^1.1.1" - type-detect "^4.1.0" - chai@^5.2.0: version "5.3.3" resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" @@ -4635,13 +4554,6 @@ chardet@^2.1.1: resolved "https://registry.yarnpkg.com/chardet/-/chardet-2.2.0.tgz#005d664f2cbd4961888d2e2c32c5a69e59d8eec4" integrity sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA== -check-error@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" - integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== - dependencies: - get-func-name "^2.0.2" - check-error@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5" @@ -5178,13 +5090,6 @@ decimal.js@^10.4.3: resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== -deep-eql@^4.1.3: - version "4.1.4" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.4.tgz#d0d3912865911bb8fac5afb4e3acfa6a28dc72b7" - integrity sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== - dependencies: - type-detect "^4.0.0" - deep-eql@^5.0.1: version "5.0.2" resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" @@ -5305,11 +5210,6 @@ devalue@^5.8.2: resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.8.2.tgz#b09644fa07acb1e21fe1f841e0c84e328b084196" integrity sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA== -diff-sequences@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== - diff@^8.0.4, diff@~8.0.2: version "8.0.4" resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.4.tgz#4f5baf3188b9b2431117b962eb20ba330fadf696" @@ -6429,11 +6329,6 @@ get-east-asian-width@^1.0.0, get-east-asian-width@^1.3.1, get-east-asian-width@^ resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9" integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA== -get-func-name@^2.0.1, get-func-name@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" - integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== - get-intrinsic@^1.1.3, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" @@ -7734,14 +7629,6 @@ loader-utils@^3.2.0: resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.3.1.tgz#735b9a19fd63648ca7adbd31c2327dfe281304e5" integrity sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg== -local-pkg@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.1.tgz#69658638d2a95287534d4c2fff757980100dbb6d" - integrity sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ== - dependencies: - mlly "^1.7.3" - pkg-types "^1.2.1" - local-pkg@^1.0.0, local-pkg@^1.1.2: version "1.2.1" resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-1.2.1.tgz#9628389399851d78f3b50c9236eddb02f0c31b2b" @@ -7819,13 +7706,6 @@ loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -loupe@^2.3.6, loupe@^2.3.7: - version "2.3.7" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" - integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== - dependencies: - get-func-name "^2.0.1" - loupe@^3.1.0, loupe@^3.1.4: version "3.2.1" resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" @@ -7887,7 +7767,7 @@ magic-string-ast@^1.0.2: dependencies: magic-string "^0.30.19" -magic-string@0.30.21, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.5, magic-string@^0.30.8: +magic-string@0.30.21, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.8: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -8140,7 +8020,7 @@ mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mlly@^1.3.0, mlly@^1.7.3, mlly@^1.7.4, mlly@^1.8.0, mlly@^1.8.2: +mlly@^1.3.0, mlly@^1.7.4, mlly@^1.8.0, mlly@^1.8.2: version "1.8.2" resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.2.tgz#e7f7919a82d13b174405613117249a3f449d78bb" integrity sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA== @@ -8430,9 +8310,9 @@ node-gyp@^12.1.0: which "^6.0.0" node-mock-http@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/node-mock-http/-/node-mock-http-1.0.4.tgz#21f2ab4ce2fe4fbe8a660d7c5195a1db85e042a4" - integrity sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ== + version "1.0.5" + resolved "https://registry.yarnpkg.com/node-mock-http/-/node-mock-http-1.0.5.tgz#497bc2f3dd208e6e3f7c06cbb4a3425f899fe818" + integrity sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw== node-releases@^2.0.51: version "2.0.51" @@ -8922,13 +8802,6 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-limit@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-5.0.0.tgz#6946d5b7140b649b7a33a027d89b4c625b3a5985" - integrity sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ== - dependencies: - yocto-queue "^1.0.0" - p-locate@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" @@ -9082,11 +8955,6 @@ pathe@^2.0.1, pathe@^2.0.3: resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -pathval@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" - integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== - pathval@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" @@ -9097,7 +8965,7 @@ perfect-debounce@^2.0.0, perfect-debounce@^2.1.0: resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz#e7078e38f231cb191855c3136a4423aef725d261" integrity sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g== -picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.1: +picocolors@1.1.1, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -9148,7 +9016,7 @@ pkg-dir@^8.0.0: dependencies: find-up-simple "^1.0.0" -pkg-types@^1.2.1, pkg-types@^1.3.1: +pkg-types@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== @@ -9447,15 +9315,6 @@ pretty-format@^27.0.2: ansi-styles "^5.0.0" react-is "^17.0.1" -pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== - dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - probe-image-size@^7.2.3: version "7.3.0" resolved "https://registry.yarnpkg.com/probe-image-size/-/probe-image-size-7.3.0.tgz#a07df2e2cffc1057026d5a1bda3a5b11ee970034" @@ -9706,11 +9565,6 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -react-is@^18.0.0: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" - integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== - react@^19.2.0: version "19.2.8" resolved "https://registry.yarnpkg.com/react/-/react-19.2.8.tgz#a80663dbb58d69c6fe3fd291d3cb324e8a7dff2d" @@ -10517,7 +10371,7 @@ statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -std-env@^3.5.0, std-env@^3.9.0: +std-env@^3.9.0: version "3.10.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== @@ -10722,13 +10576,6 @@ strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -strip-literal@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-2.1.1.tgz#26906e65f606d49f748454a08084e94190c2e5ad" - integrity sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q== - dependencies: - js-tokens "^9.0.1" - strip-literal@^3.0.0, strip-literal@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" @@ -10856,7 +10703,7 @@ tiny-invariant@^1.3.3: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== -tinybench@^2.5.1, tinybench@^2.9.0: +tinybench@^2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== @@ -10892,11 +10739,6 @@ tinyglobby@^0.2.12, tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, fdir "^6.5.0" picomatch "^4.0.4" -tinypool@^0.8.3: - version "0.8.4" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.4.tgz#e217fe1270d941b39e98c625dcecebb1408c9aa8" - integrity sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ== - tinypool@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" @@ -10912,11 +10754,6 @@ tinyrainbow@^3.1.0: resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== -tinyspy@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.1.tgz#117b2342f1f38a0dbdcc73a50a454883adf861d1" - integrity sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A== - tinyspy@^4.0.3: version "4.0.4" resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.4.tgz#d77a002fb53a88aa1429b419c1c92492e0c81f78" @@ -11026,11 +10863,6 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-detect@^4.0.0, type-detect@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" - integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== - type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" @@ -11416,17 +11248,6 @@ vite-hot-client@^2.2.0: resolved "https://registry.yarnpkg.com/vite-hot-client/-/vite-hot-client-2.2.0.tgz#284fa1afa63cc8781d079515884eb49d2890ac2f" integrity sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w== -vite-node@1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.6.1.tgz#fff3ef309296ea03ceaa6ca4bb660922f5416c57" - integrity sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA== - dependencies: - cac "^6.7.14" - debug "^4.3.4" - pathe "^1.1.1" - picocolors "^1.0.0" - vite "^5.0.0" - vite-node@3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" @@ -11530,7 +11351,7 @@ vite@7.3.6, "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^7.3.1, vite@^7.3.6: optionalDependencies: fsevents "~2.3.3" -vite@^5.0.0, vite@^5.0.12, vite@^5.2.0, vite@^5.4.17: +vite@^5.0.12, vite@^5.2.0, vite@^5.4.17: version "5.4.21" resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== @@ -11568,32 +11389,6 @@ vite@^6.0.0: optionalDependencies: fsevents "~2.3.3" -vitest@^1.2.1, vitest@^1.4.0, vitest@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.6.1.tgz#b4a3097adf8f79ac18bc2e2e0024c534a7a78d2f" - integrity sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag== - dependencies: - "@vitest/expect" "1.6.1" - "@vitest/runner" "1.6.1" - "@vitest/snapshot" "1.6.1" - "@vitest/spy" "1.6.1" - "@vitest/utils" "1.6.1" - acorn-walk "^8.3.2" - chai "^4.3.10" - debug "^4.3.4" - execa "^8.0.1" - local-pkg "^0.5.0" - magic-string "^0.30.5" - pathe "^1.1.1" - picocolors "^1.0.0" - std-env "^3.5.0" - strip-literal "^2.0.0" - tinybench "^2.5.1" - tinypool "^0.8.3" - vite "^5.0.0" - vite-node "1.6.1" - why-is-node-running "^2.2.2" - vitest@^3.2.6: version "3.2.7" resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.7.tgz#1944b6ed013a25fd26a73d18e1af92c10a57af6c" @@ -11893,7 +11688,7 @@ which@^6.0.0, which@^6.0.1: dependencies: isexe "^4.0.0" -why-is-node-running@^2.2.2, why-is-node-running@^2.3.0: +why-is-node-running@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== @@ -12044,11 +11839,6 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yocto-queue@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.2.tgz#3e09c95d3f1aa89a58c114c99223edf639152c00" - integrity sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ== - yoctocolors@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.2.0.tgz#c4b68ee477f3982c33e4de24a5aa4a354be506d3"