From 1e394593b8873af046de8a4f49ba2c6409061fda Mon Sep 17 00:00:00 2001 From: Ryan Bas Date: Wed, 8 Apr 2026 20:01:31 -0600 Subject: [PATCH] feat: SSR compatibility for journey-client and oidc-client (PoC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make journey-client and oidc-client importable and usable in Node.js/SSR environments by eliminating eager browser global references and decoupling PKCE generation from sessionStorage. Storage: Replace eager sessionStorage/localStorage references with lazy globalThis access via getBrowserStorage(). Add configurable storage option to JourneyClientConfig so SSR callers can provide a custom noop adapter. PKCE: Decouple generation from storage — createAuthorizeUrl now returns { url, verifier, state } instead of writing to sessionStorage. Callers persist PKCE values however they choose (cookies, server session, etc.). Token exchange accepts optional pkceValues parameter to skip sessionStorage. Guard redirect() with typeof window check for server environments. Export createJourneyObject for client-side step reconstitution. SvelteKit PoC in e2e/svelte-app demonstrates the full flow: server-side journey start, client-side credential submission, server-side PKCE authorize URL generation with cookie-based verifier persistence, and server-side token exchange against the AM mock API. --- .gitignore | 3 + e2e/oidc-app/src/utils/oidc-app.ts | 12 +- e2e/svelte-app/package.json | 26 ++ e2e/svelte-app/src/app.html | 12 + e2e/svelte-app/src/lib/config.ts | 17 ++ e2e/svelte-app/src/routes/+layout.svelte | 15 ++ e2e/svelte-app/src/routes/+page.server.ts | 92 +++++++ e2e/svelte-app/src/routes/+page.svelte | 219 +++++++++++++++++ .../src/routes/callback/+page.server.ts | 105 ++++++++ .../src/routes/callback/+page.svelte | 63 +++++ e2e/svelte-app/svelte.config.js | 10 + e2e/svelte-app/tsconfig.json | 22 ++ e2e/svelte-app/vite.config.ts | 6 + nx.json | 6 +- .../davinci-client/src/lib/davinci.api.ts | 4 +- packages/journey-client/src/index.ts | 1 + .../journey-client/src/lib/client.store.ts | 12 +- .../journey-client/src/lib/config.types.ts | 3 + .../src/lib/authorize.request.utils.ts | 43 +++- packages/oidc-client/src/lib/client.store.ts | 10 +- .../oidc-client/src/lib/exchange.request.ts | 12 +- .../oidc-client/src/lib/exchange.utils.ts | 19 +- packages/oidc-client/src/types.ts | 2 + .../oidc/src/lib/authorize.effects.ts | 36 ++- .../oidc/src/lib/authorize.test.ts | 49 ++-- .../oidc/src/lib/state-pkce.effects.ts | 43 ++-- .../storage/src/lib/storage.effects.ts | 25 +- pnpm-lock.yaml | 232 ++++++++++++++++++ 28 files changed, 1011 insertions(+), 88 deletions(-) create mode 100644 e2e/svelte-app/package.json create mode 100644 e2e/svelte-app/src/app.html create mode 100644 e2e/svelte-app/src/lib/config.ts create mode 100644 e2e/svelte-app/src/routes/+layout.svelte create mode 100644 e2e/svelte-app/src/routes/+page.server.ts create mode 100644 e2e/svelte-app/src/routes/+page.svelte create mode 100644 e2e/svelte-app/src/routes/callback/+page.server.ts create mode 100644 e2e/svelte-app/src/routes/callback/+page.svelte create mode 100644 e2e/svelte-app/svelte.config.js create mode 100644 e2e/svelte-app/tsconfig.json create mode 100644 e2e/svelte-app/vite.config.ts diff --git a/.gitignore b/.gitignore index b7ab27b789..6fbacabae0 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,9 @@ test-output .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md +# SvelteKit +.svelte-kit + # Gemini local knowledge base files GEMINI.md **/GEMINI.md diff --git a/e2e/oidc-app/src/utils/oidc-app.ts b/e2e/oidc-app/src/utils/oidc-app.ts index 69289580a0..fd1c0fd5c3 100644 --- a/e2e/oidc-app/src/utils/oidc-app.ts +++ b/e2e/oidc-app/src/utils/oidc-app.ts @@ -88,14 +88,14 @@ export async function oidcApp({ config, urlParams }) { }); document.getElementById('login-redirect').addEventListener('click', async () => { - const authorizeUrl = await oidcClient.authorize.url(); - if (typeof authorizeUrl !== 'string' && 'error' in authorizeUrl) { - console.error('Authorization URL Error:', authorizeUrl); - displayError(authorizeUrl); + const authorizeResult = await oidcClient.authorize.url(); + if ('error' in authorizeResult) { + console.error('Authorization URL Error:', authorizeResult); + displayError(authorizeResult); return; } else { - console.log('Authorization URL:', authorizeUrl); - window.location.assign(authorizeUrl); + console.log('Authorization URL:', authorizeResult.url); + window.location.assign(authorizeResult.url); } }); diff --git a/e2e/svelte-app/package.json b/e2e/svelte-app/package.json new file mode 100644 index 0000000000..295b6fe9a8 --- /dev/null +++ b/e2e/svelte-app/package.json @@ -0,0 +1,26 @@ +{ + "name": "@forgerock/svelte-app", + "version": "1.0.0", + "private": true, + "description": "SvelteKit SSR proof of concept for Journey Client", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@forgerock/journey-client": "workspace:*", + "@forgerock/oidc-client": "workspace:*" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^6.0.0", + "@sveltejs/kit": "^2.21.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "catalog:vite" + }, + "nx": { + "tags": ["scope:e2e"] + } +} diff --git a/e2e/svelte-app/src/app.html b/e2e/svelte-app/src/app.html new file mode 100644 index 0000000000..4c87a53dd4 --- /dev/null +++ b/e2e/svelte-app/src/app.html @@ -0,0 +1,12 @@ + + + + + + Journey Client SSR PoC + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/e2e/svelte-app/src/lib/config.ts b/e2e/svelte-app/src/lib/config.ts new file mode 100644 index 0000000000..adb1c91f07 --- /dev/null +++ b/e2e/svelte-app/src/lib/config.ts @@ -0,0 +1,17 @@ +/** + * Server configuration for the SSR proof of concept. + * Points at the AM mock API running on localhost:9443. + */ +export const WELLKNOWN_URL = + 'http://localhost:9443/am/oauth2/realms/root/.well-known/openid-configuration'; + +export const CLIENT_ID = 'SvelteSSRClient'; +export const REDIRECT_URI = 'http://localhost:5174/callback'; +export const SCOPE = 'openid profile'; + +/** No-op storage adapter for server-side usage where browser storage is unavailable. */ +export const noopStorage = { + get: async () => null, + set: async () => {}, + remove: async () => {}, +}; diff --git a/e2e/svelte-app/src/routes/+layout.svelte b/e2e/svelte-app/src/routes/+layout.svelte new file mode 100644 index 0000000000..60c87da46f --- /dev/null +++ b/e2e/svelte-app/src/routes/+layout.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children()} +
+ + diff --git a/e2e/svelte-app/src/routes/+page.server.ts b/e2e/svelte-app/src/routes/+page.server.ts new file mode 100644 index 0000000000..f22f29c470 --- /dev/null +++ b/e2e/svelte-app/src/routes/+page.server.ts @@ -0,0 +1,92 @@ +import { redirect } from '@sveltejs/kit'; +import { journey } from '@forgerock/journey-client'; +import { oidc } from '@forgerock/oidc-client'; +import { WELLKNOWN_URL, CLIENT_ID, REDIRECT_URI, SCOPE, noopStorage } from '$lib/config.js'; +import type { PageServerLoad, Actions } from './$types'; + +/** + * Server-side load function. + * + * Initializes the journey client with noop storage (no sessionStorage on server) + * and calls start() to fetch the first authentication step. The raw step payload + * is serialized and passed to the client for SSR rendering. + */ +export const load: PageServerLoad = async () => { + try { + const client = await journey({ + config: { + serverConfig: { wellknown: WELLKNOWN_URL }, + storage: { type: 'custom', name: 'journey-step', custom: noopStorage }, + }, + }); + + const result = await client.start(); + + if ('payload' in result) { + return { + stepPayload: result.payload, + error: null, + }; + } + + return { + stepPayload: null, + error: 'error' in result ? result : { error: 'unexpected', message: 'Unexpected result' }, + }; + } catch (e) { + return { + stepPayload: null, + error: { + error: 'server_init_failed', + message: e instanceof Error ? e.message : 'Failed to initialize journey client on server', + }, + }; + } +}; + +/** + * Form actions — the authorize action generates a PKCE authorize URL on the server, + * stores the verifier in a cookie, and redirects the browser to the authorize endpoint. + */ +export const actions: Actions = { + authorize: async ({ cookies }) => { + const client = await oidc({ + config: { + serverConfig: { wellknown: WELLKNOWN_URL }, + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + scope: SCOPE, + responseType: 'code', + }, + storage: { type: 'custom', name: CLIENT_ID, custom: noopStorage }, + }); + + if (!client || 'error' in client) { + return { error: 'Failed to initialize OIDC client' }; + } + + // Generate authorize URL with PKCE — returns { url, verifier, state } + const result = await client.authorize.url(); + + if ('error' in result) { + return { error: result.error }; + } + + // Store PKCE verifier + state in an httpOnly cookie for the callback route + cookies.set('pkce_verifier', result.verifier, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 300, // 5 minutes + }); + cookies.set('pkce_state', result.state, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: 300, + }); + + // Redirect browser to authorization endpoint + redirect(303, result.url); + }, +}; diff --git a/e2e/svelte-app/src/routes/+page.svelte b/e2e/svelte-app/src/routes/+page.svelte new file mode 100644 index 0000000000..28b99a5582 --- /dev/null +++ b/e2e/svelte-app/src/routes/+page.svelte @@ -0,0 +1,219 @@ + + +

Journey Client SSR PoC

+ +{#if serverError} +
+

Error: {serverError.message}

+

This is expected if the AM mock API is not running on port 9443.

+
+{:else if success} +
+

Journey complete!

+

Authentication succeeded. Now exchange for tokens via server-side PKCE:

+
+ +
+
+{:else if failure} +
+

{failure}

+
+{/if} + +{#if stepPayload?.callbacks} +
+

{stepPayload.header ?? 'Sign In'}

+ {#if stepPayload.description} +

{stepPayload.description}

+ {/if} + + {#each stepPayload.callbacks as cb (cb._id)} + {#if isTextInput(cb)} + + {:else if isPassword(cb)} + + {:else} +

Unsupported callback: {cb.type}

+ {/if} + {/each} + + +
+{:else if !serverError && !success} +

Loading...

+{/if} + + diff --git a/e2e/svelte-app/src/routes/callback/+page.server.ts b/e2e/svelte-app/src/routes/callback/+page.server.ts new file mode 100644 index 0000000000..8957d349d4 --- /dev/null +++ b/e2e/svelte-app/src/routes/callback/+page.server.ts @@ -0,0 +1,105 @@ +import { oidc } from '@forgerock/oidc-client'; +import { WELLKNOWN_URL, CLIENT_ID, REDIRECT_URI, SCOPE, noopStorage } from '$lib/config.js'; +import type { PageServerLoad } from './$types'; + +/** + * Callback route — handles the redirect from the authorization server. + * + * Reads the authorization code and state from the URL, retrieves the PKCE + * verifier from the cookie (set during authorize), and exchanges for tokens + * entirely on the server. The browser never sees the verifier or tokens directly. + */ +export const load: PageServerLoad = async ({ url, cookies }) => { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + if (error) { + return { + tokens: null, + error: { error, message: errorDescription ?? 'Authorization failed' }, + }; + } + + if (!code || !state) { + return { + tokens: null, + error: { error: 'missing_params', message: 'Missing code or state in callback URL' }, + }; + } + + // Retrieve PKCE values from httpOnly cookies + const verifier = cookies.get('pkce_verifier'); + const pkceState = cookies.get('pkce_state'); + + // Clean up cookies + cookies.delete('pkce_verifier', { path: '/' }); + cookies.delete('pkce_state', { path: '/' }); + + if (!verifier || !pkceState) { + return { + tokens: null, + error: { error: 'missing_pkce', message: 'PKCE verifier or state not found in cookies' }, + }; + } + + if (pkceState !== state) { + return { + tokens: null, + error: { error: 'state_mismatch', message: 'State parameter does not match' }, + }; + } + + try { + const client = await oidc({ + config: { + serverConfig: { wellknown: WELLKNOWN_URL }, + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + scope: SCOPE, + responseType: 'code', + }, + storage: { type: 'custom', name: CLIENT_ID, custom: noopStorage }, + }); + + if (!client || 'error' in client) { + return { + tokens: null, + error: { error: 'oidc_init_failed', message: 'Failed to initialize OIDC client' }, + }; + } + + // Exchange code for tokens, providing PKCE values directly (no sessionStorage) + const tokens = await client.token.exchange(code, state, { + pkceValues: { verifier, state: pkceState }, + }); + + if ('error' in tokens) { + return { + tokens: null, + error: { + error: tokens.error, + message: 'message' in tokens ? tokens.message : 'Token exchange failed', + }, + }; + } + + return { + tokens: { + accessToken: tokens.accessToken, + idToken: tokens.idToken, + ...(tokens.refreshToken && { refreshToken: tokens.refreshToken }), + }, + error: null, + }; + } catch (e) { + return { + tokens: null, + error: { + error: 'exchange_failed', + message: e instanceof Error ? e.message : 'Token exchange failed', + }, + }; + } +}; diff --git a/e2e/svelte-app/src/routes/callback/+page.svelte b/e2e/svelte-app/src/routes/callback/+page.svelte new file mode 100644 index 0000000000..ddefe9b51f --- /dev/null +++ b/e2e/svelte-app/src/routes/callback/+page.svelte @@ -0,0 +1,63 @@ + + +

OIDC Callback

+ +{#if data.error} +
+

Error

+

{data.error.error}: {data.error.message}

+ Back to login +
+{:else if data.tokens} +
+

Tokens received!

+
+
Access Token
+
{data.tokens.accessToken.slice(0, 20)}...
+
ID Token
+
{data.tokens.idToken.slice(0, 20)}...
+ {#if data.tokens.refreshToken} +
Refresh Token
+
{data.tokens.refreshToken.slice(0, 20)}...
+ {/if} +
+ Back to login +
+{:else} +

Processing...

+{/if} + + diff --git a/e2e/svelte-app/svelte.config.js b/e2e/svelte-app/svelte.config.js new file mode 100644 index 0000000000..2f86665436 --- /dev/null +++ b/e2e/svelte-app/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from '@sveltejs/adapter-auto'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter(), + }, +}; + +export default config; diff --git a/e2e/svelte-app/tsconfig.json b/e2e/svelte-app/tsconfig.json new file mode 100644 index 0000000000..c748ab2058 --- /dev/null +++ b/e2e/svelte-app/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + }, + "references": [ + { + "path": "../../packages/oidc-client" + }, + { + "path": "../../packages/journey-client" + } + ] +} diff --git a/e2e/svelte-app/vite.config.ts b/e2e/svelte-app/vite.config.ts new file mode 100644 index 0000000000..2e920e4aa4 --- /dev/null +++ b/e2e/svelte-app/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], +}); diff --git a/nx.json b/nx.json index d2f5805f60..7a112dd147 100644 --- a/nx.json +++ b/nx.json @@ -131,7 +131,8 @@ "buildDepsTargetName": "vite:build-deps", "watchDepsTargetName": "vite:watch-deps" }, - "include": ["packages/**/**/*", "e2e/**/**/*", "tools/**/**/*"] + "include": ["packages/**/**/*", "e2e/**/**/*", "tools/**/**/*"], + "exclude": ["e2e/svelte-app/**/*"] }, { "plugin": "@nx/js/typescript", @@ -151,7 +152,8 @@ "options": { "testTargetName": "nxTest" }, - "include": ["packages/**/**/*", "e2e/**/**/*", "tools/**/**/*"] + "include": ["packages/**/**/*", "e2e/**/**/*", "tools/**/**/*"], + "exclude": ["e2e/svelte-app/**/*"] } ], "parallel": 1, diff --git a/packages/davinci-client/src/lib/davinci.api.ts b/packages/davinci-client/src/lib/davinci.api.ts index 8f47cc2f84..003d95184d 100644 --- a/packages/davinci-client/src/lib/davinci.api.ts +++ b/packages/davinci-client/src/lib/davinci.api.ts @@ -262,7 +262,7 @@ export const davinciApi = createApi({ } try { - const authorizeUrl = await createAuthorizeUrl(authorizeEndpoint, { + const authorizeResult = await createAuthorizeUrl(authorizeEndpoint, { clientId: state?.config?.clientId, login: 'redirect', // TODO: improve this in SDK to be more semantic redirectUri: state?.config?.redirectUri, @@ -270,7 +270,7 @@ export const davinciApi = createApi({ responseMode: 'pi.flow', scope: state?.config?.scope, }); - const url = new URL(authorizeUrl); + const url = new URL(authorizeResult.url); const existingParams = url.searchParams; if (options?.query) { diff --git a/packages/journey-client/src/index.ts b/packages/journey-client/src/index.ts index 04d5c83093..33e2481c5c 100644 --- a/packages/journey-client/src/index.ts +++ b/packages/journey-client/src/index.ts @@ -6,6 +6,7 @@ */ export * from './lib/client.store.js'; +export { createJourneyObject } from './lib/journey.utils.js'; // Re-export types from internal packages that consumers need export { callbackType } from '@forgerock/sdk-types'; diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index cbd85480f2..f68cebb748 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -148,10 +148,9 @@ export async function journey({ throw new Error(message); } - const stepStorage = createStorage<{ step: Step }>({ - type: 'sessionStorage', - name: 'journey-step', - }); + const stepStorage = createStorage<{ step: Step }>( + config.storage ?? { type: 'sessionStorage', name: 'journey-step' }, + ); const self: JourneyClient = { start: async (options?: StartParam) => { @@ -200,6 +199,11 @@ export async function journey({ if (isGenericError(err)) { log.warn('Failed to persist step before redirect', err); } + if (typeof window === 'undefined') { + throw new Error( + 'redirect() requires a browser environment. Extract the redirect URL from the RedirectCallback for server-side redirection.', + ); + } window.location.assign(redirectUrl); }, diff --git a/packages/journey-client/src/lib/config.types.ts b/packages/journey-client/src/lib/config.types.ts index 25eaeaa888..5f1e664371 100644 --- a/packages/journey-client/src/lib/config.types.ts +++ b/packages/journey-client/src/lib/config.types.ts @@ -6,6 +6,7 @@ */ import type { AsyncLegacyConfigOptions, GenericError } from '@forgerock/sdk-types'; +import type { StorageConfig } from '@forgerock/storage'; import type { ResolvedServerConfig } from './wellknown.utils.js'; /** @@ -40,6 +41,8 @@ export interface JourneyServerConfig { */ export interface JourneyClientConfig extends AsyncLegacyConfigOptions { serverConfig: JourneyServerConfig; + /** Storage configuration for step persistence during redirects. Defaults to sessionStorage in browsers. */ + storage?: StorageConfig; } /** diff --git a/packages/oidc-client/src/lib/authorize.request.utils.ts b/packages/oidc-client/src/lib/authorize.request.utils.ts index 96557e22ed..851292ed6e 100644 --- a/packages/oidc-client/src/lib/authorize.request.utils.ts +++ b/packages/oidc-client/src/lib/authorize.request.utils.ts @@ -4,7 +4,7 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ -import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; +import { createAuthorizeUrl, type AuthorizeUrlResult } from '@forgerock/sdk-oidc'; import { Micro } from 'effect'; import type { WellknownResponse, GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; @@ -49,7 +49,6 @@ export function buildAuthorizeOptionsµ( * @description Creates an error response with new Authorize URL for the authorization request. * @param { error: string; error_description: string } res - The error response from the authorization request. * @param {WellknownResponse} wellknown- The well-known configuration for the OIDC server. - * @param { OidcConfig } config- The OIDC client configuration. * @param { GetAuthorizationUrlOptions } options- Optional parameters for the authorization request. * @returns { Micro.Micro } */ @@ -75,12 +74,12 @@ export function createAuthorizeErrorµ( } as const; }, }).pipe( - Micro.flatMap((url) => { + Micro.flatMap((result) => { return Micro.fail({ error: res.error, error_description: res.error_description, type: 'auth_error', - redirectUrl: url, + redirectUrl: result.url, } as const); }), ); @@ -89,6 +88,7 @@ export function createAuthorizeErrorµ( /** * @function createAuthorizeUrlµ * @description Creates an authorization URL and related options/config for the Authorize request. + * Stores PKCE values in sessionStorage for the background authorize flow. * @param {string} path - The path to the authorization endpoint. * @param { GetAuthorizationUrlOptions } options - Optional parameters for the authorization request. * @returns { Micro.Micro<[string, GetAuthorizationUrlOptions], AuthorizationError, never> } @@ -98,13 +98,18 @@ export function createAuthorizeUrlµ( options: GetAuthorizationUrlOptions, ): Micro.Micro<[string, GetAuthorizationUrlOptions], AuthorizationError, never> { return Micro.tryPromise({ - try: async () => [ - await createAuthorizeUrl(path, { + try: async (): Promise<[string, GetAuthorizationUrlOptions]> => { + const result = await createAuthorizeUrl(path, { ...options, prompt: 'none', - }), - options, - ], + }); + + // For the background flow, persist PKCE values in sessionStorage + // so the token exchange can retrieve them after the iframe redirect. + storePkceValues(options.clientId, result, options); + + return [result.url, options]; + }, catch: (error) => { let message = 'Error creating authorization URL'; if (error instanceof Error) { @@ -119,6 +124,26 @@ export function createAuthorizeUrlµ( }); } +/** + * Store PKCE values in sessionStorage for the background authorize flow. + * This is the browser-only path — server-side callers handle storage themselves. + */ +function storePkceValues( + clientId: string, + result: AuthorizeUrlResult, + options: GetAuthorizationUrlOptions, +): void { + const storageKey = `FR-SDK-authflow-${clientId}`; + globalThis.sessionStorage.setItem( + storageKey, + JSON.stringify({ + ...options, + state: result.state, + verifier: result.verifier, + }), + ); +} + export function handleResponseµ( response: AuthorizationSuccess | AuthorizationError, wellknown: WellknownResponse, diff --git a/packages/oidc-client/src/lib/client.store.ts b/packages/oidc-client/src/lib/client.store.ts index da6c3de99c..68d90b381b 100644 --- a/packages/oidc-client/src/lib/client.store.ts +++ b/packages/oidc-client/src/lib/client.store.ts @@ -5,7 +5,7 @@ * of the MIT license. See the LICENSE file for details. */ import { logger as loggerFn } from '@forgerock/sdk-logger'; -import { createAuthorizeUrl } from '@forgerock/sdk-oidc'; +import { createAuthorizeUrl, type AuthorizeUrlResult } from '@forgerock/sdk-oidc'; import { createStorage } from '@forgerock/storage'; import { Micro } from 'effect'; import { exitIsFail, exitIsSuccess } from 'effect/Micro'; @@ -29,6 +29,7 @@ import type { RevokeSuccessResult, UserInfoResponse, } from './client.types.js'; +import type { PkceValues } from './exchange.utils.js'; import type { OauthTokens, OidcConfig } from './config.types.js'; import type { AuthorizationError, AuthorizationSuccess } from './authorize.request.types.js'; import type { TokenExchangeErrorResponse } from './exchange.types.js'; @@ -105,7 +106,9 @@ export async function oidc({ * @param {GetAuthorizationUrlOptions} options - Optional parameters to customize the authorization URL. * @returns {Promise} - Returns a promise that resolves to the authorization URL or an error. */ - url: async (options?: GetAuthorizationUrlOptions): Promise => { + url: async ( + options?: GetAuthorizationUrlOptions, + ): Promise => { const optionsWithDefaults = { clientId: config.clientId, redirectUri: config.redirectUri, @@ -179,7 +182,7 @@ export async function oidc({ exchange: async ( code: string, state: string, - options?: Partial, + options?: Partial & { pkceValues?: PkceValues }, ): Promise => { const storeState = store.getState(); const wellknown = wellknownSelector(wellknownUrl, storeState); @@ -199,6 +202,7 @@ export async function oidc({ endpoint: wellknown.token_endpoint, store, options, + pkceValues: options?.pkceValues, }).pipe( Micro.tap(async (tokens) => { await storageClient.set(tokens); diff --git a/packages/oidc-client/src/lib/exchange.request.ts b/packages/oidc-client/src/lib/exchange.request.ts index 4fb4e57110..813ae77a88 100644 --- a/packages/oidc-client/src/lib/exchange.request.ts +++ b/packages/oidc-client/src/lib/exchange.request.ts @@ -8,7 +8,12 @@ import { Micro } from 'effect'; import { logger } from '@forgerock/sdk-logger'; -import { createValuesµ, handleTokenResponseµ, validateValuesµ } from './exchange.utils.js'; +import { + createValuesµ, + handleTokenResponseµ, + validateValuesµ, + type PkceValues, +} from './exchange.utils.js'; import { oidcApi } from './oidc.api.js'; import type { ClientStore } from './client.types.js'; @@ -24,6 +29,8 @@ interface BuildTokenExchangeµParams { state: string; store: ClientStore; options?: Partial; + /** Provide PKCE values directly (SSR) instead of reading from sessionStorage. */ + pkceValues?: PkceValues; } export function buildTokenExchangeµ({ @@ -34,8 +41,9 @@ export function buildTokenExchangeµ({ state, store, options, + pkceValues, }: BuildTokenExchangeµParams): Micro.Micro { - return createValuesµ(code, config, state, endpoint, options).pipe( + return createValuesµ(code, config, state, endpoint, options, pkceValues).pipe( Micro.flatMap((options) => validateValuesµ(options)), Micro.tap((options) => log.debug('Token exchange values created', options)), Micro.tapError((options) => diff --git a/packages/oidc-client/src/lib/exchange.utils.ts b/packages/oidc-client/src/lib/exchange.utils.ts index b3314a6ab4..9ca70d1a36 100644 --- a/packages/oidc-client/src/lib/exchange.utils.ts +++ b/packages/oidc-client/src/lib/exchange.utils.ts @@ -16,15 +16,32 @@ import type { TokenExchangeResponse, TokenRequestOptions } from './exchange.type import type { TokenExchangeErrorResponse } from './exchange.types.js'; import type { OidcConfig } from './config.types.js'; +/** Options for providing PKCE values directly instead of reading sessionStorage. */ +export interface PkceValues { + verifier: string; + state: string; +} + export function createValuesµ( code: string, config: OidcConfig, state: string, endpoint: string, options?: Partial, + pkceValues?: PkceValues, ) { return Micro.sync(() => { - const storedValues = getStoredAuthUrlValues(config.clientId, options?.prefix); + // If PKCE values are provided directly, use them (SSR path). + // Otherwise, fall back to reading from sessionStorage (browser path). + const storedValues: GetAuthorizationUrlOptions = pkceValues + ? { + ...pkceValues, + clientId: config.clientId, + redirectUri: config.redirectUri, + responseType: 'code', + scope: config.scope || 'openid', + } + : getStoredAuthUrlValues(config.clientId, options?.prefix); return { code, diff --git a/packages/oidc-client/src/types.ts b/packages/oidc-client/src/types.ts index 10c8b28405..45d5d4f258 100644 --- a/packages/oidc-client/src/types.ts +++ b/packages/oidc-client/src/types.ts @@ -16,3 +16,5 @@ export type { export type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; export type { CustomLogger, LogLevel } from '@forgerock/sdk-logger'; export type { StorageConfig } from '@forgerock/storage'; +export type { AuthorizeUrlResult } from '@forgerock/sdk-oidc'; +export type { PkceValues } from './lib/exchange.utils.js'; diff --git a/packages/sdk-effects/oidc/src/lib/authorize.effects.ts b/packages/sdk-effects/oidc/src/lib/authorize.effects.ts index 4881f2dd34..a2f794697f 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.effects.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.effects.ts @@ -10,26 +10,34 @@ */ import { createChallenge } from '@forgerock/sdk-utilities'; -import { generateAndStoreAuthUrlValues } from './state-pkce.effects.js'; +import { generateAuthUrlValues } from './state-pkce.effects.js'; import type { GetAuthorizationUrlOptions } from '@forgerock/sdk-types'; +/** Result of creating an authorization URL with PKCE. */ +export interface AuthorizeUrlResult { + /** The fully-formed authorization URL to redirect to. */ + url: string; + /** The PKCE verifier — caller must persist this for token exchange. */ + verifier: string; + /** The state parameter — caller must persist this for CSRF validation. */ + state: string; +} + /** - * @function createAuthorizeUrl - Create authorization URL for initial call to DaVinci - * @param baseUrl {string} - * @param options {GetAuthorizationUrlOptions} - * @returns {Promise} - the authorization URL + * Creates an authorization URL with PKCE parameters. + * + * Returns the URL along with the verifier and state values. The caller is + * responsible for persisting verifier/state (in sessionStorage, a cookie, + * a server-side session, etc.) so they can be provided during token exchange. */ export async function createAuthorizeUrl( authorizeUrl: string, options: GetAuthorizationUrlOptions, -): Promise { - /** - * Generate state and verifier for PKCE - */ +): Promise { const baseUrl = new URL(authorizeUrl).origin; - const [authorizeUrlOptions, storeOptions] = generateAndStoreAuthUrlValues({ + const authorizeUrlOptions = generateAuthUrlValues({ clientId: options.clientId, serverConfig: { baseUrl }, responseType: options.responseType, @@ -54,7 +62,9 @@ export async function createAuthorizeUrl( const url = new URL(`${authorizeUrl}?${requestParams.toString()}`); - storeOptions(); - - return url.toString(); + return { + url: url.toString(), + verifier: authorizeUrlOptions.verifier, + state: authorizeUrlOptions.state, + }; } diff --git a/packages/sdk-effects/oidc/src/lib/authorize.test.ts b/packages/sdk-effects/oidc/src/lib/authorize.test.ts index 6dc03c43a7..2695ec4892 100644 --- a/packages/sdk-effects/oidc/src/lib/authorize.test.ts +++ b/packages/sdk-effects/oidc/src/lib/authorize.test.ts @@ -8,7 +8,6 @@ import type { GenerateAndStoreAuthUrlValues } from '@forgerock/sdk-types'; import { describe, expect, it, beforeEach } from 'vitest'; import { createAuthorizeUrl } from './authorize.effects.js'; -import { getStorageKey } from './state-pkce.effects.js'; const mockSessionStorage = (() => { let store: { [key: string]: string } = {}; @@ -45,8 +44,8 @@ describe('createAuthorizeUrl', () => { const baseUrl = 'https://auth.example.com/authorize'; it('should create a valid authorization URL with all required parameters', async () => { - const url = await createAuthorizeUrl(baseUrl, mockOptions); - const parsedUrl = new URL(url); + const result = await createAuthorizeUrl(baseUrl, mockOptions); + const parsedUrl = new URL(result.url); // Check the base URL expect(parsedUrl.origin + parsedUrl.pathname).toBe(baseUrl); @@ -67,6 +66,21 @@ describe('createAuthorizeUrl', () => { expect(params.code_challenge).toBeDefined(); }); + it('should return verifier and state alongside the URL', async () => { + const result = await createAuthorizeUrl(baseUrl, mockOptions); + + expect(result.verifier).toBeDefined(); + expect(result.state).toBeDefined(); + expect(typeof result.verifier).toBe('string'); + expect(typeof result.state).toBe('string'); + expect(result.verifier.length).toBeGreaterThan(0); + expect(result.state.length).toBeGreaterThan(0); + + // State in URL should match the returned state + const parsedUrl = new URL(result.url); + expect(parsedUrl.searchParams.get('state')).toBe(result.state); + }); + it('should include optional parameters when provided', async () => { const prompt = 'login'; const responseMode = 'pi.flow'; @@ -76,8 +90,8 @@ describe('createAuthorizeUrl', () => { responseMode, }; - const url = await createAuthorizeUrl(baseUrl, optionsWithOptionals); - const params = new URL(url).searchParams; + const result = await createAuthorizeUrl(baseUrl, optionsWithOptionals); + const params = new URL(result.url).searchParams; expect(params.get('prompt')).toBe(prompt); expect(params.get('response_mode')).toBe(responseMode); @@ -94,8 +108,8 @@ describe('createAuthorizeUrl', () => { }, }; - const url = await createAuthorizeUrl(baseUrl, optionsWithOptionals); - const params = new URL(url).searchParams; + const result = await createAuthorizeUrl(baseUrl, optionsWithOptionals); + const params = new URL(result.url).searchParams; expect(params.get('queryA')).toBe(queryA); expect(params.get('queryB')).toBe(queryB); @@ -110,8 +124,8 @@ describe('createAuthorizeUrl', () => { }, }; - const url = await createAuthorizeUrl(baseUrl, optionsWithConflict); - const params = new URL(url).searchParams; + const result = await createAuthorizeUrl(baseUrl, optionsWithConflict); + const params = new URL(result.url).searchParams; // Standard param should override query param expect(params.get('client_id')).toBe(mockOptions.clientId); @@ -119,20 +133,9 @@ describe('createAuthorizeUrl', () => { expect(params.get('custom_param')).toBe('value'); }); - it('should store the authorize options in session storage', async () => { + it('should NOT store values in session storage (caller responsibility)', async () => { await createAuthorizeUrl(baseUrl, mockOptions); - const storageKey = getStorageKey(mockOptions.clientId); - const storedData = sessionStorage.getItem(storageKey); - - const parsedOptions = JSON.parse(storedData as string); - const serverUrl = new URL(baseUrl).origin; - - expect(storedData).toBeDefined(); - expect(parsedOptions).toMatchObject({ - ...mockOptions, - serverConfig: { baseUrl: serverUrl }, - }); - expect(parsedOptions).toHaveProperty('state'); - expect(parsedOptions).toHaveProperty('verifier'); + // createAuthorizeUrl no longer writes to sessionStorage — that's the caller's job + expect(sessionStorage.getItem(`FR-SDK-authflow-${mockOptions.clientId}`)).toBeNull(); }); }); diff --git a/packages/sdk-effects/oidc/src/lib/state-pkce.effects.ts b/packages/sdk-effects/oidc/src/lib/state-pkce.effects.ts index f24df9fdfb..de35bb5a80 100644 --- a/packages/sdk-effects/oidc/src/lib/state-pkce.effects.ts +++ b/packages/sdk-effects/oidc/src/lib/state-pkce.effects.ts @@ -16,44 +16,55 @@ export function getStorageKey(clientId: string, prefix?: string) { return `${prefix || 'FR-SDK'}-authflow-${clientId}`; } +/** The PKCE + state values generated for an authorization request. */ +export interface AuthUrlValues extends GetAuthorizationUrlOptions { + state: string; + verifier: string; +} + /** - * Generate and store PKCE values for later use - * @param { string } storageKey - Key to store authorization options in sessionStorage - * @param {GenerateAndStoreAuthUrlValues} options - Options for generating PKCE values - * @returns { state: string, verifier: string, GetAuthorizationUrlOptions } + * Pure PKCE generation — no storage side effects. + * Returns the authorize URL options with generated state and verifier. + * The caller is responsible for persisting these values for token exchange. */ - -export function generateAndStoreAuthUrlValues( - options: GenerateAndStoreAuthUrlValues, -): readonly [GetAuthorizationUrlOptions & { state: string; verifier: string }, () => void] { +export function generateAuthUrlValues(options: GenerateAndStoreAuthUrlValues): AuthUrlValues { const verifier = createVerifier(); const state = createState(); - const storageKey = getStorageKey(options.clientId, options.prefix); - const authorizeUrlOptions = { + return { ...options, state, verifier, }; +} + +/** + * @deprecated Use `generateAuthUrlValues` and handle storage yourself. + * Generate PKCE values and return a closure to store them in sessionStorage. + */ +export function generateAndStoreAuthUrlValues( + options: GenerateAndStoreAuthUrlValues, +): readonly [AuthUrlValues, () => void] { + const authorizeUrlOptions = generateAuthUrlValues(options); + const storageKey = getStorageKey(options.clientId, options.prefix); return [ authorizeUrlOptions, - () => sessionStorage.setItem(storageKey, JSON.stringify(authorizeUrlOptions)), + () => globalThis.sessionStorage.setItem(storageKey, JSON.stringify(authorizeUrlOptions)), ] as const; } /** - * @function getStoredAuthUrlValues - Retrieve stored authorization options from sessionStorage - * @param { string } storageKey - Key to retrieve stored values from sessionStorage - * @returns { GetAuthorizationUrlOptions } + * @deprecated Use caller-provided stored values instead. + * Retrieve stored authorization options from sessionStorage. */ export function getStoredAuthUrlValues( clientId: string, prefix?: string, ): GetAuthorizationUrlOptions { const storageKey = getStorageKey(clientId, prefix); - const storedString = sessionStorage.getItem(storageKey); - sessionStorage.removeItem(storageKey); + const storedString = globalThis.sessionStorage.getItem(storageKey); + globalThis.sessionStorage.removeItem(storageKey); try { return JSON.parse(storedString as string); diff --git a/packages/sdk-effects/storage/src/lib/storage.effects.ts b/packages/sdk-effects/storage/src/lib/storage.effects.ts index 64706f07f4..0e5b7e78ce 100644 --- a/packages/sdk-effects/storage/src/lib/storage.effects.ts +++ b/packages/sdk-effects/storage/src/lib/storage.effects.ts @@ -27,6 +27,21 @@ export interface CustomStorageConfig { custom: CustomStorageObject; } +/** + * Lazily access browser storage globals. Using `globalThis` property access + * instead of bare `sessionStorage`/`localStorage` avoids ReferenceError + * in Node.js/SSR environments — property access returns `undefined` rather than throwing. + */ +function getBrowserStorage(type: 'localStorage' | 'sessionStorage'): Storage { + const storage = type === 'localStorage' ? globalThis.localStorage : globalThis.sessionStorage; + if (!storage) { + throw new Error( + `${type} is not available in this environment. Use type: 'custom' for server-side usage.`, + ); + } + return storage; +} + function createStorageError( storeType: 'localStorage' | 'sessionStorage' | 'custom', action: 'Storing' | 'Retrieving' | 'Removing' | 'Parsing', @@ -56,10 +71,6 @@ function createStorageError( export function createStorage(config: StorageConfig): StorageClient { const { type: storeType, prefix = 'pic', name } = config; const key = `${prefix}-${name}`; - const storageTypes = { - sessionStorage, - localStorage, - }; if (storeType === 'custom' && !('custom' in config)) { throw new Error('Custom storage configuration must include a custom storage object'); @@ -89,7 +100,7 @@ export function createStorage(config: StorageConfig): StorageClient(config: StorageConfig): StorageClient(config: StorageConfig): StorageClient=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 || ^6.0.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + '@swc-node/core@1.14.1': resolution: {integrity: sha512-jrt5GUaZUU6cmMS+WTJEvGvaB6j1YNKPHPzC2PUi2BjaFbtxURHj6641Az6xN7b665hNniAIdvjxWcRml5yCnw==} engines: {node: '>= 10'} @@ -3059,6 +3125,9 @@ packages: '@types/conventional-commits-parser@5.0.2': resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -3137,6 +3206,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -3685,6 +3757,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -3771,6 +3847,10 @@ packages: axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + b4a@1.7.3: resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} peerDependencies: @@ -4084,6 +4164,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -4215,6 +4299,10 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + cookie@0.7.1: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} @@ -4528,6 +4616,9 @@ packages: peerDependencies: typescript: ^5.4.4 + devalue@5.7.1: + resolution: {integrity: sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -4825,6 +4916,9 @@ packages: jiti: optional: true + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4842,6 +4936,9 @@ packages: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -5665,6 +5762,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -6017,6 +6117,10 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -6052,6 +6156,9 @@ packages: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -7218,6 +7325,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -7519,6 +7629,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte@5.55.2: + resolution: {integrity: sha512-z41M/hi0ZPTzrwVKLvB/R1/Oo08gL1uIib8HZ+FncqxxtY9MLb01emg2fqk+WLZ/lNrrtNDFh7BZLDxAHvMgLw==} + engines: {node: '>=18'} + swc-loader@0.2.6: resolution: {integrity: sha512-9Zi9UP2YmDpgmQVbyOPJClY0dwf58JDyDMQ7uRc4krmc72twNI2fvlBWHLqVekBpPc7h5NJkGVT1zNDxFrqhvg==} peerDependencies: @@ -8024,6 +8138,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest-canvas-mock@1.1.3: resolution: {integrity: sha512-zlKJR776Qgd+bcACPh0Pq5MG3xWq+CdkACKY/wX4Jyija0BSz8LH3aCCgwFKYFwtm565+050YFEGG9Ki0gE/Hw==} peerDependencies: @@ -8249,6 +8371,9 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + zod-package-json@1.2.0: resolution: {integrity: sha512-tamtgPM3MkP+obfO2dLr/G+nYoYkpJKmuHdYEy6IXRKfLybruoJ5NUj0lM0LxwOpC9PpoGLbll1ecoeyj43Wsg==} engines: {node: '>=20'} @@ -10866,6 +10991,57 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@sveltejs/acorn-typescript@1.0.9(acorn@8.15.0)': + dependencies: + acorn: 8.15.0 + + '@sveltejs/adapter-auto@6.1.1(@sveltejs/kit@2.57.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.55.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)))': + dependencies: + '@sveltejs/kit': 2.57.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.55.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + + '@sveltejs/kit@2.57.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.55.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@standard-schema/spec': 1.0.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + '@types/cookie': 0.6.0 + acorn: 8.15.0 + cookie: 0.6.0 + devalue: 5.7.1 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.0 + sirv: 3.0.2 + svelte: 5.55.2 + vite: 7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + typescript: 5.9.3 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + debug: 4.4.3 + svelte: 5.55.2 + vite: 7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.55.2)(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.55.2 + vite: 7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitefu: 1.1.3(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)) + transitivePeerDependencies: + - supports-color + '@swc-node/core@1.14.1(@swc/core@1.11.21(@swc/helpers@0.5.17))(@swc/types@0.1.25)': dependencies: '@swc/core': 1.11.21(@swc/helpers@0.5.17) @@ -11054,6 +11230,8 @@ snapshots: dependencies: '@types/node': 24.9.2 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -11140,6 +11318,8 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': {} + '@types/unist@3.0.3': {} '@types/yargs-parser@21.0.3': {} @@ -11896,6 +12076,8 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.1: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -11996,6 +12178,8 @@ snapshots: transitivePeerDependencies: - debug + axobject-query@4.1.0: {} + b4a@1.7.3: {} babel-jest@30.2.0(@babel/core@7.28.5): @@ -12352,6 +12536,8 @@ snapshots: clone@1.0.4: {} + clsx@2.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -12482,6 +12668,8 @@ snapshots: cookie-signature@1.2.2: {} + cookie@0.6.0: {} + cookie@0.7.1: {} cookie@0.7.2: {} @@ -12766,6 +12954,8 @@ snapshots: transitivePeerDependencies: - supports-color + devalue@5.7.1: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -13184,6 +13374,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.2.2: {} + espree@10.4.0: dependencies: acorn: 8.15.0 @@ -13202,6 +13394,11 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@2.2.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.46.3 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -14158,6 +14355,10 @@ snapshots: is-promise@4.0.0: {} + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -14696,6 +14897,8 @@ snapshots: kind-of@6.0.3: {} + kleur@4.1.5: {} + leven@3.1.0: {} levn@0.4.1: @@ -14739,6 +14942,8 @@ snapshots: loader-runner@4.3.1: {} + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -15991,6 +16196,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -16334,6 +16541,25 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte@5.55.2: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.15.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.15.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.7.1 + esm-env: 1.2.2 + esrap: 2.2.4 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + swc-loader@0.2.6(@swc/core@1.11.21(@swc/helpers@0.5.17))(webpack@5.102.1(@swc/core@1.11.21(@swc/helpers@0.5.17))): dependencies: '@swc/core': 1.11.21(@swc/helpers@0.5.17) @@ -16882,6 +17108,10 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vitefu@1.1.3(vite@7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1)): + optionalDependencies: + vite: 7.3.1(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.1) + vitest-canvas-mock@1.1.3(vitest@3.2.4): dependencies: cssfontparser: 1.2.1 @@ -17185,6 +17415,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} + zimmerframe@1.1.4: {} + zod-package-json@1.2.0: dependencies: zod: 3.25.76