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
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,23 @@ pnpm add @workos/authkit-session

```bash
WORKOS_CLIENT_ID=your-client-id
WORKOS_API_KEY=your-api-key
WORKOS_REDIRECT_URI=https://yourdomain.com/auth/callback
WORKOS_COOKIE_PASSWORD=must-be-at-least-32-characters-long-secret
WORKOS_API_KEY=your-api-key # optional; 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).

Or programmatically:

```typescript
import { configure } from '@workos/authkit-session';

configure({
clientId: 'your-client-id',
apiKey: 'your-api-key',
redirectUri: 'https://yourdomain.com/auth/callback',
cookiePassword: 'must-be-at-least-32-characters-long-secret',
apiKey: 'your-api-key', // optional: omit for public client (keyless) mode
});
```

Expand Down Expand Up @@ -167,15 +169,38 @@ auth.claims.sid; // string
| Environment Variable | Config Key | Description |
| ------------------------- | ---------------- | ------------------------------------ |
| `WORKOS_CLIENT_ID` | `clientId` | WorkOS client ID |
| `WORKOS_API_KEY` | `apiKey` | WorkOS API key |
| `WORKOS_API_KEY` | `apiKey` | WorkOS API key (optional, see below) |
| `WORKOS_REDIRECT_URI` | `redirectUri` | OAuth callback URL |
| `WORKOS_COOKIE_PASSWORD` | `cookiePassword` | 32+ char encryption key |
| `WORKOS_COOKIE_NAME` | `cookieName` | Cookie name (default: `wos-session`) |
| `WORKOS_COOKIE_MAX_AGE` | `cookieMaxAge` | Cookie lifetime in seconds |
| `WORKOS_COOKIE_DOMAIN` | `cookieDomain` | Cookie domain |
| `WORKOS_COOKIE_SAME_SITE` | `cookieSameSite` | `lax`, `strict`, or `none` |

Environment variables override programmatic config.
Environment variables override programmatic config. `WORKOS_CLIENT_ID`, `WORKOS_REDIRECT_URI` and `WORKOS_COOKIE_PASSWORD` are required; everything else is optional.

### 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=your-client-id
WORKOS_REDIRECT_URI=https://yourdomain.com/auth/callback
WORKOS_COOKIE_PASSWORD=must-be-at-least-32-characters-long-secret
```

Every sign-in already uses PKCE: the code verifier is sealed into an HttpOnly cookie on the browser that started the flow, so only that browser can complete the code exchange, and no client secret is sent. Session refresh sends the refresh token with your client ID and no secret.

What works without a key: `createSignIn` / `createSignUp` / `createAuthorization`, `handleCallback`, `withAuth` (including automatic refresh), `refreshSession`, `switchOrganization`, `signOut`, and the session storage helpers.

What needs a key: WorkOS management APIs called directly through `getWorkOS()` or `authService.getWorkOS()`, such as `organizations.*` or `userManagement.getUser`. Without a key the WorkOS SDK rejects them with an `ApiKeyRequiredException` before sending a request.

Keyless mode needs `@workos-inc/node` 8.0.0 or later, which every version in this package's peer range (`^8.0.0 || ^9.0.0 || ^10.0.0`) satisfies.

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.

## API Overview

Expand Down Expand Up @@ -275,7 +300,7 @@ If the callback URL has no `state` (malformed callback), skip this call — the

### Direct Access (Advanced)

