Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -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/**"
86 changes: 86 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/<framework>/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/<pkg> run build
pnpm --filter @thunderid/<pkg> 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
```
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
30 changes: 25 additions & 5 deletions packages/express/src/ThunderIDExpressClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -39,8 +46,20 @@ class ThunderIDExpressClient<T extends ExpressClientConfig = ExpressClientConfig
return this._expressConfig;
}

/**
* Resolves the session cookie name for this client instance, honoring an
* explicit `sessionCookie.name` override before falling back to the
* `vendor`-derived default (which itself defaults to `'thunderid'`).
*/
public getSessionCookieName(): string {
return NodeCookieConfig.resolveSessionCookieName(
this._expressConfig?.vendor,
this._expressConfig?.sessionCookie?.name,
);
}

public async getUserFromRequest(req: express.Request): Promise<User | undefined> {
const sessionId: string | undefined = req.cookies?.[SESSION_COOKIE_NAME];
const sessionId: string | undefined = req.cookies?.[this.getSessionCookieName()];
return this.getUser(sessionId);
}

Expand All @@ -60,7 +79,8 @@ class ThunderIDExpressClient<T extends ExpressClientConfig = ExpressClientConfig
);
}

let userId: string = req.cookies?.[SESSION_COOKIE_NAME];
const cookieName = this.getSessionCookieName();
let userId: string = req.cookies?.[cookieName];
if (!userId) {
userId = uuidv4();
}
Expand All @@ -70,7 +90,7 @@ class ThunderIDExpressClient<T extends ExpressClientConfig = ExpressClientConfig
const authRedirectCallback = (url: string): void => {
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,
Expand Down
3 changes: 0 additions & 3 deletions packages/express/src/constants/CookieConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions packages/express/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
6 changes: 3 additions & 3 deletions packages/express/src/middleware/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
3 changes: 1 addition & 2 deletions packages/express/src/middleware/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -34,7 +33,7 @@ const protect = (
): ((req: express.Request, res: express.Response, next: express.NextFunction) => Promise<void>) => {
return async (req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> => {
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) {
Expand Down
7 changes: 7 additions & 0 deletions packages/express/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
18 changes: 11 additions & 7 deletions packages/javascript/src/StorageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = Partial<
AuthClientConfig<T> | OIDCDiscoveryApiResponse | SessionData | TemporaryStore | HybridStore
>;

export const THUNDERID_SESSION_ACTIVE = 'thunderid-session-active';

class StorageManager<T> {
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<T>): Promise<void> {
Expand Down Expand Up @@ -93,7 +95,7 @@ class StorageManager<T> {

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);
Expand Down Expand Up @@ -208,17 +210,19 @@ class StorageManager<T> {
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`);
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/javascript/src/ThunderIDJavaScriptClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -97,8 +98,9 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
}

const storageKey = clientId ? `instance_${this.instanceIdValue}-${clientId}` : `instance_${this.instanceIdValue}`;
const vendor = (fullConfig as any).vendor ?? VendorConstants.VENDOR_PREFIX;

this.storageManager = new StorageManager<T>(storageKey, store);
this.storageManager = new StorageManager<T>(storageKey, store, vendor);
Comment on lines +101 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use getVendorPrefix(vendor) instead of repeating the inline fallback.

The coding guidelines explicitly say to resolve vendor namespaces with getVendorPrefix(vendor) rather than repeating an inline vendor ?? 'thunderid' fallback. Line 101 does exactly this pattern. Additionally, (fullConfig as any).vendor bypasses type safety — if vendor is now a supported config option, it should be declared on the AuthClientConfig type so the as any cast is unnecessary.

As per coding guidelines: "When a brand-scoped namespace is genuinely required, resolve it with getVendorPrefix(vendor) rather than hardcoding a brand literal or repeating an inline vendor ?? 'thunderid' fallback."

♻️ Proposed refactor
-    const vendor = (fullConfig as any).vendor ?? VendorConstants.VENDOR_PREFIX;
-
-    this.storageManager = new StorageManager<T>(storageKey, store, vendor);
+    const vendor = getVendorPrefix((fullConfig as any).vendor);
+
+    this.storageManager = new StorageManager<T>(storageKey, store, vendor);

If vendor is added to AuthClientConfig<T>, the cast can be removed entirely:

-    const vendor = getVendorPrefix((fullConfig as any).vendor);
+    const vendor = getVendorPrefix(fullConfig.vendor);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const vendor = (fullConfig as any).vendor ?? VendorConstants.VENDOR_PREFIX;
this.storageManager = new StorageManager<T>(storageKey, store);
this.storageManager = new StorageManager<T>(storageKey, store, vendor);
const vendor = getVendorPrefix((fullConfig as any).vendor);
this.storageManager = new StorageManager<T>(storageKey, store, vendor);
🧰 Tools
🪛 ESLint

[error] 101-101: Unsafe assignment of an any value.

(@typescript-eslint/no-unsafe-assignment)


[error] 101-101: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 101-101: Unsafe member access .vendor on an any value.

(@typescript-eslint/no-unsafe-member-access)


[error] 103-103: Unsafe argument of type any assigned to a parameter of type string.

(@typescript-eslint/no-unsafe-argument)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/javascript/src/ThunderIDJavaScriptClient.ts` around lines 101 - 103,
Replace the inline vendor fallback in the ThunderIDJavaScriptClient
initialization with getVendorPrefix(vendor). Add vendor to the AuthClientConfig
type if it is a supported option, then read it directly from fullConfig and
remove the (fullConfig as any) cast before passing the resolved prefix to
StorageManager.

Source: Coding guidelines

this.cryptoHelper = new IsomorphicCrypto(this.cryptoUtils);
this.authHelper = new AuthenticationHelper(this.storageManager, this.cryptoHelper);
this.configProvider = async (): Promise<AuthClientConfig<T>> => this.storageManager.getConfigData();
Expand Down
1 change: 1 addition & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ export interface BaseConfig<T = unknown> 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"
Expand Down
Loading
Loading