From 730c14b1927cb09ec646adccd282584d5ceb38bf Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 30 Apr 2026 09:05:58 -0500 Subject: [PATCH 1/3] fix: eliminate server-side bundle leak via lazy handler bodies (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slim `actions.ts` and `server-functions.ts` so each `createServerFn(...).handler(...)` body is a thin shell that dynamic-imports its real logic from a sibling `*-bodies.ts` file. The TanStack Start compiler rewrites the slimmed handlers to `createClientRpc(id)` stubs at consumer build time, and the dynamic-import expression is dropped from the client graph — no static path reaches `@workos/authkit-session` or `@workos-inc/node`. Also: - Convert three client-side value imports of types to `import type` - Add oxlint `no-restricted-imports` override with `allowTypeImports: true` - Add `scripts/check-bundle-leak.sh` + `build:check` script + CI step - Wire `build:check` into `prepublishOnly` chain - Add CLAUDE.md documenting the lazy-bodies pattern and load-bearing assumptions Resolves #72. --- .github/workflows/ci.yml | 5 + .oxlintrc.json | 26 ++- CLAUDE.md | 214 ++++++++++++++++++++++++ package.json | 3 +- scripts/check-bundle-leak.sh | 41 +++++ src/client/AuthKitProvider.tsx | 3 +- src/client/components/impersonation.tsx | 3 +- src/client/types.ts | 2 +- src/server/action-bodies.ts | 95 +++++++++++ src/server/actions.ts | 99 +++-------- src/server/server-fn-bodies.ts | 175 +++++++++++++++++++ src/server/server-functions.ts | 162 ++++-------------- 12 files changed, 616 insertions(+), 212 deletions(-) create mode 100644 CLAUDE.md create mode 100755 scripts/check-bundle-leak.sh create mode 100644 src/server/action-bodies.ts create mode 100644 src/server/server-fn-bodies.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4efa7b6..a97f5be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,8 @@ jobs: - name: Test run: | pnpm run test:coverage -- --run + + - name: Example Build (client bundle leak check) + run: | + cd example && pnpm build && cd .. + pnpm run build:check diff --git a/.oxlintrc.json b/.oxlintrc.json index d859a01..6e0fe76 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -3,5 +3,29 @@ "plugins": null, "categories": {}, "rules": {}, - "ignorePatterns": ["dist/", "example/src/routeTree.gen.ts"] + "ignorePatterns": ["dist/", "example/src/routeTree.gen.ts"], + "overrides": [ + { + "files": ["src/server/actions.ts", "src/server/server-functions.ts"], + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["./auth-helpers*", "./authkit-loader*", "./context*", "./headers-bag*", "./action-bodies*", "./server-fn-bodies*"], + "allowTypeImports": true, + "message": "Static value imports of server-only modules are forbidden in this file. Move logic into action-bodies.ts or server-fn-bodies.ts and use a dynamic import inside the handler. See CLAUDE.md." + }, + { + "group": ["@workos/authkit-session", "@workos-inc/node"], + "allowTypeImports": true, + "message": "Static value imports of these packages are forbidden in this file. Use a dynamic import inside the handler body." + } + ] + } + ] + } + } + ] } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c87bd5b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,214 @@ +# CLAUDE.md + +Guidance for Claude Code working in this repository. + +## CRITICAL: File Organization and Import Boundaries + +**DO NOT reorganize server-side code that works.** File structure matters for bundling. + +### Lessons Learned + +**Broken commits:** + +- `5bd4367` — Reorganized `src/server/` → `src/auth/`, `src/core/`, `src/middleware/` +- `8058974` — Added `'use server'` directives + +**Why it broke:** + +1. Moving files changed import paths. +2. TanStack Start's bundler includes all imported modules at evaluation time. +3. When `authkit` was imported (even transitively), it immediately evaluated `@workos/authkit-session` and `iron-session`. +4. Server deps leaked into the client bundle → runtime errors. + +**Why the original `src/server/` structure works:** + +- Clear boundaries for the bundler to tree-shake on. +- The directory convention signals server-only intent to Vite. + +**About `'use server'`:** + +- NOT documented in TanStack Start. +- But works in practice when files are in `src/server/` (Vite bundler picks it up). +- Removing it causes crypto / iron-session to leak into client bundles. + +**Lesson:** leave `src/server/` alone. + +## CRITICAL: Lazy handler bodies (`actions.ts`, `server-functions.ts`) + +`src/server/actions.ts` and `src/server/server-functions.ts` are reachable from `src/client/**` via type-only / RPC edges. Static value imports of server-only modules from these two files re-open the issue [#72](https://github.com/workos/authkit-tanstack-start/issues/72) class of leaks (e.g. `eventemitter3` SyntaxError in Vite dev once `@workos-inc/node` ships an awkward CJS dep). + +**The pattern.** Each `createServerFn(...).handler(...)` body MUST be a thin shell that dynamically imports its real logic from the sibling bodies file: + +```ts +// src/server/actions.ts +export const getAuthAction = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const { getAuthBody } = await import('./action-bodies.js'); + return getAuthBody(); + }, +); +``` + +The actual logic lives in `src/server/action-bodies.ts` / `src/server/server-fn-bodies.ts`, which CAN statically import server-only modules. + +**Why it works.** TanStack Start's compiler (`/\.[cm]?[tj]sx?($|\?)/`, no `node_modules` exclusion) transforms our installed `dist/server/*.js` in the consumer's client bundle, replacing each `.handler(fn)` with `createClientRpc(id)`. The shell's dynamic import then has no value reference in the client graph and is dead-code-eliminated. The bodies file is never reached from the client. + +**Load-bearing assumption.** The TanStack compiler must keep transforming our installed dist. If a future Vite or TanStack release excludes `node_modules` from the transform pipeline, this approach degrades silently — the leak fires the first time client middleware invokes a handler. Re-verify by inspecting the served module after upgrades: + +```bash +cd example && pnpm dev +# In another terminal: +curl -s 'http://localhost:3000/@fs//dist/server/actions.js' | grep -c 'createClientRpc' +# Expect non-zero. If zero, the compiler is no longer transforming the SDK's dist — +# stop and pivot to HTTP-RPC. +``` + +**Regression guard.** `.oxlintrc.json` configures `no-restricted-imports` (with `allowTypeImports: true`) on these two files, blocking static value imports of `./auth-helpers*`, `./authkit-loader*`, `./context*`, `./headers-bag*`, `./action-bodies*`, `./server-fn-bodies*`, `@workos/authkit-session`, and `@workos-inc/node`. Do NOT bypass the rule. If you need a new server module, add it to the bodies file and dynamic-import from the shell. + +**Bundle check.** `pnpm run build:check` runs `scripts/check-bundle-leak.sh` against the example's built client bundle, looking for fingerprints (`@workos-inc/node`, `iron-session`, `iron-webcrypto`, `FeatureFlagsRuntimeClient`, `The listener must be a function`, `ERR_JWT_CLAIM_VALIDATION_FAILED`). Run after any change touching the `actions.ts` / `server-functions.ts` boundary or after upgrading `@workos/authkit-session`. + +## CRITICAL: Server Function Execution Context + +`createServerFn` creates automatic RPC boundaries — no directive needed. + +Server functions can ONLY be called from server contexts: + +| Context | Runs On | Can call server functions? | +| ----------------------- | ------------------------- | --------------------------- | +| `loader` | Server (SSR), then cached | Yes | +| `beforeLoad` | Server AND client | No | +| Server function handler | Server only | Yes | +| Component render | Server AND client | No (use `useServerFn` hook) | +| Route server handlers | Server only | Yes | + +### Correct: call from loader + +```typescript +export const Route = createRootRoute({ + loader: async () => { + const { user } = await getAuth(); + const url = await getSignInUrl({}); + return { user, url }; + }, +}); +``` + +### Wrong: call from beforeLoad + +```typescript +export const Route = createRootRoute({ + beforeLoad: async () => { + // beforeLoad runs on BOTH server and client (during hydration). + const { user } = await getAuth(); // Throws: "can only be called on the server" + }, +}); +``` + +## Project Overview + +First-class SDK for WorkOS AuthKit + TanStack Start. Cookie-based session management using standard Web API Request/Response. + +- **Reference example:** https://github.com/tanstack/router/tree/main/examples/react/start-workos +- **Built on:** `@workos/authkit-session` (sibling workspace at `../authkit-session`) +- **Reference SDK:** `@workos-inc/authkit-nextjs` (sibling workspace at `../authkit-nextjs`) + +## Development Commands + +```bash +pnpm install +pnpm dev # port 3000 +pnpm build # includes typecheck +pnpm start # production +``` + +## Architecture + +Key files in `src/server/`: + +- `storage.ts` — `ImperativeSessionStorage` adapter wrapping `@workos/authkit-session` +- `authkit-loader.ts` — creates the `AuthService` instance +- `server-functions.ts` — `createServerFn`-wrapped functions (safe cross-boundary: compiler rewrites to RPC on client) +- `actions.ts` — `createServerFn`-wrapped actions used by provider +- `middleware.ts` — TanStack middleware for auth +- `auth-helpers.ts` — server context helpers +- `context.ts` — `getGlobalStartContext` wrapper + +Client-side: + +- `src/client/AuthKitProvider.tsx`, `src/client/tokenStore.ts` — import from `server/actions.ts` (safe; rewritten to RPC) + +Example app callback handler: `example/src/routes/api/auth/callback.tsx`. + +Session encryption: iron-session (sealed cookies). JWT verification: jose (JWKS). + +### Key Dependencies + +- `@workos-inc/node` — WorkOS SDK +- `@workos/authkit-session` — framework-agnostic session primitives +- `@tanstack/react-start` — full-stack framework +- `iron-session` — sealed cookie encryption +- `jose` — JWT + JWKS + +## Environment Configuration + +```env +WORKOS_CLIENT_ID= +WORKOS_API_KEY= +WORKOS_REDIRECT_URI=http://localhost:3000/callback +WORKOS_COOKIE_PASSWORD= +``` + +## TypeScript Configuration + +- Module resolution: Bundler +- Target: ES2022 +- `strictNullChecks` + `noUncheckedIndexedAccess` enabled +- JSX: react-jsx + +## Documentation Resources + +- TanStack Start: https://tanstack.com/start/latest +- Context7: `/tanstack/start` or `/tanstack/router` +- TanStack Start is still in beta — APIs change often. Check latest docs on type errors. + +## Do / Don't + +**Do:** + +- Keep server-only code in `src/server/` — the directory convention matters +- Call server functions from route `loader`, not `beforeLoad` +- Follow patterns in `src/server/server-functions.ts` for new server functions +- Check `src/server/storage.ts` for the storage adapter pattern +- Verify client bundle after changes to `@workos/authkit-session` — run `cd example && pnpm build` and watch for `node:crypto` / other Node-only externalization warnings + +**Don't:** + +- Reorganize `src/server/` — see "Lessons Learned" (commits `5bd4367`, `8058974`) +- Add `'use server'` directives outside `src/server/` — undocumented in TanStack Start +- Call server functions from `beforeLoad` — runs on both server and client + +## PR Checklist + +- [ ] `pnpm build` passes (includes typecheck) +- [ ] `cd example && pnpm build` passes — no Node-only module warnings in the client chunk +- [ ] Server functions called from `loader`, not `beforeLoad` + +## Not Currently Enforced + +Older versions of this doc described TanStack Start's **Import Protection** plugin with `server-only` markers and an `example/vite.config.ts` deny list. None of that is currently active: + +- No `import '@tanstack/react-start/server-only'` markers exist in `src/` or in `@workos/authkit-session/dist/`. +- `example/vite.config.ts` uses `tanstackStart()` with default options — no `importProtection` config. + +The only defense today is: + +1. The `src/server/` directory convention (relies on developer discipline). +2. Rollup's browser-externalization errors at build time (catches Node-only imports, but only after they reach the client graph — and only for modules with specific externalization errors like `node:crypto`'s `timingSafeEqual`). + +If you want real build-time enforcement, options are: + +1. Add `server-only` markers to pure-server files in `src/server/` and consume them via explicit subpath imports. +2. Add `specifiers: ['@workos/authkit-session', 'iron-session']` to `importProtection.client` in `example/vite.config.ts`. +3. Split the SDK barrel so server-only exports come from a subpath (e.g. `@workos/authkit-session/server`) rather than the main entry. + +None of these are blocking; they're hardening work. diff --git a/package.json b/package.json index c8fa9ea..80ed987 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "dev": "tsc --watch", "prebuild": "npm run clean", "build": "tsc -p tsconfig.build.json", - "prepublishOnly": "npm run build && npm run format:check && npm run lint && npm run typecheck && npm test", + "build:check": "scripts/check-bundle-leak.sh", + "prepublishOnly": "npm run build && npm run format:check && npm run lint && npm run typecheck && npm test && (cd example && pnpm build) && npm run build:check", "typecheck": "tsc --noEmit", "lint": "oxlint", "format": "oxfmt .", diff --git a/scripts/check-bundle-leak.sh b/scripts/check-bundle-leak.sh new file mode 100755 index 0000000..0d31dce --- /dev/null +++ b/scripts/check-bundle-leak.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# scripts/check-bundle-leak.sh +# Multi-signal grep for server-only fingerprints in the example app's client bundle. +# Fails the build if any fingerprint is found — the SDK leaked server code into the client. +set -euo pipefail + +CLIENT_DIR="example/dist/client" +if [ ! -d "$CLIENT_DIR" ]; then + echo "ERROR: $CLIENT_DIR not found. Run 'cd example && pnpm build' first." >&2 + exit 1 +fi + +# Concatenate all client JS for grepping (handles minified single-line files) +ALL_JS=$(find "$CLIENT_DIR" -name "*.js" -type f -print0 | xargs -0 cat) + +FINGERPRINTS=( + # Package names + "@workos-inc/node" + "iron-session" + "iron-webcrypto" + # Code fingerprints (more robust against minification) + "FeatureFlagsRuntimeClient" + "The listener must be a function" + "ERR_JWT_CLAIM_VALIDATION_FAILED" +) + +FAIL=0 +for fp in "${FINGERPRINTS[@]}"; do + if echo "$ALL_JS" | grep -q -F "$fp"; then + echo "LEAK: '$fp' found in $CLIENT_DIR" >&2 + FAIL=1 + fi +done + +if [ $FAIL -eq 1 ]; then + echo "" >&2 + echo "Server-only code detected in client bundle. See CLAUDE.md 'Lazy handler bodies' section." >&2 + exit 1 +fi + +echo "OK: no server-side fingerprints in $CLIENT_DIR" diff --git a/src/client/AuthKitProvider.tsx b/src/client/AuthKitProvider.tsx index a992337..4119c77 100644 --- a/src/client/AuthKitProvider.tsx +++ b/src/client/AuthKitProvider.tsx @@ -1,7 +1,8 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { checkSessionAction, getAuthAction, refreshAuthAction, switchToOrganizationAction } from '../server/actions.js'; -import { ClientUserInfo, NoUserInfo, getSignOutUrl } from '../server/server-functions.js'; +import { getSignOutUrl } from '../server/server-functions.js'; +import type { ClientUserInfo, NoUserInfo } from '../server/server-functions.js'; import type { AuthContextType, AuthKitProviderProps } from './types.js'; import type { User, Impersonator } from '../types.js'; diff --git a/src/client/components/impersonation.tsx b/src/client/components/impersonation.tsx index b08ad8d..5c9de92 100644 --- a/src/client/components/impersonation.tsx +++ b/src/client/components/impersonation.tsx @@ -1,7 +1,8 @@ import { useEffect, useState, type ComponentPropsWithoutRef } from 'react'; import { Button } from './button.js'; import { MinMaxButton } from './min-max-button.js'; -import { getOrganizationAction, type OrganizationInfo } from '../../server/actions.js'; +import { getOrganizationAction } from '../../server/actions.js'; +import type { OrganizationInfo } from '../../server/actions.js'; import { useAuth } from '../AuthKitProvider.js'; interface ImpersonationProps extends ComponentPropsWithoutRef<'div'> { diff --git a/src/client/types.ts b/src/client/types.ts index 2d5dc52..70f4bc5 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -1,4 +1,4 @@ -import { ClientUserInfo, NoUserInfo } from '../server/server-functions.js'; +import type { ClientUserInfo, NoUserInfo } from '../server/server-functions.js'; import type { User, Impersonator } from '../types.js'; export interface AuthContextType { diff --git a/src/server/action-bodies.ts b/src/server/action-bodies.ts new file mode 100644 index 0000000..48ad59f --- /dev/null +++ b/src/server/action-bodies.ts @@ -0,0 +1,95 @@ +import { getRawAuthFromContext, isAuthConfigured, refreshSession } from './auth-helpers.js'; +import type { ClientUserInfo, NoUserInfo, UserInfo } from './server-functions.js'; + +export interface OrganizationInfo { + id: string; + name: string; +} + +function sanitizeAuthForClient(auth: any): Omit | NoUserInfo { + if (!auth.user) { + return { user: null }; + } + + return { + user: auth.user, + sessionId: auth.sessionId, + organizationId: auth.claims?.org_id, + role: auth.claims?.role, + roles: auth.claims?.roles, + permissions: auth.claims?.permissions, + entitlements: auth.claims?.entitlements, + featureFlags: auth.claims?.feature_flags, + impersonator: auth.impersonator, + }; +} + +export function checkSessionBody(): boolean { + if (!isAuthConfigured()) { + return false; + } + + try { + const auth = getRawAuthFromContext(); + return auth.user !== null; + } catch { + return false; + } +} + +export function getAuthBody(): ClientUserInfo | NoUserInfo { + return sanitizeAuthForClient(getRawAuthFromContext()); +} + +export async function refreshAuthBody(options?: { + organizationId?: string; +}): Promise | NoUserInfo> { + const result = await refreshSession(options?.organizationId); + + if (!result || !result.user) { + return { user: null }; + } + + return sanitizeAuthForClient(result); +} + +export function getAccessTokenBody(): string | undefined { + if (!isAuthConfigured()) { + return undefined; + } + + try { + const auth = getRawAuthFromContext(); + return auth.user ? auth.accessToken : undefined; + } catch { + return undefined; + } +} + +export async function refreshAccessTokenBody(): Promise { + const result = await refreshSession(); + return result?.user ? result.accessToken : undefined; +} + +export async function switchToOrganizationBody(data: { + organizationId: string; +}): Promise | NoUserInfo> { + const result = await refreshSession(data.organizationId); + + if (!result || !result.user) { + return { user: null }; + } + + return sanitizeAuthForClient(result); +} + +export async function getOrganizationBody(organizationId: string): Promise { + try { + const { getWorkOS } = await import('@workos/authkit-session'); + const workos = getWorkOS(); + const org = await workos.organizations.getOrganization(organizationId); + return { id: org.id, name: org.name }; + } catch { + return null; + } +} diff --git a/src/server/actions.ts b/src/server/actions.ts index 63e9c13..a42f412 100644 --- a/src/server/actions.ts +++ b/src/server/actions.ts @@ -1,49 +1,27 @@ import { createServerFn } from '@tanstack/react-start'; -import { getRawAuthFromContext, isAuthConfigured, refreshSession } from './auth-helpers.js'; import type { ClientUserInfo, NoUserInfo, UserInfo } from './server-functions.js'; +import type { OrganizationInfo } from './action-bodies.js'; -function sanitizeAuthForClient(auth: any): Omit | NoUserInfo { - if (!auth.user) { - return { user: null }; - } - - return { - user: auth.user, - sessionId: auth.sessionId, - organizationId: auth.claims?.org_id, - role: auth.claims?.role, - roles: auth.claims?.roles, - permissions: auth.claims?.permissions, - entitlements: auth.claims?.entitlements, - featureFlags: auth.claims?.feature_flags, - impersonator: auth.impersonator, - }; -} +export type { OrganizationInfo }; /** * Check if a session exists. Used by client to detect session expiration. */ -export const checkSessionAction = createServerFn({ method: 'GET' }).handler(() => { - if (!isAuthConfigured()) { - return false; - } - - try { - const auth = getRawAuthFromContext(); - return auth.user !== null; - } catch { - return false; - } +export const checkSessionAction = createServerFn({ method: 'GET' }).handler(async (): Promise => { + const { checkSessionBody } = await import('./action-bodies.js'); + return checkSessionBody(); }); /** * Get authentication context. Sanitized for client use (no access token). * Can be used to seed the AuthKitProvider with the initial authentication state. */ -export const getAuthAction = createServerFn({ method: 'GET' }).handler((): ClientUserInfo | NoUserInfo => { - const auth = getRawAuthFromContext(); - return sanitizeAuthForClient(auth); -}); +export const getAuthAction = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const { getAuthBody } = await import('./action-bodies.js'); + return getAuthBody(); + }, +); /** * Refresh authentication session. Sanitized for client use (no access token). @@ -51,38 +29,27 @@ export const getAuthAction = createServerFn({ method: 'GET' }).handler((): Clien export const refreshAuthAction = createServerFn({ method: 'POST' }) .inputValidator((options?: { organizationId?: string }) => options) .handler(async ({ data: options }): Promise | NoUserInfo> => { - const result = await refreshSession(options?.organizationId); - - if (!result || !result.user) { - return { user: null }; - } - - return sanitizeAuthForClient(result); + const { refreshAuthBody } = await import('./action-bodies.js'); + return refreshAuthBody(options); }); /** * Get access token for the current session. */ -export const getAccessTokenAction = createServerFn({ method: 'GET' }).handler((): string | undefined => { - if (!isAuthConfigured()) { - return undefined; - } - - try { - const auth = getRawAuthFromContext(); - return auth.user ? auth.accessToken : undefined; - } catch { - return undefined; - } -}); +export const getAccessTokenAction = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + const { getAccessTokenBody } = await import('./action-bodies.js'); + return getAccessTokenBody(); + }, +); /** * Refresh and get a new access token. */ export const refreshAccessTokenAction = createServerFn({ method: 'POST' }).handler( async (): Promise => { - const result = await refreshSession(); - return result?.user ? result.accessToken : undefined; + const { refreshAccessTokenBody } = await import('./action-bodies.js'); + return refreshAccessTokenBody(); }, ); @@ -92,32 +59,16 @@ export const refreshAccessTokenAction = createServerFn({ method: 'POST' }).handl export const switchToOrganizationAction = createServerFn({ method: 'POST' }) .inputValidator((data: { organizationId: string }) => data) .handler(async ({ data }): Promise | NoUserInfo> => { - const result = await refreshSession(data.organizationId); - - if (!result || !result.user) { - return { user: null }; - } - - return sanitizeAuthForClient(result); + const { switchToOrganizationBody } = await import('./action-bodies.js'); + return switchToOrganizationBody(data); }); -export interface OrganizationInfo { - id: string; - name: string; -} - /** * Fetch organization details by ID. */ export const getOrganizationAction = createServerFn({ method: 'GET' }) .inputValidator((organizationId: string) => organizationId) .handler(async ({ data: organizationId }): Promise => { - try { - const { getWorkOS } = await import('@workos/authkit-session'); - const workos = getWorkOS(); - const org = await workos.organizations.getOrganization(organizationId); - return { id: org.id, name: org.name }; - } catch { - return null; - } + const { getOrganizationBody } = await import('./action-bodies.js'); + return getOrganizationBody(organizationId); }); diff --git a/src/server/server-fn-bodies.ts b/src/server/server-fn-bodies.ts new file mode 100644 index 0000000..ab2243e --- /dev/null +++ b/src/server/server-fn-bodies.ts @@ -0,0 +1,175 @@ +import type { GetAuthorizationUrlOptions as GetAuthURLOptions, HeadersBag } from '@workos/authkit-session'; +import { getRawAuthFromContext, refreshSession, getRedirectUriFromContext } from './auth-helpers.js'; +import { getAuthkit } from './authkit-loader.js'; +import { getAuthKitContextOrNull } from './context.js'; +import { emitHeadersFrom, forEachHeaderBagEntry } from './headers-bag.js'; +import type { NoUserInfo, UserInfo } from './server-functions.js'; + +type AuthorizationResult = { + url: string; + response?: Response; + headers?: HeadersBag; +}; + +/** + * Forward every `Set-Cookie` (and any other header) emitted by the upstream + * authorization-URL call through middleware's pending-header channel so the + * PKCE verifier cookie lands on the outgoing response. Each `Set-Cookie` entry + * is appended as its own header — never comma-joined — so multi-cookie + * emissions survive as distinct HTTP headers. + */ +function forwardAuthorizationCookies(result: AuthorizationResult): string { + const ctx = getAuthKitContextOrNull(); + if (!ctx?.__setPendingHeader) { + throw new Error( + '[authkit-tanstack-react-start] PKCE cookie could not be set: middleware context unavailable. Ensure authkitMiddleware is registered in your request middleware stack.', + ); + } + + // Upstream contract guarantees one of `headers` or `response` is populated; + // if neither emits, fail loudly so a dropped PKCE verifier doesn't surface + // later as an opaque state-mismatch in the callback. + if (!emitHeadersFrom(result, ctx.__setPendingHeader)) { + throw new Error( + '[authkit-tanstack-react-start] authorization result had neither headers nor response; PKCE verifier cookie could not be forwarded. This indicates a version mismatch with @workos/authkit-session.', + ); + } + + return result.url; +} + +/** Inject middleware-configured redirectUri only when caller did not provide one. */ +function applyContextRedirectUri(options: T): T { + const contextRedirectUri = getRedirectUriFromContext(); + if (!contextRedirectUri || options?.redirectUri) return options; + return { ...options, redirectUri: contextRedirectUri } as T; +} + +/** Internal: project raw auth context into the public UserInfo shape. */ +function getAuthFromContext(): UserInfo | NoUserInfo { + const auth = getRawAuthFromContext(); + + if (!auth.user) { + return { user: null }; + } + + return { + user: auth.user, + sessionId: auth.sessionId!, + organizationId: auth.claims?.org_id, + role: auth.claims?.role, + roles: auth.claims?.roles, + permissions: auth.claims?.permissions, + entitlements: auth.claims?.entitlements, + featureFlags: auth.claims?.feature_flags, + impersonator: auth.impersonator, + accessToken: auth.accessToken!, + }; +} + +/** + * Plan returned by `signOutBody`. The shell turns this into a `redirect(...)` + * throw. Keeping `redirect` out of this file lets the shell own the only value + * import of `@tanstack/react-router` and keeps the body file's static graph + * confined to server-only modules (which the lazy-import boundary already + * isolates from the client). + */ +export type SignOutPlan = + | { kind: 'returnTo'; to: string } + | { kind: 'logoutUrl'; href: string; headers: Headers }; + +export async function getSignOutUrlBody(data?: { returnTo?: string }): Promise<{ url: string | null }> { + const auth = getAuthFromContext(); + + if (!auth.user || !auth.sessionId) { + return { url: null }; + } + + const authkit = await getAuthkit(); + const { logoutUrl } = await authkit.signOut(auth.sessionId, { returnTo: data?.returnTo }); + + return { url: logoutUrl }; +} + +export async function signOutBody(data?: { returnTo?: string }): Promise { + const auth = getAuthFromContext(); + + if (!auth.user || !auth.sessionId) { + return { kind: 'returnTo', to: data?.returnTo || '/' }; + } + + // Get authkit instance (lazy loaded) + const authkit = await getAuthkit(); + + // Get logout URL and session clear headers from storage + const { logoutUrl, headers: headersBag } = await authkit.signOut(auth.sessionId, { returnTo: data?.returnTo }); + + // Convert HeadersBag to Headers for TanStack compatibility + const headers = new Headers(); + if (headersBag) { + forEachHeaderBagEntry(headersBag, (key, value) => headers.append(key, value)); + } + + return { kind: 'logoutUrl', href: logoutUrl, headers }; +} + +export function getAuthBody(): UserInfo | NoUserInfo { + return getAuthFromContext(); +} + +export async function getAuthorizationUrlBody(options?: GetAuthURLOptions): Promise { + const authkit = await getAuthkit(); + return forwardAuthorizationCookies(await authkit.createAuthorization(undefined, applyContextRedirectUri(options ?? {}))); +} + +export async function getSignInUrlBody( + data?: string | Omit, +): Promise { + const options = typeof data === 'string' ? { returnPathname: data } : data; + const authkit = await getAuthkit(); + return forwardAuthorizationCookies(await authkit.createSignIn(undefined, applyContextRedirectUri(options ?? {}))); +} + +export async function getSignUpUrlBody( + data?: string | Omit, +): Promise { + const options = typeof data === 'string' ? { returnPathname: data } : data; + const authkit = await getAuthkit(); + return forwardAuthorizationCookies(await authkit.createSignUp(undefined, applyContextRedirectUri(options ?? {}))); +} + +export type SwitchToOrganizationPlan = + | { kind: 'redirect'; to: string } + | { kind: 'user'; user: UserInfo }; + +export async function switchToOrganizationBody( + data: { organizationId: string; returnTo?: string }, +): Promise { + const auth = getAuthFromContext(); + + if (!auth.user) { + return { kind: 'redirect', to: data.returnTo || '/' }; + } + + const result = await refreshSession(data.organizationId); + + if (!result?.user) { + return { kind: 'redirect', to: data.returnTo || '/' }; + } + + return { + kind: 'user', + user: { + user: result.user, + sessionId: result.sessionId, + organizationId: result.claims?.org_id, + role: result.claims?.role, + roles: result.claims?.roles, + permissions: result.claims?.permissions, + entitlements: result.claims?.entitlements, + featureFlags: result.claims?.feature_flags, + impersonator: result.impersonator, + accessToken: result.accessToken, + }, + }; +} diff --git a/src/server/server-functions.ts b/src/server/server-functions.ts index 6247a82..ea35050 100644 --- a/src/server/server-functions.ts +++ b/src/server/server-functions.ts @@ -1,53 +1,9 @@ import { redirect } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; import type { Impersonator, User } from '../types.js'; -import { getRawAuthFromContext, refreshSession, getRedirectUriFromContext } from './auth-helpers.js'; -import { getAuthkit } from './authkit-loader.js'; -import { getAuthKitContextOrNull } from './context.js'; // Type-only import - safe for bundling -import type { GetAuthorizationUrlOptions as GetAuthURLOptions, HeadersBag } from '@workos/authkit-session'; -import { emitHeadersFrom, forEachHeaderBagEntry } from './headers-bag.js'; - -type AuthorizationResult = { - url: string; - response?: Response; - headers?: HeadersBag; -}; - -/** - * Forward every `Set-Cookie` (and any other header) emitted by the upstream - * authorization-URL call through middleware's pending-header channel so the - * PKCE verifier cookie lands on the outgoing response. Each `Set-Cookie` entry - * is appended as its own header — never comma-joined — so multi-cookie - * emissions survive as distinct HTTP headers. - */ -function forwardAuthorizationCookies(result: AuthorizationResult): string { - const ctx = getAuthKitContextOrNull(); - if (!ctx?.__setPendingHeader) { - throw new Error( - '[authkit-tanstack-react-start] PKCE cookie could not be set: middleware context unavailable. Ensure authkitMiddleware is registered in your request middleware stack.', - ); - } - - // Upstream contract guarantees one of `headers` or `response` is populated; - // if neither emits, fail loudly so a dropped PKCE verifier doesn't surface - // later as an opaque state-mismatch in the callback. - if (!emitHeadersFrom(result, ctx.__setPendingHeader)) { - throw new Error( - '[authkit-tanstack-react-start] authorization result had neither headers nor response; PKCE verifier cookie could not be forwarded. This indicates a version mismatch with @workos/authkit-session.', - ); - } - - return result.url; -} - -/** Inject middleware-configured redirectUri only when caller did not provide one. */ -function applyContextRedirectUri(options: T): T { - const contextRedirectUri = getRedirectUriFromContext(); - if (!contextRedirectUri || options?.redirectUri) return options; - return { ...options, redirectUri: contextRedirectUri } as T; -} +import type { GetAuthorizationUrlOptions as GetAuthURLOptions } from '@workos/authkit-session'; // Type exports - re-export shared types from authkit-session export type { GetAuthURLOptions }; @@ -71,20 +27,15 @@ export interface NoUserInfo { user: null; } +/** Options for getSignInUrl/getSignUpUrl - all GetAuthURLOptions except screenHint */ +type SignInUrlOptions = Omit; + /** Internal: Returns logout URL for client-side sign out. */ export const getSignOutUrl = createServerFn({ method: 'POST' }) .inputValidator((options?: { returnTo?: string }) => options) .handler(async ({ data }): Promise<{ url: string | null }> => { - const auth = getAuthFromContext(); - - if (!auth.user || !auth.sessionId) { - return { url: null }; - } - - const authkit = await getAuthkit(); - const { logoutUrl } = await authkit.signOut(auth.sessionId, { returnTo: data?.returnTo }); - - return { url: logoutUrl }; + const { getSignOutUrlBody } = await import('./server-fn-bodies.js'); + return getSignOutUrlBody(data); }); /** @@ -107,60 +58,25 @@ export const getSignOutUrl = createServerFn({ method: 'POST' }) export const signOut = createServerFn({ method: 'POST' }) .inputValidator((options?: { returnTo?: string }) => options) .handler(async ({ data }) => { - const auth = getAuthFromContext(); + const { signOutBody } = await import('./server-fn-bodies.js'); + const plan = await signOutBody(data); - if (!auth.user || !auth.sessionId) { - // No session to terminate + if (plan.kind === 'returnTo') { throw redirect({ - to: data?.returnTo || '/', + to: plan.to, throw: true, reloadDocument: true, }); } - // Get authkit instance (lazy loaded) - const authkit = await getAuthkit(); - - // Get logout URL and session clear headers from storage - const { logoutUrl, headers: headersBag } = await authkit.signOut(auth.sessionId, { returnTo: data?.returnTo }); - - // Convert HeadersBag to Headers for TanStack compatibility - const headers = new Headers(); - if (headersBag) { - forEachHeaderBagEntry(headersBag, (key, value) => headers.append(key, value)); - } - - // Clear session and redirect to WorkOS logout throw redirect({ - href: logoutUrl, + href: plan.href, throw: true, reloadDocument: true, - headers, + headers: plan.headers, }); }); -/** Internal function to get auth from context (server-only). */ -export function getAuthFromContext(): UserInfo | NoUserInfo { - const auth = getRawAuthFromContext(); - - if (!auth.user) { - return { user: null }; - } - - return { - user: auth.user, - sessionId: auth.sessionId!, - organizationId: auth.claims?.org_id, - role: auth.claims?.role, - roles: auth.claims?.roles, - permissions: auth.claims?.permissions, - entitlements: auth.claims?.entitlements, - featureFlags: auth.claims?.feature_flags, - impersonator: auth.impersonator, - accessToken: auth.accessToken!, - }; -} - /** * Get authentication context from the current request. * Can be called from route loaders (works during client-side navigation via RPC). @@ -183,8 +99,9 @@ export function getAuthFromContext(): UserInfo | NoUserInfo { * }); * ``` */ -export const getAuth = createServerFn({ method: 'GET' }).handler((): UserInfo | NoUserInfo => { - return getAuthFromContext(); +export const getAuth = createServerFn({ method: 'GET' }).handler(async (): Promise => { + const { getAuthBody } = await import('./server-fn-bodies.js'); + return getAuthBody(); }); /** @@ -193,14 +110,11 @@ export const getAuth = createServerFn({ method: 'GET' }).handler((): UserInfo | */ export const getAuthorizationUrl = createServerFn({ method: 'GET' }) .inputValidator((options?: GetAuthURLOptions) => options) - .handler(async ({ data: options = {} }) => { - const authkit = await getAuthkit(); - return forwardAuthorizationCookies(await authkit.createAuthorization(undefined, applyContextRedirectUri(options))); + .handler(async ({ data: options }): Promise => { + const { getAuthorizationUrlBody } = await import('./server-fn-bodies.js'); + return getAuthorizationUrlBody(options); }); -/** Options for getSignInUrl/getSignUpUrl - all GetAuthURLOptions except screenHint */ -type SignInUrlOptions = Omit; - /** * Get the sign-in URL. * Convenience wrapper around getAuthorizationUrl with sign-in screen hint. @@ -219,10 +133,9 @@ type SignInUrlOptions = Omit; */ export const getSignInUrl = createServerFn({ method: 'GET' }) .inputValidator((data?: string | SignInUrlOptions) => data) - .handler(async ({ data }) => { - const options = typeof data === 'string' ? { returnPathname: data } : data; - const authkit = await getAuthkit(); - return forwardAuthorizationCookies(await authkit.createSignIn(undefined, applyContextRedirectUri(options ?? {}))); + .handler(async ({ data }): Promise => { + const { getSignInUrlBody } = await import('./server-fn-bodies.js'); + return getSignInUrlBody(data); }); /** @@ -243,10 +156,9 @@ export const getSignInUrl = createServerFn({ method: 'GET' }) */ export const getSignUpUrl = createServerFn({ method: 'GET' }) .inputValidator((data?: string | SignInUrlOptions) => data) - .handler(async ({ data }) => { - const options = typeof data === 'string' ? { returnPathname: data } : data; - const authkit = await getAuthkit(); - return forwardAuthorizationCookies(await authkit.createSignUp(undefined, applyContextRedirectUri(options ?? {}))); + .handler(async ({ data }): Promise => { + const { getSignUpUrlBody } = await import('./server-fn-bodies.js'); + return getSignUpUrlBody(data); }); /** @@ -263,28 +175,12 @@ export const getSignUpUrl = createServerFn({ method: 'GET' }) export const switchToOrganization = createServerFn({ method: 'POST' }) .inputValidator((data: { organizationId: string; returnTo?: string }) => data) .handler(async ({ data }): Promise => { - const auth = getAuthFromContext(); - - if (!auth.user) { - throw redirect({ to: data.returnTo || '/' }); - } - - const result = await refreshSession(data.organizationId); + const { switchToOrganizationBody } = await import('./server-fn-bodies.js'); + const plan = await switchToOrganizationBody(data); - if (!result?.user) { - throw redirect({ to: data.returnTo || '/' }); + if (plan.kind === 'redirect') { + throw redirect({ to: plan.to }); } - return { - user: result.user, - sessionId: result.sessionId, - organizationId: result.claims?.org_id, - role: result.claims?.role, - roles: result.claims?.roles, - permissions: result.claims?.permissions, - entitlements: result.claims?.entitlements, - featureFlags: result.claims?.feature_flags, - impersonator: result.impersonator, - accessToken: result.accessToken, - }; + return plan.user; }); From 653d6e64fb7b23044daecdf426580930e8ac695b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:19:39 +0000 Subject: [PATCH 2/3] fix: Apply oxfmt formatting Co-Authored-By: nick.nisi@workos.com --- .oxlintrc.json | 9 ++++++++- src/server/actions.ts | 10 ++++------ src/server/server-fn-bodies.ts | 27 +++++++++++---------------- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 6e0fe76..6bb25f6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -13,7 +13,14 @@ { "patterns": [ { - "group": ["./auth-helpers*", "./authkit-loader*", "./context*", "./headers-bag*", "./action-bodies*", "./server-fn-bodies*"], + "group": [ + "./auth-helpers*", + "./authkit-loader*", + "./context*", + "./headers-bag*", + "./action-bodies*", + "./server-fn-bodies*" + ], "allowTypeImports": true, "message": "Static value imports of server-only modules are forbidden in this file. Move logic into action-bodies.ts or server-fn-bodies.ts and use a dynamic import inside the handler. See CLAUDE.md." }, diff --git a/src/server/actions.ts b/src/server/actions.ts index a42f412..2dd1b98 100644 --- a/src/server/actions.ts +++ b/src/server/actions.ts @@ -36,12 +36,10 @@ export const refreshAuthAction = createServerFn({ method: 'POST' }) /** * Get access token for the current session. */ -export const getAccessTokenAction = createServerFn({ method: 'GET' }).handler( - async (): Promise => { - const { getAccessTokenBody } = await import('./action-bodies.js'); - return getAccessTokenBody(); - }, -); +export const getAccessTokenAction = createServerFn({ method: 'GET' }).handler(async (): Promise => { + const { getAccessTokenBody } = await import('./action-bodies.js'); + return getAccessTokenBody(); +}); /** * Refresh and get a new access token. diff --git a/src/server/server-fn-bodies.ts b/src/server/server-fn-bodies.ts index ab2243e..37d20e3 100644 --- a/src/server/server-fn-bodies.ts +++ b/src/server/server-fn-bodies.ts @@ -74,9 +74,7 @@ function getAuthFromContext(): UserInfo | NoUserInfo { * confined to server-only modules (which the lazy-import boundary already * isolates from the client). */ -export type SignOutPlan = - | { kind: 'returnTo'; to: string } - | { kind: 'logoutUrl'; href: string; headers: Headers }; +export type SignOutPlan = { kind: 'returnTo'; to: string } | { kind: 'logoutUrl'; href: string; headers: Headers }; export async function getSignOutUrlBody(data?: { returnTo?: string }): Promise<{ url: string | null }> { const auth = getAuthFromContext(); @@ -119,32 +117,29 @@ export function getAuthBody(): UserInfo | NoUserInfo { export async function getAuthorizationUrlBody(options?: GetAuthURLOptions): Promise { const authkit = await getAuthkit(); - return forwardAuthorizationCookies(await authkit.createAuthorization(undefined, applyContextRedirectUri(options ?? {}))); + return forwardAuthorizationCookies( + await authkit.createAuthorization(undefined, applyContextRedirectUri(options ?? {})), + ); } -export async function getSignInUrlBody( - data?: string | Omit, -): Promise { +export async function getSignInUrlBody(data?: string | Omit): Promise { const options = typeof data === 'string' ? { returnPathname: data } : data; const authkit = await getAuthkit(); return forwardAuthorizationCookies(await authkit.createSignIn(undefined, applyContextRedirectUri(options ?? {}))); } -export async function getSignUpUrlBody( - data?: string | Omit, -): Promise { +export async function getSignUpUrlBody(data?: string | Omit): Promise { const options = typeof data === 'string' ? { returnPathname: data } : data; const authkit = await getAuthkit(); return forwardAuthorizationCookies(await authkit.createSignUp(undefined, applyContextRedirectUri(options ?? {}))); } -export type SwitchToOrganizationPlan = - | { kind: 'redirect'; to: string } - | { kind: 'user'; user: UserInfo }; +export type SwitchToOrganizationPlan = { kind: 'redirect'; to: string } | { kind: 'user'; user: UserInfo }; -export async function switchToOrganizationBody( - data: { organizationId: string; returnTo?: string }, -): Promise { +export async function switchToOrganizationBody(data: { + organizationId: string; + returnTo?: string; +}): Promise { const auth = getAuthFromContext(); if (!auth.user) { From ff52dee8c09920cc403096324b798a2abe95942c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 17:39:50 +0000 Subject: [PATCH 3/3] refactor: Extract shared mapAuthToBaseInfo helper Consolidate the duplicated auth-to-user-info field mapping from action-bodies.ts (sanitizeAuthForClient) and server-fn-bodies.ts (getAuthFromContext) into a shared mapAuthToBaseInfo helper in auth-helpers.ts. Also eliminates the 'any' type in sanitizeAuthForClient. Co-Authored-By: nick.nisi@workos.com --- src/server/action-bodies.ts | 28 ++++++---------------------- src/server/actions.spec.ts | 14 ++++++++++++++ src/server/auth-helpers.ts | 22 ++++++++++++++++++++++ src/server/server-fn-bodies.ts | 34 ++++------------------------------ 4 files changed, 46 insertions(+), 52 deletions(-) diff --git a/src/server/action-bodies.ts b/src/server/action-bodies.ts index 48ad59f..cb1a16b 100644 --- a/src/server/action-bodies.ts +++ b/src/server/action-bodies.ts @@ -1,4 +1,4 @@ -import { getRawAuthFromContext, isAuthConfigured, refreshSession } from './auth-helpers.js'; +import { getRawAuthFromContext, isAuthConfigured, mapAuthToBaseInfo, refreshSession } from './auth-helpers.js'; import type { ClientUserInfo, NoUserInfo, UserInfo } from './server-functions.js'; export interface OrganizationInfo { @@ -6,24 +6,6 @@ export interface OrganizationInfo { name: string; } -function sanitizeAuthForClient(auth: any): Omit | NoUserInfo { - if (!auth.user) { - return { user: null }; - } - - return { - user: auth.user, - sessionId: auth.sessionId, - organizationId: auth.claims?.org_id, - role: auth.claims?.role, - roles: auth.claims?.roles, - permissions: auth.claims?.permissions, - entitlements: auth.claims?.entitlements, - featureFlags: auth.claims?.feature_flags, - impersonator: auth.impersonator, - }; -} - export function checkSessionBody(): boolean { if (!isAuthConfigured()) { return false; @@ -38,7 +20,9 @@ export function checkSessionBody(): boolean { } export function getAuthBody(): ClientUserInfo | NoUserInfo { - return sanitizeAuthForClient(getRawAuthFromContext()); + const auth = getRawAuthFromContext(); + if (!auth.user) return { user: null }; + return mapAuthToBaseInfo(auth); } export async function refreshAuthBody(options?: { @@ -50,7 +34,7 @@ export async function refreshAuthBody(options?: { return { user: null }; } - return sanitizeAuthForClient(result); + return mapAuthToBaseInfo(result); } export function getAccessTokenBody(): string | undefined { @@ -80,7 +64,7 @@ export async function switchToOrganizationBody(data: { return { user: null }; } - return sanitizeAuthForClient(result); + return mapAuthToBaseInfo(result); } export async function getOrganizationBody(organizationId: string): Promise { diff --git a/src/server/actions.spec.ts b/src/server/actions.spec.ts index d2b7aac..f02919f 100644 --- a/src/server/actions.spec.ts +++ b/src/server/actions.spec.ts @@ -23,6 +23,20 @@ vi.mock('./auth-helpers', () => ({ }, isAuthConfigured: () => mockIsConfigured, refreshSession: vi.fn(), + mapAuthToBaseInfo: (auth: any) => { + if (!auth.user) return { user: null }; + return { + user: auth.user, + sessionId: auth.sessionId, + organizationId: auth.claims?.org_id, + role: auth.claims?.role, + roles: auth.claims?.roles, + permissions: auth.claims?.permissions, + entitlements: auth.claims?.entitlements, + featureFlags: auth.claims?.feature_flags, + impersonator: auth.impersonator, + }; + }, })); const mockGetOrganization = vi.fn(); diff --git a/src/server/auth-helpers.ts b/src/server/auth-helpers.ts index 6163cf8..ade4687 100644 --- a/src/server/auth-helpers.ts +++ b/src/server/auth-helpers.ts @@ -62,6 +62,28 @@ export async function getSessionWithRefreshToken(): Promise<{ }; } +/** + * Maps an already-narrowed authenticated AuthResult to the shared base user info + * shape (without accessToken). Callers must check `auth.user` before calling. + * Both action-bodies.ts and server-fn-bodies.ts use this to avoid duplicating + * the claims → public-info field mapping. + */ +export function mapAuthToBaseInfo = Record>( + auth: AuthResult & { user: User }, +) { + return { + user: auth.user, + sessionId: auth.sessionId, + organizationId: auth.claims?.org_id, + role: auth.claims?.role, + roles: auth.claims?.roles, + permissions: auth.claims?.permissions, + entitlements: auth.claims?.entitlements, + featureFlags: auth.claims?.feature_flags, + impersonator: auth.impersonator, + }; +} + /** * Refreshes the session with an optional organization ID. */ diff --git a/src/server/server-fn-bodies.ts b/src/server/server-fn-bodies.ts index 37d20e3..1691275 100644 --- a/src/server/server-fn-bodies.ts +++ b/src/server/server-fn-bodies.ts @@ -1,5 +1,5 @@ import type { GetAuthorizationUrlOptions as GetAuthURLOptions, HeadersBag } from '@workos/authkit-session'; -import { getRawAuthFromContext, refreshSession, getRedirectUriFromContext } from './auth-helpers.js'; +import { getRawAuthFromContext, mapAuthToBaseInfo, refreshSession, getRedirectUriFromContext } from './auth-helpers.js'; import { getAuthkit } from './authkit-loader.js'; import { getAuthKitContextOrNull } from './context.js'; import { emitHeadersFrom, forEachHeaderBagEntry } from './headers-bag.js'; @@ -48,23 +48,8 @@ function applyContextRedirectUri /** Internal: project raw auth context into the public UserInfo shape. */ function getAuthFromContext(): UserInfo | NoUserInfo { const auth = getRawAuthFromContext(); - - if (!auth.user) { - return { user: null }; - } - - return { - user: auth.user, - sessionId: auth.sessionId!, - organizationId: auth.claims?.org_id, - role: auth.claims?.role, - roles: auth.claims?.roles, - permissions: auth.claims?.permissions, - entitlements: auth.claims?.entitlements, - featureFlags: auth.claims?.feature_flags, - impersonator: auth.impersonator, - accessToken: auth.accessToken!, - }; + if (!auth.user) return { user: null }; + return { ...mapAuthToBaseInfo(auth), accessToken: auth.accessToken }; } /** @@ -154,17 +139,6 @@ export async function switchToOrganizationBody(data: { return { kind: 'user', - user: { - user: result.user, - sessionId: result.sessionId, - organizationId: result.claims?.org_id, - role: result.claims?.role, - roles: result.claims?.roles, - permissions: result.claims?.permissions, - entitlements: result.claims?.entitlements, - featureFlags: result.claims?.feature_flags, - impersonator: result.impersonator, - accessToken: result.accessToken, - }, + user: { ...mapAuthToBaseInfo(result), accessToken: result.accessToken }, }; }