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
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,13 @@ The simplest way is to set environment variables in your `.env.local` file:

```bash
WORKOS_CLIENT_ID="client_..." # retrieved from the WorkOS dashboard
WORKOS_API_KEY="sk_test_..." # retrieved from the WorkOS dashboard
WORKOS_REDIRECT_URI="http://localhost:5173/callback" # configured in the WorkOS dashboard
WORKOS_COOKIE_PASSWORD="<your password>" # generate a secure password here
WORKOS_API_KEY="sk_test_..." # optional: retrieved from the WorkOS dashboard; needed for feature flags and WorkOS management APIs
```

Without `WORKOS_API_KEY`, AuthKit runs as a PKCE public client. See [Public client (keyless) mode](#public-client-keyless-mode).

### 2. Programmatic Configuration

You can also configure AuthKit programmatically by importing the `configure` function:
Expand All @@ -57,10 +59,10 @@ import { configure } from '@workos-inc/authkit-react-router';
// In your root or entry file
configure({
clientId: 'client_1234567890',
apiKey: 'sk_test_1234567890',
redirectUri: 'http://localhost:5173/callback',
cookiePassword: 'your-secure-cookie-password',
// Optional settings
apiKey: 'sk_test_1234567890', // omit for public client (keyless) mode
cookieName: 'my-custom-cookie-name',
apiHttps: true,
cookieMaxAge: 60 * 60 * 24 * 30, // 30 days
Expand Down Expand Up @@ -100,7 +102,7 @@ When retrieving configuration values, AuthKit follows this priority order:
| Option | Environment Variable | Default | Required | Description |
| ---------------- | ------------------------ | --------------------- | -------- | --------------------------------------------- |
| `clientId` | `WORKOS_CLIENT_ID` | - | Yes | Your WorkOS Client ID |
| `apiKey` | `WORKOS_API_KEY` | - | Yes | Your WorkOS API Key |
| `apiKey` | `WORKOS_API_KEY` | - | No | Your WorkOS API Key. Needed for feature flags and WorkOS management APIs; omit for [public client mode](#public-client-keyless-mode) |
| `redirectUri` | `WORKOS_REDIRECT_URI` | - | Yes | The callback URL configured in WorkOS |
| `cookiePassword` | `WORKOS_COOKIE_PASSWORD` | - | Yes | Password for cookie encryption (min 32 chars) |
| `cookieName` | `WORKOS_COOKIE_NAME` | `wos-session` | No | Name of the session cookie |
Expand All @@ -114,6 +116,35 @@ When retrieving configuration values, AuthKit follows this priority order:
>
> The `cookiePassword` must be at least 32 characters long for security reasons.

### Public client (keyless) mode

If your service 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:5173/callback"
WORKOS_COOKIE_PASSWORD="<your password>"
```

Every sign-in already uses PKCE: the code verifier stays in an HttpOnly cookie on the browser that started the flow, so the authorization code can only be exchanged by that browser, without a client secret. Session refresh sends the refresh token with your client ID and no secret.

What works without a key: sign-in and sign-up URLs, the callback (`authLoader`), `authkitLoader` (including automatic refresh), `withAuth` (without automatic refresh), `refreshSession`, `switchToOrganization`, `saveSession`, and `signOut`.

What needs a key:

- Feature flags. `getFeatureFlagsRuntimeClient()` throws `Feature flags require a WorkOS API key; ...` in keyless mode.
- Direct WorkOS management calls through `getWorkOS()`, such as `organizations.*` or `userManagement.getUser`. The WorkOS SDK rejects them with an `ApiKeyRequiredException`.

In TypeScript, `AuthKitConfig` is a union of `AuthKitConfidentialConfig` (with `apiKey`) and `AuthKitPublicConfig` (without). Because configuration is resolved from the environment at runtime, `getConfig('apiKey')` is typed `string | undefined`.

> [!IMPORTANT]
>
> The WorkOS Node SDK falls back to `process.env.WORKOS_API_KEY` when no key is configured. If that variable is set in the server's environment, a key is in play, even if you use a custom value source with `configure()`. To run keyless, make sure `WORKOS_API_KEY` is absent from the process environment.

> [!NOTE]
>
> To verify: whether your WorkOS environment must allow public clients for this client ID. If the code exchange fails in keyless mode with a client authentication error, check your client's settings in the WorkOS dashboard.

## Setup

### Callback route
Expand Down Expand Up @@ -172,6 +203,10 @@ export function App() {

### Evaluate feature flags

> [!NOTE]
>
> Feature flags require a WorkOS API key (`WORKOS_API_KEY` or `configure({ apiKey })`). They aren't available in [public client mode](#public-client-keyless-mode).

By default, `authkitLoader` reads `featureFlags` from the `feature_flags`
claim in the WorkOS access token. This is convenient for small flag sets, but
changes are reflected only after the user's access token refreshes.
Expand Down Expand Up @@ -487,7 +522,7 @@ export const loader = async (args: LoaderFunctionArgs) => {

### Advanced: Direct access to the WorkOS client

For advanced use cases or functionality not covered by the helper methods, you can access the underlying WorkOS client directly:
For advanced use cases or functionality not covered by the helper methods, you can access the underlying WorkOS client directly. WorkOS management APIs need an API key; in [public client mode](#public-client-keyless-mode) they throw an `ApiKeyRequiredException`.

```ts
import { getWorkOS } from '@workos-inc/authkit-react-router';
Expand Down
73 changes: 71 additions & 2 deletions src/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,76 @@ describe('config', () => {
it('throws an error if required values are missing', () => {
expect(() => {
configure(() => undefined);
getConfig('apiKey');
}).toThrow();
getConfig('clientId');
}).toThrow('Missing required configuration value for clientId (WORKOS_CLIENT_ID).');
});

describe('public client (no API key)', () => {
const publicEnv = {
WORKOS_CLIENT_ID: 'client_public',
WORKOS_REDIRECT_URI: 'http://localhost:5173/callback',
WORKOS_COOKIE_PASSWORD: 'kR620keEzOIzPThfnMEAba8XYgKdQ5vg',
};
let savedApiKey: string | undefined;

beforeEach(() => {
savedApiKey = process.env.WORKOS_API_KEY;
delete process.env.WORKOS_API_KEY;
});

afterEach(() => {
if (savedApiKey !== undefined) process.env.WORKOS_API_KEY = savedApiKey;
});

it('resolves apiKey to undefined instead of throwing', () => {
configure(() => undefined);
expect(getConfig('apiKey')).toBeUndefined();
});

it('works with env-only configuration from process.env', () => {
// process.env from jest.setup minus WORKOS_API_KEY
expect(getConfig('apiKey')).toBeUndefined();
expect(getConfig('clientId')).toBe(process.env.WORKOS_CLIENT_ID);
expect(getConfig('redirectUri')).toBe(process.env.WORKOS_REDIRECT_URI);
expect(getConfig('cookiePassword')).toBe(process.env.WORKOS_COOKIE_PASSWORD);
});

it('works with an env-only value source', () => {
configure({}, publicEnv);
expect(getConfig('apiKey')).toBeUndefined();
expect(getConfig('clientId')).toBe('client_public');
expect(getConfig('redirectUri')).toBe('http://localhost:5173/callback');
expect(getConfig('cookiePassword')).toBe(publicEnv.WORKOS_COOKIE_PASSWORD);
});

it('accepts a programmatic config without apiKey', () => {
configure(
{
clientId: 'client_public',
redirectUri: 'http://localhost:5173/callback',
cookiePassword: publicEnv.WORKOS_COOKIE_PASSWORD,
},
() => undefined,
);
expect(getConfig('apiKey')).toBeUndefined();
expect(getConfig('clientId')).toBe('client_public');
});

it.each([
['clientId', 'WORKOS_CLIENT_ID'],
['redirectUri', 'WORKOS_REDIRECT_URI'],
['cookiePassword', 'WORKOS_COOKIE_PASSWORD'],
] as const)('still requires %s', (key, envKey) => {
const rest: Record<string, string> = { ...publicEnv };
delete rest[envKey];
configure({}, rest);
expect(() => getConfig(key)).toThrow(`Missing required configuration value for ${key} (${envKey}).`);
});

it('still validates the cookie password length', () => {
expect(() => configure({ clientId: 'client_public', cookiePassword: 'short' })).toThrow(
'cookiePassword must be at least 32 characters long',
);
});
});
});
13 changes: 12 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export class Configuration {

private valueSource: ValueSource = defaultSource;

private readonly requiredKeys: (keyof AuthKitConfig)[] = ['clientId', 'apiKey', 'redirectUri', 'cookiePassword'];
// `apiKey` is intentionally optional: without it AuthKit runs as a PKCE
// public client (sign-in only). See `AuthKitPublicConfig`.
private readonly requiredKeys: (keyof AuthKitConfig)[] = ['clientId', 'redirectUri', 'cookiePassword'];

/**
* Convert a camelCase string to an uppercase, underscore-separated environment variable name.
Expand Down Expand Up @@ -133,12 +135,21 @@ export function configure(source: ValueSource): void;
* @param config The configuration values
*
* @example
* // Confidential client (API key enables feature flags and management APIs)
* configure({
* clientId: 'your-client-id',
* redirectUri: 'https://your-app.com/auth/callback',
* apiKey: 'your-api-key',
* cookiePassword: 'your-cookie-password',
* });
*
* @example
* // Public client (keyless, sign-in only)
* configure({
* clientId: 'your-client-id',
* redirectUri: 'https://your-app.com/auth/callback',
* cookiePassword: 'your-cookie-password',
* });
*/
export function configure(config: Partial<AuthKitConfig>): void;
/**
Expand Down
30 changes: 30 additions & 0 deletions src/feature-flags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,34 @@ describe('feature flags', () => {
expect(createRuntimeClient).toHaveBeenCalledTimes(1);
expect(createRuntimeClient).toHaveBeenCalledWith({ pollingIntervalMs: 5000 });
});

describe('without an API key', () => {
let savedApiKey: string | undefined;

beforeEach(() => {
savedApiKey = process.env.WORKOS_API_KEY;
delete process.env.WORKOS_API_KEY;
jest.resetModules();
});

afterEach(() => {
if (savedApiKey !== undefined) process.env.WORKOS_API_KEY = savedApiKey;
});

it('throws an actionable error before creating a client or calling the network', async () => {
const fetchSpy = jest.spyOn(globalThis, 'fetch');
const { getWorkOS: getKeylessWorkOS } = await import('./workos.js');
const workos = getKeylessWorkOS();
expect(workos.key).toBeUndefined();
const createRuntimeClient = jest.spyOn(workos.featureFlags, 'createRuntimeClient');
const { getFeatureFlagsRuntimeClient } = await import('./feature-flags.js');

expect(() => getFeatureFlagsRuntimeClient()).toThrow(
'Feature flags require a WorkOS API key; set WORKOS_API_KEY or configure({ apiKey }). ' +
'Public-client (keyless) mode supports sign-in only.',
);
expect(createRuntimeClient).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
});
19 changes: 16 additions & 3 deletions src/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,26 @@ import type { FeatureFlagsRuntimeClient, RuntimeClientOptions } from '@workos-in
import { lazy } from './utils.js';
import { getWorkOS } from './workos.js';

export const FEATURE_FLAGS_API_KEY_REQUIRED =
'Feature flags require a WorkOS API key; set WORKOS_API_KEY or configure({ apiKey }). ' +
'Public-client (keyless) mode supports sign-in only.';

/**
* Returns a shared WorkOS Feature Flags runtime client.
*
* The runtime client keeps feature flag state in sync in the background, so it
* should be created once per server process instead of once per request.
* Options are only used when the client is created for the first time.
*
* Requires a WorkOS API key (`WORKOS_API_KEY` or `configure({ apiKey })`).
* Throws in public-client (keyless) mode.
*/
export const getFeatureFlagsRuntimeClient = lazy(
(options?: RuntimeClientOptions): FeatureFlagsRuntimeClient => getWorkOS().featureFlags.createRuntimeClient(options),
);
export const getFeatureFlagsRuntimeClient = lazy((options?: RuntimeClientOptions): FeatureFlagsRuntimeClient => {
const workos = getWorkOS();
// Check the client's effective key (which includes the SDK's own
// WORKOS_API_KEY env fallback) so this matches what requests would send.
if (!workos.key) {
throw new Error(FEATURE_FLAGS_API_KEY_REQUIRED);
}
return workos.featureFlags.createRuntimeClient(options);
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { getFeatureFlagsRuntimeClient } from './feature-flags.js';
import { authkitLoader, refreshSession, saveSession } from './session.js';
import { getWorkOS } from './workos.js';

export type { AuthKitConfig, AuthKitConfidentialConfig, AuthKitPublicConfig } from './interfaces.js';

export {
authLoader,
authkitLoader,
Expand Down
47 changes: 39 additions & 8 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,21 +225,15 @@ export interface UnauthorizedData {
}

/**
* AuthKit Configuration Options
* Configuration shared by both client modes.
*/
export interface AuthKitConfig {
interface AuthKitBaseConfig {
/**
* The WorkOS Client ID
* Equivalent to the WORKOS_CLIENT_ID environment variable
*/
clientId: string;

/**
* The WorkOS API Key
* Equivalent to the WORKOS_API_KEY environment variable
*/
apiKey: string;

/**
* The redirect URI for the authentication callback
* Equivalent to the WORKOS_REDIRECT_URI environment variable
Expand Down Expand Up @@ -291,3 +285,40 @@ export interface AuthKitConfig {
*/
cookieName: string;
}

/**
* Confidential client: the server holds a WorkOS API key.
*
* The key is sent as the client secret on code exchange and refresh (alongside
* PKCE), and unlocks features that call WorkOS management APIs: feature flags
* (`getFeatureFlagsRuntimeClient`) and direct `getWorkOS()` calls such as
* `organizations.*` or `userManagement.getUser`.
*/
export interface AuthKitConfidentialConfig extends AuthKitBaseConfig {
/**
* The WorkOS API Key
* Equivalent to the WORKOS_API_KEY environment variable
*/
apiKey: string;
}

/**
* Public client (keyless): the server holds no WorkOS API key.
*
* Sign-in, callback, session refresh, organization switching and sign-out all
* work; PKCE protects the code exchange and the refresh token is bound to the
* client ID. Feature flags and WorkOS management APIs are unavailable.
*/
export interface AuthKitPublicConfig extends AuthKitBaseConfig {
apiKey?: never;
}

/**
* AuthKit Configuration Options
*
* Discriminated on the presence of `apiKey`: {@link AuthKitConfidentialConfig}
* when set, {@link AuthKitPublicConfig} when not. Configuration is resolved at
* runtime from environment variables and `configure()`, so `getConfig('apiKey')`
* is typed `string | undefined`.
*/
export type AuthKitConfig = AuthKitConfidentialConfig | AuthKitPublicConfig;
Loading
Loading