diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..94b99b5 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,50 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: en-US + +reviews: + request_changes_workflow: false + high_level_summary: true + poem: false + + path_instructions: + - path: "packages/**/*.{ts,tsx}" + instructions: | + Flag any hardcoded occurrence of the literal `thunderid`/`ThunderID`/`THUNDERID` (any casing) used to + build a runtime key or name — storage keys, cookie/session names, CSS class prefixes, + `useState`/composable keys, log tags, DOM `id`/`data-*` attributes, and similar. + + Do not flag the package's entry point — the main client class, provider component, or a file whose + purpose IS to represent the SDK itself (e.g. `ThunderIDClient`, `ThunderIDProvider`, + `ThunderIDBrowserClient`). That's a fixed identity, not a per-tenant value. + + For everything else, ask: **"Should this read from the `vendor` config / `getVendorPrefix()` util + instead of hardcoding the vendor name?"** The best fix is avoiding the vendor name entirely when the + brand prefix isn't load-bearing. When a brand-scoped namespace is genuinely required, it should resolve + through `getVendorPrefix(vendor)` (from `@thunderid/javascript`, re-exported via + `@thunderid/browser`/`@thunderid/node`) rather than a literal string or a repeated inline + `vendor ?? 'thunderid'` fallback. + + - path: "packages/**/*.{ts,tsx}" + instructions: | + This is a layered monorepo: `@thunderid/javascript` is the framework-agnostic core; `@thunderid/browser` + and `@thunderid/node` build on it; framework packages (`react`, `vue`, `nextjs`, `nuxt`, `express`, + `react-router`, `tanstack-router`) build on those. If this change adds a utility/helper/constant that + duplicates logic already present in another package, or that more than one framework package would + plausibly need, ask: **"Should this live lower in the package hierarchy (e.g. `@thunderid/javascript`) + and be re-exported instead?"** + + auto_review: + enabled: true + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + base_branches: + - main + + path_filters: + - "!**/dist/**" + - "!**/node_modules/**" + - "!**/*.d.ts" + - "!**/.next/**" + - "!**/.nuxt/**" + - "!**/.output/**" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..11bd555 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# ThunderID JavaScript SDKs — Agent Instructions + +## Project overview + +pnpm + Turborepo monorepo of TypeScript SDKs under `packages/`: a framework-agnostic core +(`@thunderid/javascript`), a browser layer (`@thunderid/browser`), a server layer (`@thunderid/node`), and +framework SDKs built on top of those (`react`, `vue`, `nuxt`, `nextjs`, `express`, `react-router`, +`tanstack-router`). `samples//quickstart` directories contain standalone demo apps — not part of +any published package. + +## Build & test + +```bash +pnpm install + +pnpm build # turbo run build across all packages +pnpm test # turbo run test +pnpm lint # turbo run lint (@thunderid/eslint-plugin) +pnpm typecheck # turbo run typecheck +pnpm format:check + +# Scope to a single package: +pnpm --filter @thunderid/ run build +pnpm --filter @thunderid/ run test +``` + +## Package hierarchy — check before adding a util + +`@thunderid/javascript` is the framework-agnostic core (auth flows, token management, i18n, `VendorConstants`, +shared utils). Every other package depends on it, directly or transitively: + +- `@thunderid/browser` → `@thunderid/javascript` + DOM/browser APIs (storage, session sync, WebAuthn). +- `@thunderid/node` → `@thunderid/javascript` + server-side session/cookie handling. +- `@thunderid/react`, `@thunderid/vue` → `@thunderid/browser`. +- `@thunderid/nextjs` → `@thunderid/node` + `@thunderid/react`. +- `@thunderid/nuxt` → `@thunderid/node` + `@thunderid/vue`. +- `@thunderid/express` → `@thunderid/node`. +- `@thunderid/react-router`, `@thunderid/tanstack-router` → `@thunderid/react`. + +**Before adding a helper/util/constant to a framework package, check whether it belongs lower in this +hierarchy instead.** If the same logic would be needed by more than one framework package (or you're about to +copy-paste one), it almost certainly belongs in `@thunderid/javascript` — or `@thunderid/browser`/ +`@thunderid/node` if it needs DOM/server APIs — and should be re-exported from there, not duplicated. Every +package's `src/index.ts` re-exports its dependency's exports (`export * from '@thunderid/javascript'`, etc.), +so check there before assuming a helper isn't already available. + +## Vendor naming rules + +The SDK is white-labelable: a consuming app can override the brand/vendor namespace via the `vendor` config +field, so storage keys, cookie names, CSS class prefixes, state keys, etc. shouldn't be pinned to one brand. + +- Do not hardcode the literal `thunderid`/`ThunderID`/`THUNDERID` (any casing) when building a runtime + key/name that a consumer's `vendor` override should control — storage keys, cookie/session names, CSS + class prefixes, `useState`/composable keys, log tags, DOM `id`/`data-*` attributes, and the like. +- It's fine for the **entry point** — the main client class, provider component, or a file whose purpose IS + to represent the SDK itself (e.g. `ThunderIDClient`, `ThunderIDProvider`, `ThunderIDBrowserClient`) — to + carry the name. That's a fixed identity, not a per-tenant value. Don't flag those. +- Avoiding the vendor name entirely is the best outcome, when the brand prefix isn't actually load-bearing. +- When a brand-scoped namespace is genuinely required, resolve it through `getVendorPrefix(vendor)` (defined + in `@thunderid/javascript`, re-exported via `@thunderid/browser`/`@thunderid/node`) instead of hardcoding a + literal or repeating an inline `vendor ?? 'thunderid'` fallback. That default belongs in one place. + +## Code style + +- TypeScript, ESLint via `@thunderid/eslint-plugin`, Prettier. +- Every source file carries a copyright header (`@thunderid/copyright-header` ESLint rule); `samples/**` is + exempt (see `eslint.config.js`). +- Prefer editing or re-exporting an existing util over duplicating a helper across packages (see hierarchy + above). + +## File layout + +``` +packages/ + javascript/ Framework-agnostic core SDK + browser/ Browser/DOM layer (storage, WebAuthn, session sync) + node/ Server-side layer (cookies, sessions) + react/ React components + hooks + vue/ Vue components + composables + nextjs/ Next.js SDK (client + server) + nuxt/ Nuxt module + express/ Express middleware + react-router/ React Router integration + tanstack-router/ TanStack Router integration +samples/ Standalone quickstart demo apps, one per framework +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/packages/express/src/ThunderIDExpressClient.ts b/packages/express/src/ThunderIDExpressClient.ts index b1dbe3a..40ac416 100644 --- a/packages/express/src/ThunderIDExpressClient.ts +++ b/packages/express/src/ThunderIDExpressClient.ts @@ -16,10 +16,17 @@ * under the License. */ -import {ThunderIDNodeClient, ThunderIDAuthException, Storage, TokenResponse, User} from '@thunderid/node'; +import { + ThunderIDNodeClient, + ThunderIDAuthException, + Storage, + TokenResponse, + User, + CookieConfig as NodeCookieConfig, +} from '@thunderid/node'; import express from 'express'; import {v4 as uuidv4} from 'uuid'; -import CookieConfig, {SESSION_COOKIE_NAME} from './constants/CookieConfig'; +import CookieConfig from './constants/CookieConfig'; import {ExpressClientConfig} from './models/config'; import hasErrorInURL from './utils/expressUtils'; @@ -39,8 +46,20 @@ class ThunderIDExpressClient { - const sessionId: string | undefined = req.cookies?.[SESSION_COOKIE_NAME]; + const sessionId: string | undefined = req.cookies?.[this.getSessionCookieName()]; return this.getUser(sessionId); } @@ -60,7 +79,8 @@ class ThunderIDExpressClient { if (!url) return; - res.cookie(SESSION_COOKIE_NAME, userId, { + res.cookie(cookieName, userId, { httpOnly: sc?.httpOnly ?? CookieConfig.defaultHttpOnly, maxAge: (sc?.expiryTime ?? CookieConfig.defaultExpirySeconds) * 1000, sameSite: (sc?.sameSite ?? CookieConfig.defaultSameSite) as any, diff --git a/packages/express/src/constants/CookieConfig.ts b/packages/express/src/constants/CookieConfig.ts index 1ad2e96..da4d6d3 100644 --- a/packages/express/src/constants/CookieConfig.ts +++ b/packages/express/src/constants/CookieConfig.ts @@ -16,9 +16,6 @@ * under the License. */ -/** Session cookie name used across the Express SDK. */ -export const SESSION_COOKIE_NAME = 'THUNDERID_SESSION_ID'; - /** Default cookie configuration values. */ const CookieConfig = { defaultExpirySeconds: 86400, diff --git a/packages/express/src/index.ts b/packages/express/src/index.ts index 481dd4d..c8405d8 100644 --- a/packages/express/src/index.ts +++ b/packages/express/src/index.ts @@ -28,7 +28,10 @@ export {default as handleFlow} from './middleware/flow'; export type {ExpressClientConfig, ThunderIDExpressConfig, StrictExpressClientConfig} from './models/config'; // Constants -export {default as CookieConfig, SESSION_COOKIE_NAME} from './constants/CookieConfig'; +// Note: session cookie *naming* is owned by `@thunderid/node`'s `CookieConfig` class, +// re-exported below, so every server-side SDK derives the same cookie name. +export {default as CookieConfig} from './constants/CookieConfig'; -// Re-export everything from the Node SDK (includes SessionCookieConfig, ThunderIDNodeConfig, etc.) +// Re-export everything from the Node SDK (includes SessionCookieConfig, ThunderIDNodeConfig, +// the `CookieConfig` naming class, etc.) export * from '@thunderid/node'; diff --git a/packages/express/src/middleware/authentication.ts b/packages/express/src/middleware/authentication.ts index 7c970f5..47bef4d 100644 --- a/packages/express/src/middleware/authentication.ts +++ b/packages/express/src/middleware/authentication.ts @@ -18,7 +18,6 @@ import {ThunderIDRuntimeError, TokenResponse, logger as Logger} from '@thunderid/node'; import express from 'express'; -import {SESSION_COOKIE_NAME} from '../constants/CookieConfig'; import {ThunderIDExpressConfig} from '../models/config'; import ThunderIDExpressClient from '../ThunderIDExpressClient'; @@ -143,7 +142,8 @@ const handleSignOut = (): express.RequestHandler => { return; } - const sessionId: string | undefined = req.cookies?.[SESSION_COOKIE_NAME]; + const cookieName = client.getSessionCookieName(); + const sessionId: string | undefined = req.cookies?.[cookieName]; if (!sessionId) { onError( @@ -156,7 +156,7 @@ const handleSignOut = (): express.RequestHandler => { try { const signOutURL: string = await client.signOut(sessionId); if (signOutURL) { - res.cookie(SESSION_COOKIE_NAME, null, {maxAge: 0}); + res.cookie(cookieName, null, {maxAge: 0}); res.redirect(signOutURL); } } catch (e: any) { diff --git a/packages/express/src/middleware/protect.ts b/packages/express/src/middleware/protect.ts index 903fdc5..281261d 100644 --- a/packages/express/src/middleware/protect.ts +++ b/packages/express/src/middleware/protect.ts @@ -18,7 +18,6 @@ import {logger as Logger} from '@thunderid/node'; import express from 'express'; -import {SESSION_COOKIE_NAME} from '../constants/CookieConfig'; import ThunderIDExpressClient from '../ThunderIDExpressClient'; /** @@ -34,7 +33,7 @@ const protect = ( ): ((req: express.Request, res: express.Response, next: express.NextFunction) => Promise) => { return async (req: express.Request, res: express.Response, next: express.NextFunction): Promise => { const client: ThunderIDExpressClient | undefined = (req as any).thunderIDAuth; - const sessionId: string | undefined = req.cookies?.[SESSION_COOKIE_NAME]; + const sessionId: string | undefined = req.cookies?.[client?.getSessionCookieName() ?? '']; const reject = (): void => { if (onUnauthenticated) { diff --git a/packages/express/src/models/config.ts b/packages/express/src/models/config.ts index fe9fe02..5c6bffe 100644 --- a/packages/express/src/models/config.ts +++ b/packages/express/src/models/config.ts @@ -43,6 +43,13 @@ export interface StrictExpressClientConfig { * * Set `mode: 'embedded'` to enable app-native embedded auth via `handleFlow()`. * Defaults to `'redirect'` (standard OAuth 2.0 authorization-code flow). + * + * Inherits `vendor` and `sessionCookie.name` from `ThunderIDNodeConfig` — set + * either to control the session cookie name written by this SDK (default: + * `__thunderid__session`, the same `@thunderid/node` `CookieConfig` naming + * convention used by every server-side ThunderID SDK, derived from `vendor` + * defaulting to `'thunderid'`). `sessionCookie.name` takes priority over the + * `vendor`-derived default. */ export type ExpressClientConfig = ThunderIDNodeConfig & StrictExpressClientConfig; diff --git a/packages/javascript/src/StorageManager.ts b/packages/javascript/src/StorageManager.ts index bf1811a..3ed3563 100644 --- a/packages/javascript/src/StorageManager.ts +++ b/packages/javascript/src/StorageManager.ts @@ -20,22 +20,24 @@ import {AuthClientConfig} from './models/config'; import {OIDCDiscoveryApiResponse} from './models/oidc-discovery'; import {SessionData} from './models/session'; import {Stores, Storage, TemporaryStore, HybridStore, TemporaryStoreValue} from './models/store'; +import VendorConstants from './constants/VendorConstants'; import logger from './utils/logger'; type PartialData = Partial< AuthClientConfig | OIDCDiscoveryApiResponse | SessionData | TemporaryStore | HybridStore >; -export const THUNDERID_SESSION_ACTIVE = 'thunderid-session-active'; - class StorageManager { protected id: string; protected store: Storage; - public constructor(instanceID: string, store: Storage) { + protected vendor: string; + + public constructor(instanceID: string, store: Storage, vendor: string = VendorConstants.VENDOR_PREFIX) { this.id = instanceID; this.store = store; + this.vendor = vendor; } protected async setDataInBulk(key: string, data: PartialData): Promise { @@ -93,7 +95,7 @@ class StorageManager { protected static isLocalStorageAvailable(): boolean { try { - const testValue = '__THUNDERID_AUTH_CORE_LOCAL_STORAGE_TEST__'; + const testValue = '__AUTH_SDK_STORAGE_TEST__'; localStorage.setItem(testValue, testValue); localStorage.removeItem(testValue); @@ -208,17 +210,19 @@ class StorageManager { public setSessionStatus(status: string): void { // Using local storage to store the session status as it is required to be available across tabs. if (StorageManager.isLocalStorageAvailable()) { - localStorage.setItem(`${THUNDERID_SESSION_ACTIVE}`, status); + localStorage.setItem(`${this.vendor}-session-active`, status); } } public getSessionStatus(): string { - return StorageManager.isLocalStorageAvailable() ? (localStorage.getItem(`${THUNDERID_SESSION_ACTIVE}`) ?? '') : ''; + return StorageManager.isLocalStorageAvailable() + ? (localStorage.getItem(`${this.vendor}-session-active`) ?? '') + : ''; } public removeSessionStatus(): void { if (StorageManager.isLocalStorageAvailable()) { - localStorage.removeItem(`${THUNDERID_SESSION_ACTIVE}`); + localStorage.removeItem(`${this.vendor}-session-active`); } } diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index f63c041..67e9c9f 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -19,6 +19,7 @@ import OIDCDiscoveryConstants from './constants/OIDCDiscoveryConstants'; import OIDCRequestConstants from './constants/OIDCRequestConstants'; import PKCEConstants from './constants/PKCEConstants'; +import VendorConstants from './constants/VendorConstants'; import {DefaultCacheStore} from './DefaultCacheStore'; import {DefaultCrypto} from './DefaultCrypto'; import {ThunderIDAuthException} from './errors/exception'; @@ -97,8 +98,9 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { } const storageKey = clientId ? `instance_${this.instanceIdValue}-${clientId}` : `instance_${this.instanceIdValue}`; + const vendor = (fullConfig as any).vendor ?? VendorConstants.VENDOR_PREFIX; - this.storageManager = new StorageManager(storageKey, store); + this.storageManager = new StorageManager(storageKey, store, vendor); this.cryptoHelper = new IsomorphicCrypto(this.cryptoUtils); this.authHelper = new AuthenticationHelper(this.storageManager, this.cryptoHelper); this.configProvider = async (): Promise> => this.storageManager.getConfigData(); diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 54fd8f1..62b2ba3 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -196,6 +196,7 @@ export {default as buildValidatorFromRules} from './utils/buildValidatorFromRule export {default as evaluateValidationRule, DEFAULT_VALIDATION_MESSAGE_KEYS} from './utils/evaluateValidationRule'; export {default as processOpenIDScopes} from './utils/processOpenIDScopes'; export {default as withVendorCSSClassPrefix} from './utils/withVendorCSSClassPrefix'; +export {default as getVendorPrefix} from './utils/getVendorPrefix'; export { default as logger, diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index d107a47..4cc0741 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -144,6 +144,13 @@ export interface BaseConfig extends WithPreferences, WithExtensions */ applicationId?: string | undefined; + /** + * Vendor/brand namespace used to prefix storage keys, cookie names, and CSS class names + * generated by the SDK. Override this when white-labeling the SDK under a different brand. + * @default 'thunderid' + */ + vendor?: string; + /** * The base URL of the ThunderID identity server. * Example: "https://localhost:8090" diff --git a/packages/javascript/src/utils/getVendorPrefix.ts b/packages/javascript/src/utils/getVendorPrefix.ts new file mode 100644 index 0000000..b7f72f5 --- /dev/null +++ b/packages/javascript/src/utils/getVendorPrefix.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import VendorConstants from '../constants/VendorConstants'; + +/** + * Resolves the vendor/brand namespace to use for storage keys, cookie names, etc. + * Falls back to `VendorConstants.VENDOR_PREFIX` (the SDK-wide default) when no vendor + * is configured, so there is a single source of truth for the default brand name. + * + * @param vendor - The vendor value from the resolved SDK configuration, if any. + * @returns The resolved vendor prefix. + */ +export const getVendorPrefix = (vendor?: string): string => vendor ?? VendorConstants.VENDOR_PREFIX; + +export default getVendorPrefix; diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index 03d9d77..9395f7c 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -26,6 +26,7 @@ import { User, UserProfile, ThunderIDRuntimeError, + getVendorPrefix, } from '@thunderid/node'; import { I18nProvider, @@ -96,6 +97,7 @@ const ThunderIDClientProvider: FC) => { const reRenderCheckRef: RefObject = useRef(false); @@ -360,6 +362,7 @@ const ThunderIDClientProvider: FC({ clientId: process.env.NUXT_PUBLIC_THUNDERID_CLIENT_ID, signInUrl: process.env.NUXT_PUBLIC_THUNDERID_SIGN_IN_URL, signUpUrl: process.env.NUXT_PUBLIC_THUNDERID_SIGN_UP_URL, + vendor: process.env.NUXT_PUBLIC_THUNDERID_VENDOR, }, // Layer 2: nuxt.config.ts options userOptions, @@ -69,6 +71,7 @@ export default defineNuxtModule({ afterSignInUrl: '/', afterSignOutUrl: '/', scopes: ['openid', 'profile'], + vendor: VendorConstants.VENDOR_PREFIX, }, ); @@ -106,6 +109,7 @@ export default defineNuxtModule({ signInUrl: publicConfig.signInUrl, signUpUrl: publicConfig.signUpUrl, tokenRequest: publicConfig.tokenRequest, + vendor: publicConfig.vendor, }, ) as { afterSignInUrl: string; @@ -119,6 +123,7 @@ export default defineNuxtModule({ signInUrl?: string; signUpUrl?: string; tokenRequest?: ThunderIDNuxtConfig['tokenRequest']; + vendor: string; }; // Ensure clientSecret never leaks to public config diff --git a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts index e3f1034..2dae428 100644 --- a/packages/nuxt/src/runtime/components/ThunderIDRoot.ts +++ b/packages/nuxt/src/runtime/components/ThunderIDRoot.ts @@ -21,6 +21,7 @@ import type {UpdateMeProfileConfig, User, UserProfile} from '@thunderid/node'; import {FlowMetaProvider, FlowProvider, I18nProvider, ThemeProvider, UserProvider} from '@thunderid/vue'; import {defineComponent, h, type Component, type Ref, type SetupContext, type VNode} from 'vue'; import type {ThunderIDAuthState, ThunderIDNuxtConfig} from '../types'; +import {getAuthStateKey, getUserProfileStateKey} from '../utils/stateKeys'; import {useState, useRuntimeConfig} from '#imports'; /** @@ -54,17 +55,26 @@ import {useState, useRuntimeConfig} from '#imports'; const ThunderIDRoot: Component = defineComponent({ name: 'ThunderIDRoot', setup(_props: Record, {slots}: SetupContext): () => VNode { + // ── Vendor namespace from runtime config ──────────────────────────────── + // Must resolve the same `vendor` as `runtime/plugins/thunderid.ts` and + // `runtime/middleware/defineThunderIDMiddleware.ts` so all three read/write + // the same `useState` keys. + const runtimeThunderIDConfig: { + preferences?: ThunderIDNuxtConfig['preferences']; + vendor?: string; + } = useRuntimeConfig().public.thunderid as { + preferences?: ThunderIDNuxtConfig['preferences']; + vendor?: string; + }; + const vendor: string | undefined = runtimeThunderIDConfig?.vendor; + // ── Read SSR-hydrated state keys (seeded by the Nuxt plugin) ──────────── - const userProfileState: Ref = useState('thunderid:user-profile'); + const userProfileState: Ref = useState(getUserProfileStateKey(vendor)); // Used by onUpdateProfile to keep the top-level auth user claim in sync. - const authState: Ref = useState('thunderid:auth'); + const authState: Ref = useState(getAuthStateKey(vendor)); // ── Preferences from runtime config ──────────────────────────────────── - const prefs: ThunderIDNuxtConfig['preferences'] | undefined = ( - useRuntimeConfig().public.thunderid as { - preferences?: ThunderIDNuxtConfig['preferences']; - } - )?.preferences; + const prefs: ThunderIDNuxtConfig['preferences'] | undefined = runtimeThunderIDConfig?.preferences; // Gate flags — mirror the same checks in thunderid-ssr.ts so client props // always agree with what the Nitro plugin decided to fetch server-side. diff --git a/packages/nuxt/src/runtime/middleware/defineThunderIDMiddleware.ts b/packages/nuxt/src/runtime/middleware/defineThunderIDMiddleware.ts index 48e350c..2d3056c 100644 --- a/packages/nuxt/src/runtime/middleware/defineThunderIDMiddleware.ts +++ b/packages/nuxt/src/runtime/middleware/defineThunderIDMiddleware.ts @@ -16,10 +16,11 @@ * under the License. */ -import {defineNuxtRouteMiddleware, navigateTo, useState} from '#app'; +import {defineNuxtRouteMiddleware, navigateTo, useRuntimeConfig, useState} from '#app'; import type {Ref} from 'vue'; import type {RouteLocationNormalized} from 'vue-router'; import type {ThunderIDAuthState} from '../types'; +import {getAuthStateKey} from '../utils/stateKeys'; export interface ThunderIDMiddlewareOptions { /** @@ -64,7 +65,11 @@ export function defineThunderIDMiddleware( const {redirectTo = DEFAULT_REDIRECT_TO, requireOrganization = false, requireScopes = []} = options; return defineNuxtRouteMiddleware((to: RouteLocationNormalized) => { - const authState: Ref = useState('thunderid:auth'); + // Must resolve the same `vendor` as `runtime/plugins/thunderid.ts` and + // `runtime/components/ThunderIDRoot.ts` so all three read/write the same + // `useState` key. + const vendor: string | undefined = (useRuntimeConfig().public.thunderid as {vendor?: string} | undefined)?.vendor; + const authState: Ref = useState(getAuthStateKey(vendor)); if (!authState.value?.isSignedIn) { const returnTo: string = encodeURIComponent(to.fullPath); diff --git a/packages/nuxt/src/runtime/plugins/thunderid.ts b/packages/nuxt/src/runtime/plugins/thunderid.ts index c7c4d2a..62b0fd9 100644 --- a/packages/nuxt/src/runtime/plugins/thunderid.ts +++ b/packages/nuxt/src/runtime/plugins/thunderid.ts @@ -17,6 +17,7 @@ */ import {getRedirectBasedSignUpUrl} from '@thunderid/browser'; +import {VendorConstants} from '@thunderid/node'; import type {UserProfile} from '@thunderid/node'; import {ThunderIDPlugin, THUNDERID_KEY} from '@thunderid/vue'; import type {H3Event} from 'h3'; @@ -24,6 +25,7 @@ import {computed} from 'vue'; import type {ComputedRef, Ref} from 'vue'; import ThunderIDRoot from '../components/ThunderIDRoot'; import type {ThunderIDAuthState, ThunderIDSSRData} from '../types'; +import {getAuthStateKey, getUserProfileStateKey} from '../utils/stateKeys'; import type {NuxtApp} from '#app'; import {defineNuxtPlugin, useState, useRequestEvent, useRuntimeConfig, navigateTo} from '#app'; @@ -34,9 +36,9 @@ import {defineNuxtPlugin, useState, useRequestEvent, useRuntimeConfig, navigateT * Responsibilities — mirrors the split between `ThunderIDServerProvider` and * `ThunderIDClientProvider` in the Next.js SDK: * - * 1. **Auth state** — hydrate `useState('thunderid:auth')` from the Nitro - * plugin's `event.context.thunderid` so SSR and client agree on signed-in - * status and the user object. + * 1. **Auth state** — hydrate `useState(getAuthStateKey(vendor))` (default + * key: `'thunderid:auth'`) from the Nitro plugin's `event.context[vendor]` + * so SSR and client agree on signed-in status and the user object. * 2. **THUNDERID_KEY** — provide the primary auth context at the app level. * Action helpers (`signIn` / `signOut` / `signUp`) use Nuxt's * `navigateTo` so redirects work on both server and client. @@ -57,6 +59,7 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { scopes: string | string[]; signInUrl?: string; signUpUrl?: string; + vendor?: string; } = useRuntimeConfig().public.thunderid as { afterSignInUrl: string; afterSignOutUrl: string; @@ -67,8 +70,11 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { scopes: string | string[]; signInUrl?: string; signUpUrl?: string; + vendor?: string; }; + const vendor: string = publicConfig.vendor ?? VendorConstants.VENDOR_PREFIX; + // Surface misconfiguration in the browser dev console only. The server // counterpart is handled by the thunderid-ssr Nitro plugin; doing both // covers the two places a developer will actually look. @@ -88,16 +94,19 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { // Nuxt snapshots the values into the `__NUXT__` payload and the client // hydrates automatically — no extra fetch needed. - const authState: Ref = useState('thunderid:auth', () => ({ + const authState: Ref = useState(getAuthStateKey(vendor), () => ({ isLoading: true, isSignedIn: false, user: null, })); - const userProfileState: Ref = useState('thunderid:user-profile', () => null); + const userProfileState: Ref = useState( + getUserProfileStateKey(vendor), + () => null, + ); if (import.meta.server) { const event: H3Event | undefined = useRequestEvent(); - const ssr: ThunderIDSSRData | undefined = event?.context?.thunderid?.ssr; + const ssr: ThunderIDSSRData | undefined = (event?.context as Record | undefined)?.[vendor]?.ssr; if (ssr) { // Seed from the rich SSR payload written by the thunderid-ssr Nitro plugin. @@ -109,9 +118,9 @@ export default defineNuxtPlugin((nuxtApp: NuxtApp) => { userProfileState.value = ssr.userProfile; } else { // Backwards-compat: fall back to the legacy context shape (pre-Step-2 plugin). - const ssrContext: {isSignedIn?: boolean; session?: {sub?: string}} | undefined = event?.context?.thunderid as - | {isSignedIn?: boolean; session?: {sub?: string}} - | undefined; + const ssrContext: {isSignedIn?: boolean; session?: {sub?: string}} | undefined = ( + event?.context as Record | undefined + )?.[vendor] as {isSignedIn?: boolean; session?: {sub?: string}} | undefined; if (ssrContext) { authState.value = { isLoading: false, diff --git a/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts b/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts index ed4ecdc..b7c6868 100644 --- a/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts +++ b/packages/nuxt/src/runtime/server/plugins/thunderid-ssr.ts @@ -16,6 +16,7 @@ * under the License. */ +import {VendorConstants} from '@thunderid/node'; import {getRequestURL, type H3Event} from 'h3'; import {defineNitroPlugin} from 'nitropack/runtime'; import type {ThunderIDAuthState, ThunderIDNuxtConfig, ThunderIDSSRData} from '../../types'; @@ -49,8 +50,9 @@ function resolveCallbackUrl(event: H3Event): string { * within an organisation. * 4. In parallel (gated by `preferences`): * - Fetches user + SCIM2 user profile (`preferences.user.fetchUserProfile !== false`) - * 5. Writes the full {@link ThunderIDSSRData} to `event.context.thunderid.ssr` - * so the Nuxt plugin can seed `useState` keys for zero-cost hydration. + * 5. Writes the full {@link ThunderIDSSRData} to `event.context[vendor].ssr` + * (default vendor: `'thunderid'`) so the Nuxt plugin can seed `useState` + * keys for zero-cost hydration. * * Each fetch is individually wrapped in try/catch so a broken SCIM * call never crashes SSR — the client layer can recover via the existing @@ -119,14 +121,17 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { const publicConfig: ThunderIDNuxtConfig = config.public.thunderid as ThunderIDNuxtConfig; const prefs: ThunderIDNuxtConfig['preferences'] | undefined = publicConfig?.preferences; const sessionSecret: string | undefined = process.env.THUNDERID_SESSION_SECRET || config.thunderid?.sessionSecret; + // Vendor namespace for `event.context[vendor]` — must match the key the + // Nuxt plugin (`runtime/plugins/thunderid.ts`) reads from. + const vendor: string = publicConfig?.vendor ?? VendorConstants.VENDOR_PREFIX; const session: Awaited> = await verifyAndRehydrateSession( event, sessionSecret, ); if (!session) { - const eventContext: H3Event['context'] = event.context; - eventContext.thunderid = {isSignedIn: false, session: null}; + const eventContext: Record = event.context; + eventContext[vendor] = {isSignedIn: false, session: null}; return; } @@ -178,8 +183,8 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { userProfile: userProfileResult.status === 'fulfilled' ? userProfileResult.value : null, }; - const eventContext: H3Event['context'] = event.context; - eventContext.thunderid = {isSignedIn: true, session, ssr: ssrData}; + const eventContext: Record = event.context; + eventContext[vendor] = {isSignedIn: true, session, ssr: ssrData}; // Keep legacy __thunderidAuth in place so the existing Nuxt plugin // (Step 3) can be updated independently without a runtime gap. diff --git a/packages/nuxt/src/runtime/server/utils/event-context.ts b/packages/nuxt/src/runtime/server/utils/event-context.ts index 8d93f74..3aef335 100644 --- a/packages/nuxt/src/runtime/server/utils/event-context.ts +++ b/packages/nuxt/src/runtime/server/utils/event-context.ts @@ -16,12 +16,15 @@ * under the License. */ +import {VendorConstants} from '@thunderid/node'; import type {H3Event} from 'h3'; -import type {ThunderIDSessionPayload, ThunderIDSSRData} from '../../types'; +import type {ThunderIDNuxtConfig, ThunderIDSessionPayload, ThunderIDSSRData} from '../../types'; +import {useRuntimeConfig} from '#imports'; /** - * The typed shape of `event.context.thunderid` set by the ThunderID Nitro plugin - * on every SSR request. + * The typed shape of `event.context[vendor]` (default vendor: `'thunderid'`, + * i.e. `event.context.thunderid`) set by the ThunderID Nitro plugin on every + * SSR request. */ export interface ThunderIDEventContext { /** Convenience boolean derived from the session presence. */ @@ -33,7 +36,11 @@ export interface ThunderIDEventContext { } /** - * Typed accessor for `event.context.thunderid`. + * Typed accessor for `event.context[vendor]` (default: `event.context.thunderid`). + * + * Resolves the vendor namespace from `runtimeConfig.public.thunderid.vendor` + * (falling back to `'thunderid'`) so this always reads the same key the + * ThunderID Nitro plugin (`thunderid-ssr.ts`) writes to. * * Returns null when called before the ThunderID SSR plugin has populated * the context (e.g. in non-Nuxt Nitro routes that run before the plugin). @@ -50,5 +57,10 @@ export interface ThunderIDEventContext { * ``` */ export function getThunderIDContext(event: H3Event): ThunderIDEventContext | null { - return (event.context.thunderid as ThunderIDEventContext | undefined) ?? null; + const publicConfig: ThunderIDNuxtConfig | undefined = useRuntimeConfig(event).public.thunderid as + | ThunderIDNuxtConfig + | undefined; + const vendor: string = publicConfig?.vendor ?? VendorConstants.VENDOR_PREFIX; + + return ((event.context as Record)[vendor] as ThunderIDEventContext | undefined) ?? null; } diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index eab09b7..0a96240 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -87,6 +87,17 @@ export interface ThunderIDNuxtConfig { */ authMethod?: TokenEndpointAuthMethod; }; + /** + * Vendor/brand namespace used to prefix Nuxt `useState` keys, the + * `event.context` namespace, and other server-side identifiers. + * Override this when white-labeling the SDK under a different brand. + * + * Note: this is unrelated to the module's Nuxt config key (`thunderid: {...}` + * in `nuxt.config.ts`), which is fixed and not affected by this option. + * + * @default 'thunderid' + */ + vendor?: string; } /** @@ -120,8 +131,9 @@ export interface ThunderIDTempSessionPayload extends JWTPayload { /** * Full SSR payload resolved by the Nitro plugin on each page request. - * Written to `event.context.thunderid.ssr` and subsequently seeded into - * hydrated `useState` keys so the client never re-fetches on first render. + * Written to `event.context[vendor].ssr` (default vendor: `'thunderid'`, i.e. + * `event.context.thunderid.ssr`) and subsequently seeded into hydrated + * `useState` keys so the client never re-fetches on first render. */ export interface ThunderIDSSRData { isSignedIn: boolean; diff --git a/packages/nuxt/src/runtime/utils/stateKeys.ts b/packages/nuxt/src/runtime/utils/stateKeys.ts new file mode 100644 index 0000000..bda30a2 --- /dev/null +++ b/packages/nuxt/src/runtime/utils/stateKeys.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {VendorConstants} from '@thunderid/node'; + +/** + * Shared `useState` key for the ThunderID auth state (`ThunderIDAuthState`). + * + * Must stay in sync across `runtime/plugins/thunderid.ts`, + * `runtime/components/ThunderIDRoot.ts`, and + * `runtime/middleware/defineThunderIDMiddleware.ts` — all three must resolve + * the same `vendor` (from `useRuntimeConfig().public.thunderid.vendor`) to + * read/write the same reactive state. + */ +export const getAuthStateKey = (vendor: string = VendorConstants.VENDOR_PREFIX): string => `${vendor}:auth`; + +/** + * Shared `useState` key for the SSR-hydrated user profile (`UserProfile | null`). + * + * Must stay in sync across the same three files as {@link getAuthStateKey}. + */ +export const getUserProfileStateKey = (vendor: string = VendorConstants.VENDOR_PREFIX): string => + `${vendor}:user-profile`; diff --git a/packages/react/src/components/auth/Callback/OAuthCallback.tsx b/packages/react/src/components/auth/Callback/OAuthCallback.tsx index 4b7df70..d483974 100644 --- a/packages/react/src/components/auth/Callback/OAuthCallback.tsx +++ b/packages/react/src/components/auth/Callback/OAuthCallback.tsx @@ -16,8 +16,9 @@ * under the License. */ -import {navigate as browserNavigate} from '@thunderid/browser'; -import {FC, useEffect, useRef} from 'react'; +import {navigate as browserNavigate, getVendorPrefix} from '@thunderid/browser'; +import {FC, useContext, useEffect, useRef} from 'react'; +import ThunderIDContext from '../../../contexts/ThunderID/ThunderIDContext'; /** * Props for Callback component @@ -56,6 +57,11 @@ export const OAuthCallback: FC = ({onNavigate, onError}: OAu // Prevent double execution in React Strict Mode const processingRef: any = useRef(false); + // Read the vendor prefix directly from context (rather than the throwing useThunderID hook) + // so this component keeps working standalone, without a ThunderIDProvider ancestor. + const thunderIDContext = useContext(ThunderIDContext); + const vendor: string = getVendorPrefix(thunderIDContext?.vendor); + // Resolve navigation: use provided onNavigate (router-aware) or fall back to browser navigate utility const navigate = (path: string): void => { if (onNavigate) { @@ -98,7 +104,7 @@ export const OAuthCallback: FC = ({onNavigate, onError}: OAu throw new Error('Missing OAuth state parameter - possible security issue'); } - const storedData: string | null = sessionStorage.getItem(`thunderid_oauth_${state}`); + const storedData: string | null = sessionStorage.getItem(`${vendor}_oauth_${state}`); if (!storedData) { // If state not found, might be an error callback - try to handle gracefully if (oauthError) { @@ -124,12 +130,12 @@ export const OAuthCallback: FC = ({onNavigate, onError}: OAu // 3. Validate state freshness const MAX_STATE_AGE = 600000; // 10 minutes if (Date.now() - timestamp > MAX_STATE_AGE) { - sessionStorage.removeItem(`thunderid_oauth_${state}`); + sessionStorage.removeItem(`${vendor}_oauth_${state}`); throw new Error('OAuth state expired - please try again'); } // 4. Clean up state - sessionStorage.removeItem(`thunderid_oauth_${state}`); + sessionStorage.removeItem(`${vendor}_oauth_${state}`); // 5. Handle OAuth error response if (oauthError) { @@ -178,7 +184,7 @@ export const OAuthCallback: FC = ({onNavigate, onError}: OAu }; processOAuthCallback(); - }, [onNavigate, onError]); + }, [onNavigate, onError, vendor]); // Headless component - no UI, just processing logic return null; diff --git a/packages/react/src/components/auth/Callback/TokenCallback.tsx b/packages/react/src/components/auth/Callback/TokenCallback.tsx index 109ac38..e42289b 100644 --- a/packages/react/src/components/auth/Callback/TokenCallback.tsx +++ b/packages/react/src/components/auth/Callback/TokenCallback.tsx @@ -56,7 +56,7 @@ export const TokenCallback: FC = ({ signUpPath = '/signup', }: TokenCallbackProps) => { const processingRef: any = useRef(false); - const {isInitialized, isLoading, signIn, signUp, getStorageManager} = useThunderID(); + const {isInitialized, isLoading, signIn, signUp, getStorageManager, vendor} = useThunderID(); const navigate = (path: string): void => { if (onNavigate) { @@ -82,7 +82,7 @@ export const TokenCallback: FC = ({ const state: string = redirectUrlObj.searchParams.get('state') || crypto.randomUUID(); sessionStorage.setItem( - `thunderid_oauth_${state}`, + `${vendor}_oauth_${state}`, JSON.stringify({ path: isRegistrationFlow ? signUpPath : signInPath, timestamp: Date.now(), @@ -110,7 +110,7 @@ export const TokenCallback: FC = ({ }; const redirectWithError = (error: Error, isRegistrationFlow?: boolean): void => { - sessionStorage.removeItem('thunderid_execution_id'); + sessionStorage.removeItem(`${vendor}_execution_id`); onError?.(error); @@ -161,7 +161,7 @@ export const TokenCallback: FC = ({ if (response.type === EmbeddedSignInFlowType.Redirection) { const redirectURL: string | undefined = response.data?.redirectURL || response?.redirectURL; const nextExecutionId: string = response.executionId || executionId; - sessionStorage.setItem('thunderid_execution_id', nextExecutionId); + sessionStorage.setItem(`${vendor}_execution_id`, nextExecutionId); if (redirectURL) { initiateOAuthRedirect(redirectURL, isRegistrationFlow); @@ -172,7 +172,7 @@ export const TokenCallback: FC = ({ if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { const redirectUrl: string | undefined = response?.redirectUrl || response?.redirect_uri; - sessionStorage.removeItem('thunderid_execution_id'); + sessionStorage.removeItem(`${vendor}_execution_id`); await storageManager.removeHybridDataParameter('authId'); onSuccess?.({ @@ -198,7 +198,7 @@ export const TokenCallback: FC = ({ } const nextExecutionId: string = response.executionId || executionId; - sessionStorage.setItem('thunderid_execution_id', nextExecutionId); + sessionStorage.setItem(`${vendor}_execution_id`, nextExecutionId); if (response.challengeToken) { await storageManager.setTemporaryDataParameter('challengeToken', response.challengeToken); @@ -229,6 +229,7 @@ export const TokenCallback: FC = ({ signUp, signInPath, signUpPath, + vendor, ]); return null; diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx index 39cf2c7..eb9f008 100644 --- a/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx +++ b/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx @@ -278,7 +278,7 @@ const BaseAcceptInvite: FC = ({ showTitle = true, showSubtitle = true, }: BaseAcceptInviteProps): ReactElement => { - const {meta, isInitialized, getStorageManager} = useThunderID(); + const {meta, isInitialized, getStorageManager, vendor} = useThunderID(); const {t} = useTranslation(preferences?.i18n); const {theme} = useTheme(); const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); @@ -415,6 +415,7 @@ const BaseAcceptInvite: FC = ({ */ useOAuthCallback({ currentExecutionId: executionId ?? null, + executionIdStorageKey: `${vendor}_execution_id`, isInitialized: isStorageReady, onComplete: () => { setIsValidatingToken(false); @@ -610,7 +611,7 @@ const BaseAcceptInvite: FC = ({ const redirectURL: any = response.data?.redirectURL || response?.redirectURL; if (redirectURL && typeof window !== 'undefined') { // Initiate OAuth redirect with secure state management - initiateOAuthRedirect(redirectURL); + initiateOAuthRedirect(redirectURL, vendor); return; } } @@ -693,7 +694,7 @@ const BaseAcceptInvite: FC = ({ try { // Store executionId in sessionStorage for OAuth callback if (executionId) { - sessionStorage.setItem('thunderid_execution_id', executionId); + sessionStorage.setItem(`${vendor}_execution_id`, executionId); } // Send the invite token to validate and continue the flow diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index d07a56f..5ae2656 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -227,7 +227,7 @@ const SignIn: FC = ({ variant, children, }: SignInProps): ReactElement => { - const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta, getStorageManager, scopes} = + const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta, getStorageManager, scopes, vendor} = useThunderID(); const {t} = useTranslation(preferences?.i18n); @@ -262,9 +262,9 @@ const SignIn: FC = ({ const setExecutionId = (executionId: string | null): void => { setCurrentExecutionId(executionId); if (executionId) { - sessionStorage.setItem('thunderid_execution_id', executionId); + sessionStorage.setItem(`${vendor}_execution_id`, executionId); } else { - sessionStorage.removeItem('thunderid_execution_id'); + sessionStorage.removeItem(`${vendor}_execution_id`); } }; @@ -427,7 +427,7 @@ const SignIn: FC = ({ const urlParams: any = getUrlParams(); await handleAuthId(urlParams.authId); - initiateOAuthRedirect(redirectURL); + initiateOAuthRedirect(redirectURL, vendor); return true; } } @@ -462,7 +462,7 @@ const SignIn: FC = ({ setExecutionId(null); await setChallengeToken(null); setIsFlowInitialized(false); - sessionStorage.removeItem('thunderid_execution_id'); + sessionStorage.removeItem(`${vendor}_execution_id`); try { const storageManager: any = await getStorageManager(); await storageManager?.removeHybridDataParameter?.('authId'); @@ -511,7 +511,7 @@ const SignIn: FC = ({ // sessionStorage so the in-progress flow can be resumed instead of starting a new flow. // This is required for authorization_code apps where direct new-flow initiation is blocked // server-side and the flow must be initiated through the OAuth /authorize endpoint. - const storedExecutionId: any = !urlParams.executionId ? sessionStorage.getItem('thunderid_execution_id') : null; + const storedExecutionId: any = !urlParams.executionId ? sessionStorage.getItem(`${vendor}_execution_id`) : null; const resumeExecutionId: any = urlParams.executionId || storedExecutionId; if (!resumeExecutionId && !effectiveApplicationId) { @@ -878,6 +878,7 @@ const SignIn: FC = ({ useOAuthCallback({ currentExecutionId, + executionIdStorageKey: `${vendor}_execution_id`, isInitialized: isInitialized && !isLoading && isStorageReady, isSubmitting, onError: (err: any) => { diff --git a/packages/react/src/contexts/I18n/I18nProvider.tsx b/packages/react/src/contexts/I18n/I18nProvider.tsx index 7f00a2b..7a7d228 100644 --- a/packages/react/src/contexts/I18n/I18nProvider.tsx +++ b/packages/react/src/contexts/I18n/I18nProvider.tsx @@ -26,6 +26,7 @@ import { TranslationBundleConstants, getDefaultI18nBundles, normalizeTranslations, + getVendorPrefix, } from '@thunderid/browser'; import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState} from 'react'; import I18nContext, {I18nContextValue} from './I18nContext'; @@ -35,7 +36,6 @@ const logger: ReturnType = createPackageCom 'I18nProvider', ); -const DEFAULT_STORAGE_KEY = 'thunderid-i18n-language'; const DEFAULT_URL_PARAM = 'lang'; export interface I18nProviderProps { @@ -43,6 +43,13 @@ export interface I18nProviderProps { * The i18n preferences from the ThunderIDProvider */ preferences?: I18nPreferences; + + /** + * Vendor/brand namespace used to derive the default language storage key + * (e.g. `${vendor}-i18n-language`). Resolved from the SDK config's `vendor` option. + * @default 'thunderid' + */ + vendor?: string; } const detectBrowserLanguage = (): string => { @@ -135,12 +142,13 @@ const detectUrlParamLanguage = (paramName: string): string | null => { const I18nProvider: FC> = ({ children, preferences, + vendor, }: PropsWithChildren): ReactElement => { // Get default bundles from the browser package const defaultBundles: Record = getDefaultI18nBundles(); const storageStrategy: I18nStorageStrategy = preferences?.storageStrategy ?? 'cookie'; - const storageKey: string = preferences?.storageKey ?? DEFAULT_STORAGE_KEY; + const storageKey: string = preferences?.storageKey ?? `${getVendorPrefix(vendor)}-i18n-language`; const urlParamConfig: string | false = preferences?.urlParam === undefined ? DEFAULT_URL_PARAM : preferences.urlParam; const resolvedCookieDomain: string | undefined = useMemo((): string | undefined => { diff --git a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts index 0507021..d3e85b4 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts +++ b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts @@ -25,6 +25,7 @@ import { SignInOptions, TokenExchangeRequestConfig, TokenResponse, + VendorConstants, } from '@thunderid/browser'; import {Context, createContext} from 'react'; import {ThunderIDReactConfig} from '../../models/config'; @@ -208,6 +209,12 @@ export type ThunderIDContextProps = { signUpUrl: string | undefined; user: any; + + /** + * Vendor/brand namespace used to prefix storage keys, cookie names, and CSS class names. + * Resolved from the `vendor` config option, defaulting to `'thunderid'`. + */ + vendor: string; } & Pick; /** @@ -249,6 +256,7 @@ const ThunderIDContext: Context = createContext Promise.resolve({} as any), signUpUrl: undefined, user: null, + vendor: VendorConstants.VENDOR_PREFIX, }); ThunderIDContext.displayName = 'ThunderIDContext'; diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index c69eed8..8d6c044 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -27,6 +27,7 @@ import { extractUserClaimsFromIdToken, EmbeddedSignInFlowResponse, createPackageComponentLogger, + getVendorPrefix, } from '@thunderid/browser'; import {FC, RefObject, PropsWithChildren, ReactElement, useEffect, useMemo, useRef, useState, useCallback} from 'react'; import ThunderIDContext from './ThunderIDContext'; @@ -245,7 +246,9 @@ const ThunderIDProvider: FC> = ({ const urlParams: URLSearchParams = currentUrl.searchParams; const code: string | null = urlParams.get('code'); const executionIdFromUrl: string | null = urlParams.get('executionId'); - const storedExecutionId: string | null = sessionStorage.getItem('thunderid_execution_id'); + const storedExecutionId: string | null = sessionStorage.getItem( + `${getVendorPrefix(config.vendor)}_execution_id`, + ); if (code && !executionIdFromUrl && !storedExecutionId) { await signIn(); @@ -468,10 +471,12 @@ const ThunderIDProvider: FC> = ({ signUpUrl, syncSession, user, + vendor: getVendorPrefix(config.vendor), }), [ applicationId, config?.organizationHandle, + config.vendor, config.afterSignInUrl, config.scopes, signInUrl, @@ -507,7 +512,7 @@ const ThunderIDProvider: FC> = ({ return ( - + = createVueLogger('Callback'); @@ -45,6 +47,11 @@ const Callback: Component = defineComponent({ onNavigate: {default: undefined, type: Function as unknown as () => (path: string) => void}, }, setup(props: CallbackSetupProps) { + // Read the vendor prefix directly from injection (rather than the throwing useThunderID + // composable) so this component keeps working standalone, without a ThunderIDProvider ancestor. + const thunderIDContext: ThunderIDContext | null = inject(THUNDERID_KEY, null); + const vendor: string = getVendorPrefix(thunderIDContext?.vendor); + const navigate = (path: string): void => { if (props.onNavigate) { props.onNavigate(path); @@ -84,7 +91,7 @@ const Callback: Component = defineComponent({ throw new Error('Missing OAuth state parameter - possible security issue'); } - const storedData: string | null = sessionStorage.getItem(`thunderid_oauth_${state}`); + const storedData: string | null = sessionStorage.getItem(`${vendor}_oauth_${state}`); if (!storedData) { if (oauthError) { const errorMsg: string = errorDescription || oauthError || 'OAuth authentication failed'; @@ -110,12 +117,12 @@ const Callback: Component = defineComponent({ // 3. Validate state freshness const MAX_STATE_AGE = 300000; // 5 minutes if (Date.now() - timestamp > MAX_STATE_AGE) { - sessionStorage.removeItem(`thunderid_oauth_${state}`); + sessionStorage.removeItem(`${vendor}_oauth_${state}`); throw new Error('OAuth state expired - please try again'); } // 4. Clean up state - sessionStorage.removeItem(`thunderid_oauth_${state}`); + sessionStorage.removeItem(`${vendor}_oauth_${state}`); // 5. Handle OAuth error response if (oauthError) { diff --git a/packages/vue/src/components/auth/sign-in/SignIn.ts b/packages/vue/src/components/auth/sign-in/SignIn.ts index a59c899..ebe09c0 100644 --- a/packages/vue/src/components/auth/sign-in/SignIn.ts +++ b/packages/vue/src/components/auth/sign-in/SignIn.ts @@ -49,8 +49,6 @@ import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTra import {initiateOAuthRedirect} from '../../../utils/oauth'; import {handlePasskeyAuthentication, handlePasskeyRegistration} from '../../../utils/passkey'; -const EXECUTION_ID_STORAGE_KEY = 'thunderid_execution_id'; - interface PasskeyState { actionId: string | null; challenge: string | null; @@ -121,10 +119,13 @@ const SignIn: Component = defineComponent({ isLoading: sdkLoading, scopes, getStorageManager, + vendor, } = useThunderID(); const {meta: flowMeta} = useFlowMeta(); const {t} = useI18n(); + const executionIdStorageKey = `${vendor}_execution_id`; + // Flow state const components: Ref = ref([]); const additionalData: Ref> = ref({}); @@ -152,9 +153,9 @@ const SignIn: Component = defineComponent({ const persistExecutionId = (executionId: string | null): void => { currentExecutionId.value = executionId; if (executionId) { - sessionStorage.setItem(EXECUTION_ID_STORAGE_KEY, executionId); + sessionStorage.setItem(executionIdStorageKey, executionId); } else { - sessionStorage.removeItem(EXECUTION_ID_STORAGE_KEY); + sessionStorage.removeItem(executionIdStorageKey); } }; @@ -266,7 +267,7 @@ const SignIn: Component = defineComponent({ await sm.setHybridDataParameter('authId', urlParams.authId); } } - initiateOAuthRedirect(redirectURL); + initiateOAuthRedirect(redirectURL, vendor); return; } } @@ -370,7 +371,7 @@ const SignIn: Component = defineComponent({ await sm.setHybridDataParameter('authId', urlParams.authId); } } - initiateOAuthRedirect(redirectURL); + initiateOAuthRedirect(redirectURL, vendor); return; } } @@ -551,7 +552,7 @@ const SignIn: Component = defineComponent({ useOAuthCallback({ currentFlowId: currentExecutionId, - flowIdStorageKey: EXECUTION_ID_STORAGE_KEY, + flowIdStorageKey: executionIdStorageKey, isInitialized, isSubmitting, onError: (err: any) => { diff --git a/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts b/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts index e122f6e..9a6a21a 100644 --- a/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts +++ b/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts @@ -16,7 +16,7 @@ * under the License. */ -import {FlowMetadataResponse, withVendorCSSClassPrefix} from '@thunderid/browser'; +import {FlowMetadataResponse, withVendorCSSClassPrefix, getVendorPrefix} from '@thunderid/browser'; import { type Component, type PropType, @@ -25,12 +25,15 @@ import { type VNode, defineComponent, h, + inject, ref, watch, } from 'vue'; import useFlowMeta from '../../../composables/useFlowMeta'; import useI18n from '../../../composables/useI18n'; import {useOAuthCallback} from '../../../composables/useOAuthCallback'; +import {THUNDERID_KEY} from '../../../keys'; +import type {ThunderIDContext} from '../../../models/contexts'; import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; import {initiateOAuthRedirect} from '../../../utils/oauth'; import {renderInviteUserComponents} from '../../auth/sign-in/AuthOptionFactory'; @@ -135,6 +138,12 @@ const BaseAcceptInvite: Component = defineComponent({ const {meta: metaRef} = useFlowMeta(); const {t} = useI18n(); + // This component can be used standalone (outside , see AcceptInvite.ts), + // so read the vendor prefix via optional injection rather than the throwing useThunderID composable. + const thunderIDContext: ThunderIDContext | null = inject(THUNDERID_KEY, null); + const vendor: string = getVendorPrefix(thunderIDContext?.vendor); + const flowIdStorageKey = `${vendor}_flow_id`; + // ── State ── const isLoading: Ref = ref(false); const isValidatingToken: Ref = ref(true); @@ -179,6 +188,7 @@ const BaseAcceptInvite: Component = defineComponent({ useOAuthCallback({ currentFlowId: ref(props.flowId ?? null), + flowIdStorageKey, isInitialized: ref(true), onComplete: () => { isComplete.value = true; @@ -286,7 +296,7 @@ const BaseAcceptInvite: Component = defineComponent({ if (response.type === 'REDIRECTION') { const redirectURL: string | undefined = response.data?.redirectURL || (response as any)?.redirectURL; if (redirectURL) { - initiateOAuthRedirect(redirectURL); + initiateOAuthRedirect(redirectURL, vendor); return; } } @@ -349,7 +359,7 @@ const BaseAcceptInvite: Component = defineComponent({ apiError.value = null; try { - if (flowId) sessionStorage.setItem('thunderid_flow_id', flowId); + if (flowId) sessionStorage.setItem(flowIdStorageKey, flowId); const payload: any = {flowId, inputs: {inviteToken}, verbose: true}; const rawResponse: AcceptInviteFlowResponse = await props.onSubmit(payload); diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index 838d7a5..ffcb37b 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -101,6 +101,12 @@ export interface ThunderIDContext { /** The current user object, or `null` if not signed in. */ user: Readonly>; + + /** + * Vendor/brand namespace used to prefix storage keys, cookie names, and CSS class names. + * Resolved from the `vendor` config option, defaulting to `VendorConstants.VENDOR_PREFIX`. + */ + vendor: string; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/packages/vue/src/plugins/ThunderIDPlugin.ts b/packages/vue/src/plugins/ThunderIDPlugin.ts index 48e5ba3..199854b 100644 --- a/packages/vue/src/plugins/ThunderIDPlugin.ts +++ b/packages/vue/src/plugins/ThunderIDPlugin.ts @@ -44,6 +44,12 @@ export interface ThunderIDPluginOptions { * initialisation so it is safe to call during SSR. */ mode?: 'browser' | 'delegated'; + + /** + * Vendor/brand namespace used to prefix the injected `