For maximum control, use the primitives directly:
For maximum control, use the primitives directly. WorkOS management APIs on the client from `getWorkOS()` need an API key; in [public client mode](#public-client-keyless-mode) they throw an `ApiKeyRequiredException`.

```typescript
import {
Expand Down
99 changes: 99 additions & 0 deletions src/core/client/workos.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { vi } from 'vitest';

const env = {
WORKOS_CLIENT_ID: 'client_123',
WORKOS_REDIRECT_URI: 'http://localhost:3000/callback',
WORKOS_COOKIE_PASSWORD: 'a'.repeat(32),
};

// Fresh module graph per test: the config provider and getWorkOS() are
// process-wide singletons.
async function load(source: Record<string, string>) {
const { configure } = await import('../config.js');
configure(key => source[key]);
return import('./workos.js');
}

describe('WorkOS client', () => {
let savedApiKey: string | undefined;

beforeEach(() => {
vi.resetModules();
savedApiKey = process.env.WORKOS_API_KEY;
// The SDK falls back to process.env.WORKOS_API_KEY; keep it out unless a
// test sets it on purpose.
delete process.env.WORKOS_API_KEY;
});

afterEach(() => {
vi.doUnmock('@workos-inc/node');
if (savedApiKey !== undefined) process.env.WORKOS_API_KEY = savedApiKey;
else delete process.env.WORKOS_API_KEY;
});

describe('constructor arguments', () => {
let WorkOSMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
WorkOSMock = vi.fn();
vi.doMock('@workos-inc/node', () => ({ WorkOS: WorkOSMock }));
});

it('passes apiKey and clientId in confidential mode', async () => {
const { createWorkOSInstance } = await load({
...env,
WORKOS_API_KEY: 'sk_test_confidential',
});
createWorkOSInstance();

expect(WorkOSMock).toHaveBeenCalledTimes(1);
expect(WorkOSMock.mock.calls[0]).toEqual([
{
apiKey: 'sk_test_confidential',
clientId: 'client_123',
apiHostname: 'api.workos.com',
https: true,
port: undefined,
appInfo: { name: 'authkit-session', version: expect.any(String) },
},
]);
});

it('passes clientId and no apiKey in public mode', async () => {
const { createWorkOSInstance } = await load(env);
createWorkOSInstance();

expect(WorkOSMock).toHaveBeenCalledTimes(1);
expect(WorkOSMock.mock.calls[0]).toHaveLength(1);
const [options] = WorkOSMock.mock.calls[0]!;
expect(options.apiKey).toBeUndefined();
expect(options.clientId).toBe('client_123');
});
});

it('builds a real keyless client in public mode', async () => {
const { getWorkOS } = await load(env);
const workos = getWorkOS();

expect(workos.key).toBeUndefined();
expect(workos.clientId).toBe('client_123');
});

it('builds a real confidential client with a key', async () => {
const { getWorkOS } = await load({
...env,
WORKOS_API_KEY: 'sk_test_confidential',
});

expect(getWorkOS().key).toBe('sk_test_confidential');
});

it('picks up WORKOS_API_KEY from process.env even when a custom value source omits it', async () => {
// Documented behavior: the WorkOS SDK falls back to process.env, so a key
// in the process environment always ends up on the client.
process.env.WORKOS_API_KEY = 'sk_test_from_process_env';
const { getWorkOS } = await load(env);

expect(getWorkOS().key).toBe('sk_test_from_process_env');
});
});
44 changes: 24 additions & 20 deletions src/core/client/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,39 @@ import { once } from '../../utils.js';
import pkg from '../../../package.json' with { type: 'json' };

/**
* Create a WorkOS instance with the provided API key and optional settings.
* Create a WorkOS instance from the AuthKit configuration.
*
* With an API key this is a confidential client. Without one it is a PKCE
* public client: sign-in, callback, refresh and sign-out work, but WorkOS
* management APIs throw an `ApiKeyRequiredException` before any request.
*
* Note: when no API key is configured, the WorkOS SDK itself falls back to
* `process.env.WORKOS_API_KEY`. So if that variable is set in the process
* environment, a key is always in play, even when a custom value source
* passed to `configure()` doesn't provide one.
*/
export function createWorkOSInstance() {
// Get required API key from config
const apiKey = getConfig('apiKey');

// Get optional settings
const apiHostname = getConfig('apiHostname');
const apiHttps = getConfig('apiHttps');
const apiPort = getConfig('apiPort');

const options = {
apiHostname,
https: apiHttps,
port: apiPort,
return new WorkOS({
// Optional: absent means public-client (keyless) mode
apiKey: getConfig('apiKey'),
clientId: getConfig('clientId'),
apiHostname: getConfig('apiHostname'),
https: getConfig('apiHttps'),
port: getConfig('apiPort'),
appInfo: {
name: 'authkit-session',
version: pkg.version,
},
};

// Initialize the WorkOS client with config values
const workos = new WorkOS(apiKey, options);

return workos;
});
}

/**
* Create a WorkOS instance with the provided API key and optional settings.
* Returns the shared WorkOS client used by AuthKit.
* This function is lazy loaded to avoid loading the WorkOS SDK when it's not needed.
*
* Calling WorkOS management APIs directly (e.g. `getWorkOS().organizations`,
* `getWorkOS().userManagement.getUser`) requires an API key
* (`WORKOS_API_KEY` or `configure({ apiKey })`). In public-client (keyless)
* mode those calls throw an `ApiKeyRequiredException` before any request.
*/
export const getWorkOS = once(createWorkOSInstance);
2 changes: 1 addition & 1 deletion src/core/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ describe('config', () => {

it('shows all missing fields at once', () => {
expect(() => validateConfig()).toThrow(/WORKOS_CLIENT_ID is required/);
expect(() => validateConfig()).toThrow(/WORKOS_API_KEY is required/);
expect(() => validateConfig()).not.toThrow(/WORKOS_API_KEY/);
expect(() => validateConfig()).toThrow(/WORKOS_REDIRECT_URI is required/);
expect(() => validateConfig()).toThrow(
/WORKOS_COOKIE_PASSWORD is required/,
Expand Down
Loading
Loading