From b47c20897511ca162fffc21842207610aa8a03e2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:31:23 -0700 Subject: [PATCH 1/8] fix: surface the API-key requirement from getOrganizationAction Rethrow the WorkOS SDK's ApiKeyRequiredException instead of swallowing it to null, and let the impersonation banner degrade when the action rejects. --- src/client/components/impersonation.tsx | 5 ++++- src/server/action-bodies.ts | 9 ++++++++- src/server/actions.ts | 3 +++ src/server/authkit-loader.ts | 4 ++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/client/components/impersonation.tsx b/src/client/components/impersonation.tsx index 5c9de92..822d2e5 100644 --- a/src/client/components/impersonation.tsx +++ b/src/client/components/impersonation.tsx @@ -18,7 +18,10 @@ export function Impersonation({ side = 'bottom', returnTo, ...props }: Impersona useEffect(() => { if (!organizationId || !impersonator || !user) return; if (organization && organization.id === organizationId) return; - getOrganizationAction({ data: organizationId }).then(setOrganization); + // Rejects without a WorkOS API key (public-client mode); show the banner without the org name. + getOrganizationAction({ data: organizationId }) + .then(setOrganization) + .catch(() => setOrganization(null)); }, [organizationId, impersonator, user]); if (!impersonator || !user) return null; diff --git a/src/server/action-bodies.ts b/src/server/action-bodies.ts index 8fe488d..2728d61 100644 --- a/src/server/action-bodies.ts +++ b/src/server/action-bodies.ts @@ -67,6 +67,11 @@ export async function switchToOrganizationBody(data: { return mapAuthToBaseInfo(result); } +/** + * Requires a WorkOS API key. In public-client (keyless) mode the WorkOS SDK + * throws an `ApiKeyRequiredException`, which is rethrown so callers see why; + * every other failure still resolves to `null`. + */ export async function getOrganizationBody(organizationId: string): Promise { try { // Authorization: only resolve the organization the caller is currently @@ -82,7 +87,9 @@ export async function getOrganizationBody(organizationId: string): Promise organizationId) diff --git a/src/server/authkit-loader.ts b/src/server/authkit-loader.ts index 89e0dd8..39b1456 100644 --- a/src/server/authkit-loader.ts +++ b/src/server/authkit-loader.ts @@ -32,6 +32,10 @@ export async function getConfig(key: K): Promise< return getConfigFromSession(key); } +/** + * Delegates to authkit-session, which decides what's required. With public-client + * support there, `WORKOS_API_KEY` is optional; no extra key check is added here. + */ export async function validateConfig(): Promise { const { validateConfig: validateConfigFromSession } = await import('@workos/authkit-session'); return validateConfigFromSession(); From d13317953af9fb7a25c753358dbe4894e8e4b586 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:31:23 -0700 Subject: [PATCH 2/8] fix: name WORKOS_API_KEY when getOrganizationAction runs without a key Wrap the WorkOS SDK's ApiKeyRequiredException in an error naming WORKOS_API_KEY. The impersonation banner logs the rejection and renders without the organization name instead of leaving an unhandled rejection. --- src/client/components/impersonation.tsx | 10 ++++--- src/client/impersonation.spec.tsx | 40 +++++++++++++++++++++++++ src/server/action-bodies.ts | 12 ++++++-- src/server/actions.spec.ts | 36 ++++++++++++++++++++++ src/server/actions.ts | 2 +- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/client/components/impersonation.tsx b/src/client/components/impersonation.tsx index 822d2e5..23a6f3b 100644 --- a/src/client/components/impersonation.tsx +++ b/src/client/components/impersonation.tsx @@ -18,10 +18,12 @@ export function Impersonation({ side = 'bottom', returnTo, ...props }: Impersona useEffect(() => { if (!organizationId || !impersonator || !user) return; if (organization && organization.id === organizationId) return; - // Rejects without a WorkOS API key (public-client mode); show the banner without the org name. - getOrganizationAction({ data: organizationId }) - .then(setOrganization) - .catch(() => setOrganization(null)); + getOrganizationAction({ data: organizationId }).then(setOrganization, (error) => { + // Show no organization name, like an unknown organization. Log rather than swallow: in + // public-client (keyless) mode this is the API-key error, otherwise a real failure. + setOrganization(null); + console.error('[authkit-tanstack-react-start] Failed to load the impersonated organization:', error); + }); }, [organizationId, impersonator, user]); if (!impersonator || !user) return null; diff --git a/src/client/impersonation.spec.tsx b/src/client/impersonation.spec.tsx index 8bad69c..0c9033b 100644 --- a/src/client/impersonation.spec.tsx +++ b/src/client/impersonation.spec.tsx @@ -65,6 +65,46 @@ describe('Impersonation', () => { expect(getOrganizationAction).toHaveBeenCalledWith({ data: 'org_123' }); }); + it('shows no organization name, logs, and leaves no unhandled rejection when the action rejects', async () => { + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error( + 'getOrganizationAction requires a WorkOS API key; set WORKOS_API_KEY. Public-client (keyless) mode supports sign-in only.', + ); + mockAuth({ organizationId: 'org_123' }); + vi.mocked(getOrganizationAction).mockRejectedValue(error); + + try { + const { container } = await act(async () => render()); + // Let any unhandled rejection surface before asserting. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(getOrganizationAction).toHaveBeenCalledWith({ data: 'org_123' }); + expect(container.querySelector('[data-workos-impersonation-root]')).toBeInTheDocument(); + expect(screen.getByText('user@example.com')).toBeInTheDocument(); + expect(container).not.toHaveTextContent('within'); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('impersonated organization'), error); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + consoleError.mockRestore(); + } + }); + + it('shows the organization name when the action resolves', async () => { + mockAuth({ organizationId: 'org_123' }); + vi.mocked(getOrganizationAction).mockResolvedValue({ id: 'org_123', name: 'Test Org' }); + + await act(async () => { + render(); + }); + + expect(await screen.findByText('Test Org')).toBeInTheDocument(); + }); + it('should render at the bottom by default', () => { mockAuth(); const { container } = render(); diff --git a/src/server/action-bodies.ts b/src/server/action-bodies.ts index 2728d61..5567e0d 100644 --- a/src/server/action-bodies.ts +++ b/src/server/action-bodies.ts @@ -69,8 +69,9 @@ export async function switchToOrganizationBody(data: { /** * Requires a WorkOS API key. In public-client (keyless) mode the WorkOS SDK - * throws an `ApiKeyRequiredException`, which is rethrown so callers see why; - * every other failure still resolves to `null`. + * rejects the call with an `ApiKeyRequiredException` before any request; that + * is rethrown as an error naming `WORKOS_API_KEY`. Every other failure still + * resolves to `null`. */ export async function getOrganizationBody(organizationId: string): Promise { try { @@ -89,7 +90,12 @@ export async function getOrganizationBody(organizationId: string): Promise { expect(result).toBeNull(); }); + + it('rejects with an error naming WORKOS_API_KEY when the client has no API key', async () => { + mockAuthContext = { + auth: () => ({ user: { id: 'user_123' }, claims: { org_id: 'org_123' } }), + request: new Request('http://test.local'), + }; + // What a keyless WorkOS client throws for a management API, before any request. + // The SDK falls back to process.env.WORKOS_API_KEY, so keep it out. + vi.stubEnv('WORKOS_API_KEY', undefined); + const keyless = new WorkOS({ clientId: 'client_123' }); + vi.unstubAllEnvs(); + expect(keyless.key).toBeUndefined(); + mockGetOrganization.mockImplementation((id: string) => keyless.organizations.getOrganization(id)); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const rejection = getOrganizationAction({ data: 'org_123' }); + + await expect(rejection).rejects.toThrow( + 'getOrganizationAction requires a WorkOS API key; set WORKOS_API_KEY. Public-client (keyless) mode supports sign-in only.', + ); + await expect(rejection).rejects.toMatchObject({ cause: expect.any(ApiKeyRequiredException) }); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('still returns null for an unauthorized organization without an API key', async () => { + mockAuthContext = { + auth: () => ({ user: { id: 'user_123' }, claims: { org_id: 'org_123' } }), + request: new Request('http://test.local'), + }; + mockGetOrganization.mockRejectedValue(new ApiKeyRequiredException('/organizations/org_456')); + + await expect(getOrganizationAction({ data: 'org_456' })).resolves.toBeNull(); + expect(mockGetOrganization).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/server/actions.ts b/src/server/actions.ts index 7fef2c9..394b341 100644 --- a/src/server/actions.ts +++ b/src/server/actions.ts @@ -65,7 +65,7 @@ export const switchToOrganizationAction = createServerFn({ method: 'POST' }) * Fetch organization details by ID. * * Requires a WorkOS API key: in public-client (keyless) mode this rejects with - * the WorkOS SDK's `ApiKeyRequiredException`. + * an error naming `WORKOS_API_KEY`. */ export const getOrganizationAction = createServerFn({ method: 'GET' }) .validator((organizationId: string) => organizationId) From 5b6218b6d96ff7ecfffcbd7c4cc5b6e417ce5023 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:31:23 -0700 Subject: [PATCH 3/8] test: type the unhandled-rejection probe in the impersonation spec --- src/client/impersonation.spec.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/client/impersonation.spec.tsx b/src/client/impersonation.spec.tsx index 0c9033b..773e31d 100644 --- a/src/client/impersonation.spec.tsx +++ b/src/client/impersonation.spec.tsx @@ -15,6 +15,13 @@ vi.mock('../server/actions', () => ({ const mockSignOut = vi.fn(); +// Node's process (the client project has no @types/node); used to observe unhandled rejections. +const nodeProcess = ( + globalThis as unknown as { + process: { on(event: string, fn: () => void): void; off(event: string, fn: () => void): void }; + } +).process; + function mockAuth(overrides: Record = {}) { vi.mocked(useAuth).mockReturnValue({ impersonator: { email: 'admin@example.com', reason: 'testing' }, @@ -67,7 +74,7 @@ describe('Impersonation', () => { it('shows no organization name, logs, and leaves no unhandled rejection when the action rejects', async () => { const unhandled = vi.fn(); - process.on('unhandledRejection', unhandled); + nodeProcess.on('unhandledRejection', unhandled); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const error = new Error( 'getOrganizationAction requires a WorkOS API key; set WORKOS_API_KEY. Public-client (keyless) mode supports sign-in only.', @@ -89,7 +96,7 @@ describe('Impersonation', () => { expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('impersonated organization'), error); expect(unhandled).not.toHaveBeenCalled(); } finally { - process.off('unhandledRejection', unhandled); + nodeProcess.off('unhandledRejection', unhandled); consoleError.mockRestore(); } }); From 6e787309cb52ee2168abe0948735342fdece3bfc Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:31:23 -0700 Subject: [PATCH 4/8] test: cover config, middleware and sign-in without an API key --- src/server/public-client.spec.ts | 137 +++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/server/public-client.spec.ts diff --git a/src/server/public-client.spec.ts b/src/server/public-client.spec.ts new file mode 100644 index 0000000..66c6971 --- /dev/null +++ b/src/server/public-client.spec.ts @@ -0,0 +1,137 @@ +/** + * Public-client (keyless) mode as surfaced by this package. + * + * `@workos/authkit-session` owns config validation and the PKCE flows, and is + * mocked here. These tests check that this package's own layer (config + * passthrough, middleware, sign-in URL server function) needs no API key and + * never asks for one. The real keyless flow is exercised against the real + * authkit-session in the example app. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { session, state } = vi.hoisted(() => { + const state: { config: Record; ctx: unknown } = { config: {}, ctx: undefined }; + const service = { + withAuth: vi.fn(), + saveSession: vi.fn(), + createSignIn: vi.fn(), + clearPendingVerifierByName: vi.fn(), + }; + return { + state, + session: { + service, + createAuthService: vi.fn(() => service), + validateConfig: vi.fn(), + getConfig: vi.fn((key: string) => state.config[key]), + }, + }; +}); + +vi.mock('@workos/authkit-session', async (importOriginal) => ({ + ...(await importOriginal()), + createAuthService: session.createAuthService, + validateConfig: session.validateConfig, + getConfig: session.getConfig, +})); + +vi.mock('@tanstack/react-start', () => ({ + getGlobalStartContext: () => state.ctx, + createServerFn: () => { + const passthrough = (handler: Function) => (opts?: { data?: unknown }) => handler(opts ?? {}); + return { validator: () => ({ handler: passthrough }), handler: passthrough }; + }, +})); + +const API_KEY = 'sk_test_confidential'; +const baseConfig = { + clientId: 'client_123', + redirectUri: 'http://localhost:3000/api/auth/callback', + cookiePassword: 'a'.repeat(32), +}; +const AUTHORIZATION_URL = + 'https://api.workos.com/user_management/authorize?client_id=client_123&code_challenge=abc&code_challenge_method=S256'; +const VERIFIER_COOKIE = 'wos-auth-verifier-1=sealed; Path=/; HttpOnly; SameSite=Lax; Max-Age=600'; + +describe.each([ + ['public client (no API key)', undefined], + ['confidential client (API key)', API_KEY], +] as const)('%s', (_label, apiKey) => { + beforeEach(() => { + vi.resetModules(); // middleware-body caches its one-time config validation + vi.clearAllMocks(); + state.ctx = undefined; + state.config = apiKey ? { ...baseConfig, apiKey } : { ...baseConfig }; + session.validateConfig.mockReturnValue(undefined); + session.service.withAuth.mockResolvedValue({ auth: { user: null }, refreshedSessionData: undefined }); + session.service.createSignIn.mockResolvedValue({ + url: AUTHORIZATION_URL, + cookieName: 'wos-auth-verifier-1', + headers: { 'Set-Cookie': VERIFIER_COOKIE }, + }); + session.service.saveSession.mockResolvedValue({ + response: new Response(null, { headers: { 'Set-Cookie': 'wos-session=refreshed; Path=/' } }), + }); + }); + + describe('config', () => { + it('validates through authkit-session without adding a key check', async () => { + const { validateConfig, getConfig } = await import('./authkit-loader'); + + await expect(validateConfig()).resolves.toBeUndefined(); + expect(session.validateConfig).toHaveBeenCalledTimes(1); + await expect(getConfig('apiKey')).resolves.toBe(apiKey); + }); + + it("propagates authkit-session's validation error unchanged", async () => { + const error = new Error('AuthKit configuration error. Missing or invalid environment variables: ...'); + session.validateConfig.mockImplementation(() => { + throw error; + }); + const { validateConfig } = await import('./authkit-loader'); + + await expect(validateConfig()).rejects.toBe(error); + }); + }); + + describe('middleware and sign-in', () => { + async function runMiddleware(next: () => Promise) { + const { middlewareBody } = await import('./middleware-body'); + return middlewareBody({ + request: new Request('http://localhost:3000/'), + next: async ({ context }: { context: unknown }) => { + state.ctx = context; // what TanStack exposes to server functions + return { response: await next() }; + }, + }); + } + + it('starts a PKCE sign-in and forwards the verifier cookie', async () => { + const { getSignInUrl } = await import('./server-functions'); + + const result = await runMiddleware(async () => new Response(await getSignInUrl({ data: '/dashboard' }))); + + expect(session.validateConfig).toHaveBeenCalledTimes(1); + expect(session.service.createSignIn).toHaveBeenCalledWith(undefined, { + returnPathname: '/dashboard', + redirectUri: baseConfig.redirectUri, + }); + expect(await result.response.text()).toBe(AUTHORIZATION_URL); + expect(result.response.headers.get('Set-Cookie')).toBe(VERIFIER_COOKIE); + expect(session.getConfig).not.toHaveBeenCalledWith('apiKey'); + }); + + it('saves a session refreshed by withAuth', async () => { + session.service.withAuth.mockResolvedValue({ + auth: { user: { id: 'user_123' } }, + refreshedSessionData: 'sealed-refreshed-session', + }); + + const result = await runMiddleware(async () => new Response('ok')); + + expect(session.service.saveSession).toHaveBeenCalledWith(undefined, 'sealed-refreshed-session'); + expect(result.response.headers.get('Set-Cookie')).toBe('wos-session=refreshed; Path=/'); + expect(session.getConfig).not.toHaveBeenCalledWith('apiKey'); + }); + }); +}); From 771f0657b9bf813ef70b1e48e137840f0d7c5de0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:31:24 -0700 Subject: [PATCH 5/8] docs: document public client (keyless) mode --- CLAUDE.md | 2 +- README.md | 32 +++++++++++++++++++++++++++++--- example/.env.example | 4 +++- example/README.md | 2 +- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c87bd5b..1c5445e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ Session encryption: iron-session (sealed cookies). JWT verification: jose (JWKS) ```env WORKOS_CLIENT_ID= -WORKOS_API_KEY= +WORKOS_API_KEY= # optional: omit for keyless PKCE public-client mode WORKOS_REDIRECT_URI=http://localhost:3000/callback WORKOS_COOKIE_PASSWORD= ``` diff --git a/README.md b/README.md index f73395e..442d807 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,17 @@ pnpm add @workos/authkit-tanstack-react-start ### Environment Variables -Create a `.env` file in your project root with the following required variables: +Create a `.env` file in your project root with the following variables: ```bash WORKOS_CLIENT_ID="client_..." # Get from WorkOS dashboard -WORKOS_API_KEY="sk_test_..." # Get from WorkOS dashboard WORKOS_REDIRECT_URI="http://localhost:3000/api/auth/callback" WORKOS_COOKIE_PASSWORD="..." # Min 32 characters +WORKOS_API_KEY="sk_test_..." # Optional: get from WorkOS dashboard; needed for WorkOS management APIs ``` +Without `WORKOS_API_KEY`, AuthKit runs as a PKCE public client. See [Public client (keyless) mode](#public-client-keyless-mode). + Generate a secure cookie password (32+ characters): ```bash @@ -44,6 +46,30 @@ openssl rand -base64 24 | `WORKOS_COOKIE_SAMESITE` | `lax` | SameSite attribute (`lax`, `strict`, `none`) | | `WORKOS_API_HOSTNAME` | `api.workos.com` | WorkOS API hostname | +### Public client (keyless) mode + +> [!IMPORTANT] +> Keyless mode needs `@workos/authkit-session` 0.8.0 or later (expected to be the first release with [workos/authkit-session#59](https://github.com/workos/authkit-session/pull/59)). With earlier versions, startup validation still reports `WORKOS_API_KEY is required`. + +If your app only signs users in and shouldn't hold a WorkOS secret key, leave `WORKOS_API_KEY` unset. AuthKit then runs as an OAuth public client and needs only three variables: + +```bash +WORKOS_CLIENT_ID="client_..." +WORKOS_REDIRECT_URI="http://localhost:3000/api/auth/callback" +WORKOS_COOKIE_PASSWORD="..." +``` + +Every sign-in already uses PKCE: the code verifier stays in an HttpOnly cookie on the browser that started the flow, so only that browser can exchange the authorization code, without a client secret. Session refresh sends the refresh token with your client ID and no secret. + +What works without a key: `authkitMiddleware` (including automatic session refresh), `getSignInUrl` / `getSignUpUrl` / `getAuthorizationUrl`, `handleCallbackRoute`, `getAuth`, `switchToOrganization`, `signOut`, and the client hooks (`useAuth`, `useAccessToken`, `useTokenClaims`). + +What needs a key: + +- `getOrganizationAction`. Without a key it rejects with `getOrganizationAction requires a WorkOS API key; set WORKOS_API_KEY. Public-client (keyless) mode supports sign-in only.` before making any request. The `Impersonation` component uses it to show the organization name; without a key the banner still renders, without the organization name, and logs the error to the browser console. +- Direct WorkOS management calls through `getAuthkit()` → `getWorkOS()`, such as `organizations.*` or `userManagement.getUser`. The WorkOS SDK rejects them with an `ApiKeyRequiredException`. + +The WorkOS Node SDK also reads `process.env.WORKOS_API_KEY` itself, so a key in the server's environment always wins: to run keyless, make sure the variable is absent from every env source your server loads. + ### Setup (3 Steps) #### 1. Configure Middleware @@ -780,7 +806,7 @@ If you don't need client hooks, use `getAuth()` in loaders instead. The middleware validates configuration on first request. If you see errors about missing variables: 1. Check your `.env` file exists -2. Verify all required variables are set +2. Verify all required variables are set (`WORKOS_CLIENT_ID`, `WORKOS_REDIRECT_URI`, `WORKOS_COOKIE_PASSWORD`; `WORKOS_API_KEY` is optional, see [Public client (keyless) mode](#public-client-keyless-mode)) 3. Ensure `WORKOS_COOKIE_PASSWORD` is 32+ characters 4. Restart your dev server after changing env vars diff --git a/example/.env.example b/example/.env.example index b1e1e82..8db92e1 100644 --- a/example/.env.example +++ b/example/.env.example @@ -1,5 +1,7 @@ # The following environment variables are required to run the application WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback -WORKOS_API_KEY='' WORKOS_CLIENT_ID='' WORKOS_COOKIE_PASSWORD='' + +# Optional: omit to run as a PKCE public client (sign-in only, no management APIs) +WORKOS_API_KEY='' diff --git a/example/README.md b/example/README.md index 705b60e..54dc241 100644 --- a/example/README.md +++ b/example/README.md @@ -25,9 +25,9 @@ You will need a [WorkOS account](https://dashboard.workos.com/signup). ```bash WORKOS_CLIENT_ID= - WORKOS_API_KEY= WORKOS_COOKIE_PASSWORD= WORKOS_REDIRECT_URI=http://localhost:3000/callback + WORKOS_API_KEY= # optional: omit for public client (keyless) mode ``` `WORKOS_COOKIE_PASSWORD` is the private key used to encrypt the session cookie. It has to be at least 32 characters long. You can use the [1Password generator](https://1password.com/password-generator/) or the `openssl` library to generate a strong password via the command line: From 409c3f3fe49bb36303a54a45b5af90ae3e34c924 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 7 Oct 2026 10:41:40 -0700 Subject: [PATCH 6/8] fix: ignore stale organization lookups in the impersonation banner A lookup for a previous organization that settles after the organization changed no longer overwrites the current name. --- src/client/components/impersonation.tsx | 22 +++++++--- src/client/impersonation.spec.tsx | 57 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/client/components/impersonation.tsx b/src/client/components/impersonation.tsx index 23a6f3b..a248715 100644 --- a/src/client/components/impersonation.tsx +++ b/src/client/components/impersonation.tsx @@ -18,12 +18,22 @@ export function Impersonation({ side = 'bottom', returnTo, ...props }: Impersona useEffect(() => { if (!organizationId || !impersonator || !user) return; if (organization && organization.id === organizationId) return; - getOrganizationAction({ data: organizationId }).then(setOrganization, (error) => { - // Show no organization name, like an unknown organization. Log rather than swallow: in - // public-client (keyless) mode this is the API-key error, otherwise a real failure. - setOrganization(null); - console.error('[authkit-tanstack-react-start] Failed to load the impersonated organization:', error); - }); + // Ignore a lookup that settles after this effect is cleaned up (e.g. the organization changed). + let stale = false; + getOrganizationAction({ data: organizationId }).then( + (org) => { + if (!stale) setOrganization(org); + }, + (error) => { + // Show no organization name, like an unknown organization. Log rather than swallow: in + // public-client (keyless) mode this is the API-key error, otherwise a real failure. + if (!stale) setOrganization(null); + console.error('[authkit-tanstack-react-start] Failed to load the impersonated organization:', error); + }, + ); + return () => { + stale = true; + }; }, [organizationId, impersonator, user]); if (!impersonator || !user) return null; diff --git a/src/client/impersonation.spec.tsx b/src/client/impersonation.spec.tsx index 773e31d..937f6ca 100644 --- a/src/client/impersonation.spec.tsx +++ b/src/client/impersonation.spec.tsx @@ -101,6 +101,63 @@ describe('Impersonation', () => { } }); + describe('when the organization changes while a lookup is pending', () => { + function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + } + + async function switchOrganizations() { + const first = deferred<{ id: string; name: string } | null>(); + vi.mocked(getOrganizationAction) + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ id: 'org_456', name: 'New Org' }); + + mockAuth({ organizationId: 'org_123' }); + const { rerender } = await act(async () => render()); + await act(async () => { + mockAuth({ organizationId: 'org_456' }); + rerender(); + }); + expect(await screen.findByText('New Org')).toBeInTheDocument(); + return first; + } + + it('keeps the current name when the old lookup rejects later', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const first = await switchOrganizations(); + + await act(async () => { + first.reject(new Error('old lookup failed')); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(screen.getByText('New Org')).toBeInTheDocument(); + expect(getOrganizationAction).toHaveBeenCalledTimes(2); + } finally { + consoleError.mockRestore(); + } + }); + + it('keeps the current name when the old lookup resolves later', async () => { + const first = await switchOrganizations(); + + await act(async () => { + first.resolve({ id: 'org_123', name: 'Old Org' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(screen.getByText('New Org')).toBeInTheDocument(); + expect(screen.queryByText('Old Org')).not.toBeInTheDocument(); + }); + }); + it('shows the organization name when the action resolves', async () => { mockAuth({ organizationId: 'org_123' }); vi.mocked(getOrganizationAction).mockResolvedValue({ id: 'org_123', name: 'Test Org' }); From e86338b00454f384a1edac33b700e488012977e9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 19 Oct 2026 12:22:09 -0500 Subject: [PATCH 7/8] docs: drop the expected-release note from the keyless README section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 442d807..4804e64 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ openssl rand -base64 24 ### Public client (keyless) mode > [!IMPORTANT] -> Keyless mode needs `@workos/authkit-session` 0.8.0 or later (expected to be the first release with [workos/authkit-session#59](https://github.com/workos/authkit-session/pull/59)). With earlier versions, startup validation still reports `WORKOS_API_KEY is required`. +> Keyless mode needs `@workos/authkit-session` 0.8.0 or later. With earlier versions, startup validation still reports `WORKOS_API_KEY is required`. If your app only signs users in and shouldn't hold a WorkOS secret key, leave `WORKOS_API_KEY` unset. AuthKit then runs as an OAuth public client and needs only three variables: From 0b39e91f30a17afc00c3e77c0eb968681bce3fce Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 20 Oct 2026 11:25:19 -0500 Subject: [PATCH 8/8] fix(deps): require @workos/authkit-session ^0.8.0 for keyless PKCE --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 78af7c8..0cd52ec 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://github.com/workos/authkit-tanstack-start/issues" }, "dependencies": { - "@workos/authkit-session": "^0.7.0" + "@workos/authkit-session": "^0.8.0" }, "devDependencies": { "@tanstack/react-router": "^1.170.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15686e4..df6c2cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@workos/authkit-session': - specifier: ^0.7.0 - version: 0.7.0(@workos-inc/node@10.7.0)(typescript@5.9.3) + specifier: ^0.8.0 + version: 0.8.0(@workos-inc/node@10.7.0)(typescript@5.9.3) devDependencies: '@tanstack/react-router': specifier: ^1.170.15 @@ -2185,8 +2185,8 @@ packages: resolution: {integrity: sha512-rffa9znZuIv4yo+9JRxGOLTTSxh0XhOT6jlsktgqEi9MuB9V0VFHRzLd3bTpkq+J9sgrPZNGpvX4aWNfMqt5cQ==} engines: {node: '>=22.11.0'} - '@workos/authkit-session@0.7.0': - resolution: {integrity: sha512-8RvHzszR3Z6k8w+5m47G3B4ultZljRw9pLyCyEPtAg6+7gBdDEIt/0f9TNBOZdvlIz4YHIechUzYujdmR20ngA==} + '@workos/authkit-session@0.8.0': + resolution: {integrity: sha512-max44mLZeR3pgVnILaix2eCVxE9jAnvlxl5LOwHFNdIdOliJxHNVjSQhHpYf6dM/ymPvf2HDB+9zjcAkPwcdIw==} engines: {node: '>=20.0.0'} peerDependencies: '@workos-inc/node': ^8.0.0 || ^9.0.0 || ^10.0.0 @@ -5051,7 +5051,7 @@ snapshots: '@workos-inc/node@10.7.0': {} - '@workos/authkit-session@0.7.0(@workos-inc/node@10.7.0)(typescript@5.9.3)': + '@workos/authkit-session@0.8.0(@workos-inc/node@10.7.0)(typescript@5.9.3)': dependencies: '@workos-inc/node': 10.7.0 iron-webcrypto: 2.0.0