Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Session encryption: iron-session (sealed cookies). JWT verification: jose (JWKS)

```env
WORKOS_CLIENT_ID=<your_client_id>
WORKOS_API_KEY=<your_api_key>
WORKOS_API_KEY=<your_api_key> # optional: omit for keyless PKCE public-client mode
WORKOS_REDIRECT_URI=http://localhost:3000/callback
WORKOS_COOKIE_PASSWORD=<min_32_chars>
```
Expand Down
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. 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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion example/.env.example
Original file line number Diff line number Diff line change
@@ -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='<YOUR_API_KEY>'
WORKOS_CLIENT_ID='<YOUR_CLIENT_ID>'
WORKOS_COOKIE_PASSWORD='<YOUR_COOKIE_PASSWORD>'

# Optional: omit to run as a PKCE public client (sign-in only, no management APIs)
WORKOS_API_KEY='<YOUR_API_KEY>'
2 changes: 1 addition & 1 deletion example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ You will need a [WorkOS account](https://dashboard.workos.com/signup).

```bash
WORKOS_CLIENT_ID=<YOUR_CLIENT_ID>
WORKOS_API_KEY=<YOUR_API_SECRET_KEY>
WORKOS_COOKIE_PASSWORD=<YOUR_COOKIE_PASSWORD>
WORKOS_REDIRECT_URI=http://localhost:3000/callback
WORKOS_API_KEY=<YOUR_API_SECRET_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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion src/client/components/impersonation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +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);
// 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;
Expand Down
104 changes: 104 additions & 0 deletions src/client/impersonation.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) {
vi.mocked(useAuth).mockReturnValue({
impersonator: { email: 'admin@example.com', reason: 'testing' },
Expand Down Expand Up @@ -65,6 +72,103 @@ 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();
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.',
);
mockAuth({ organizationId: 'org_123' });
vi.mocked(getOrganizationAction).mockRejectedValue(error);

try {
const { container } = await act(async () => render(<Impersonation />));
// 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 {
nodeProcess.off('unhandledRejection', unhandled);
consoleError.mockRestore();
}
});

describe('when the organization changes while a lookup is pending', () => {
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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(<Impersonation />));
await act(async () => {
mockAuth({ organizationId: 'org_456' });
rerender(<Impersonation />);
});
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' });

await act(async () => {
render(<Impersonation />);
});

expect(await screen.findByText('Test Org')).toBeInTheDocument();
});

it('should render at the bottom by default', () => {
mockAuth();
const { container } = render(<Impersonation />);
Expand Down
15 changes: 14 additions & 1 deletion src/server/action-bodies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export async function switchToOrganizationBody(data: {
return mapAuthToBaseInfo(result);
}

/**
* Requires a WorkOS API key. In public-client (keyless) mode the WorkOS SDK
* 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<OrganizationInfo | null> {
try {
// Authorization: only resolve the organization the caller is currently
Expand All @@ -82,7 +88,14 @@ export async function getOrganizationBody(organizationId: string): Promise<Organ
const workos = getWorkOS();
const org = await workos.organizations.getOrganization(organizationId);
return { id: org.id, name: org.name };
} catch {
} catch (error) {
// The SDK sets `name` explicitly; matching on it survives duplicate SDK copies.
if (error instanceof Error && error.name === 'ApiKeyRequiredException') {
throw new Error(
'getOrganizationAction requires a WorkOS API key; set WORKOS_API_KEY. Public-client (keyless) mode supports sign-in only.',
{ cause: error },
);
}
return null;
}
}
36 changes: 36 additions & 0 deletions src/server/actions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ApiKeyRequiredException, WorkOS } from '@workos-inc/node';

// Mock context state
let mockAuthContext: any = null;
Expand Down Expand Up @@ -342,5 +343,40 @@ describe('Actions', () => {

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();
});
});
});
3 changes: 3 additions & 0 deletions src/server/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export const switchToOrganizationAction = createServerFn({ method: 'POST' })

/**
* Fetch organization details by ID.
*
* Requires a WorkOS API key: in public-client (keyless) mode this rejects with
* an error naming `WORKOS_API_KEY`.
*/
export const getOrganizationAction = createServerFn({ method: 'GET' })
.validator((organizationId: string) => organizationId)
Expand Down
4 changes: 4 additions & 0 deletions src/server/authkit-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ export async function getConfig<K extends keyof AuthKitConfig>(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<void> {
const { validateConfig: validateConfigFromSession } = await import('@workos/authkit-session');
return validateConfigFromSession();
Expand Down
Loading
Loading