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
137 changes: 137 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ workos-emulate
workos-emulate --port 9100 --json
workos-emulate --seed workos-emulate.config.yaml
workos-emulate --interactive # serve login pages for E2E browser testing
workos-emulate --signing-key ci-key.pem --issuer https://api.workos.com # stable JWKS and iss
workos-emulate --version
```

Expand Down Expand Up @@ -510,6 +511,142 @@ All events are also queryable at `GET /events` (filter with `?events[]=user.crea
- Resources defined in a seed file record events (visible at `GET /events`) but are not delivered to webhook endpoints from the same seed file — endpoints are registered last, mirroring real WorkOS, where pre-existing data never replays. Register endpoints via the API if you want deliveries for setup data.
- `dsync.group.user_added` / `dsync.group.user_removed` are catalogued but never emitted: the emulator has no directory group membership mutation surface.

## JWT Templates (custom claims)

A JWT template adds your own claims to every access token the emulator mints, so authorization
code that reads a custom claim runs against the emulator unchanged. Seed it to have the claims
present from the first sign-in, with no setup call:

```yaml
jwtTemplate:
content: >-
{"urn:myapp:name": "{{ user.first_name }} {{ user.last_name }}",
"urn:myapp:tenant": "{{ organization.metadata.tenant_id }}",
"urn:myapp:role": "{{ organization_membership.role }}"}
```

Or set it at runtime, matching the WorkOS API — `content` is a template string that renders to a
JSON object:

```bash
curl -X PUT http://localhost:4100/user_management/jwt_template \
-H "Authorization: Bearer sk_test_default" \
-H "Content-Type: application/json" \
-d '{"content": "{\"urn:myapp:tenant\": \"{{ organization.metadata.tenant_id }}\"}"}'
```

Either way the rendered claims land in the token:

```json
{
"sub": "user_01...",
"org_id": "org_01...",
"role": "admin",
"urn:myapp:name": "Alice Smith",
"urn:myapp:tenant": "tenant_123"
}
```

### Template syntax

WorkOS uses a small interpolation syntax, not full Liquid. The emulator implements that subset:

| Form | Meaning |
| --------------------------------------- | ------------------------------------------------------ |
| `{{ user.email }}` | Interpolate a value by dotted path |
| `{{ user.nickname \|\| user.email }}` | Fallback chain; the first non-null value wins |
| `{{ user.nickname \|\| 'anonymous' }}` | Single-quoted literal as the last resort |
| `"{{ user.first_name }} {{ user.id }}"` | Concatenation inside a JSON string; null becomes `""` |
| `{"meta": {{ user.metadata }}}` | A whole object or array, interpolated outside a string |
| `organization.domains.0.domain` | Array index as a path segment |

Filters, conditionals, and loops are not part of the syntax and are not supported.

Available variables are `user.*`, `organization.*`, and `organization_membership.*`. `organization`
and `organization_membership` are only populated for an org-scoped session; in a session with no
organization they resolve to null, so use a fallback if a claim must always be present.

Templates apply to AuthKit session tokens — every grant on `POST /user_management/authenticate`,
including `refresh_token`, so claims survive a refresh. They do not apply to M2M
(`client_credentials`) tokens, widget tokens, or the profile-based `POST /sso/token`, none of which
resolve a user and membership to render against.

### What is rejected

Templates are validated when set — over the API, and at startup for a seeded one, so a bad template
fails the boot rather than the first sign-in. `--validate-config` checks it too.

- **Reserved claims.** A template may not set `iss`, `sub`, `exp`, `iat`, `nbf`, or `jti`. Note that
`aud`, `sid`, `org_id`, `role`, `roles`, and `permissions` are _not_ reserved: a template may
deliberately override those, and the rendered value wins over what the emulator resolved.
- **Unknown variables.** An unrecognized root (`{{ usr.email }}`) is a typo and is rejected. A path
_below_ a known root that the emulator does not model resolves to null instead — including
`organization.allow_profiles_outside_organization` and
`organization_membership.custom_attributes`, which the emulator has no data for and will not
invent.
- **Anything that is not a JSON object** with at least one key.

WorkOS caps rendered claims at 3072 bytes, because the session cookie carrying them has to fit in a
browser. That depends on the data, so it is enforced when the token is signed: a template that
renders too large fails the authenticate call with a 422 naming the size, rather than quietly
handing back a token missing its claims. Nothing is persisted when that happens — no session, no
refresh token, no bumped `last_sign_in_at` — with one exception: a login that passed
`invitation_token` has already consumed the invitation by then, and the membership it created
stands. Retrying with the same token returns `invitation_invalid`, so fix the template and re-seed
rather than replaying the login.

> Earlier versions accepted a `custom_claims` object on this endpoint and stored it without ever
> putting it in a token. That field is gone; `content` is what works. Sending `custom_claims` now
> returns a 422 pointing at `content`.

## Stable Signing Key and Issuer

By default the emulator generates an RSA keypair at startup and mints its own URL as `iss`. That is
fine for a single run, but it means a restart invalidates every token already issued and changes the
published JWKS — and the issuer moves with the port.

Pin either or both to make tokens outlive a restart:

```bash
# Generate a key once and keep it with your test fixtures
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ci-key.pem

workos-emulate \
--signing-key ci-key.pem \
--kid ci_key \
--issuer https://api.workos.com
```

Each flag has an environment equivalent — `WORKOS_EMULATE_SIGNING_KEY`, `WORKOS_EMULATE_KID`,
`WORKOS_EMULATE_ISSUER` — so a compose file can set them once. Flags win over the environment.

Programmatically:

```ts
const emulator = await createEmulator({
signingKey: { privateKey: readFileSync('ci-key.pem', 'utf-8'), kid: 'ci_key' },
issuer: 'https://api.workos.com',
});
```

What this buys you:

- **JWKS stable across restarts.** `/sso/jwks/:client_id` publishes the same key every boot, so a
token minted before a restart still verifies after it. Without a pinned key, a verifier that
cached the JWKS must refetch.
- **A constant `iss`.** A verifier comparing `iss` against a hardcoded string needs no test-only
branch. It must still fetch JWKS from the emulator — pinning the issuer does not make WorkOS's
real keys apply.
- **One key across several emulators**, or tokens pre-signed offline with the same key the emulator
verifies.

The key must be a PEM-encoded RSA private key, since tokens are signed RS256; anything else fails at
startup with a message saying why. Omit `--kid` and the `kid` is derived from the key itself, so it
is stable for a pinned key without being pinned separately.

> A pinned signing key is a test fixture, not a secret to reuse anywhere real. Never point the
> emulator at a key your production environment trusts.

## Error Hooks

Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.).
Expand Down
45 changes: 45 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ interface CliArgs {
port: number;
host?: string;
seed?: string;
signingKey?: string;
kid?: string;
issuer?: string;
json: boolean;
help: boolean;
version: boolean;
Expand All @@ -22,6 +25,13 @@ interface CliArgs {
const DEFAULT_PORT = 4100;
const SEED_CANDIDATES = ['workos-emulate.config.yaml', 'workos-emulate.config.yml', 'workos-emulate.config.json'];

/** Flags taking a string value, accepted as either `--flag value` or `--flag=value`. */
const VALUE_FLAGS = [
['--signing-key', 'signingKey'],
['--kid', 'kid'],
['--issuer', 'issuer'],
] as const satisfies ReadonlyArray<readonly [string, 'signingKey' | 'kid' | 'issuer']>;

function printHelp(): void {
console.log(`Usage: workos-emulate [options]

Expand All @@ -32,13 +42,22 @@ Options:
--host <hostname> Interface to bind to (default: localhost). Use 0.0.0.0 to
intentionally expose the emulator to other hosts.
--seed, -s <path> Path to seed config file (YAML or JSON)
--signing-key <path>
Path to a PEM-encoded RSA private key to sign tokens with. Without
it a key is generated at startup, so the published JWKS changes on
every restart. Pin it to keep the JWKS stable.
--kid <id> Key id to advertise in the JWKS (default: derived from the key)
--issuer <url> Value to mint as the "iss" claim (default: the emulator's own URL)
--interactive, -i Show login pages for SSO/AuthKit (for E2E browser testing)
--validate-config Validate seed config file without starting server
--json Print startup details as JSON
--version, -v Print the installed version
--help, -h Show this help message

Environment:
WORKOS_EMULATE_SIGNING_KEY=<path> Same as --signing-key
WORKOS_EMULATE_KID=<id> Same as --kid
WORKOS_EMULATE_ISSUER=<url> Same as --issuer
NO_UPDATE_NOTIFIER=1 Disable update checks
WORKOS_EMULATE_DISABLE_UPDATE_CHECK=1 Disable update checks
`);
Expand Down Expand Up @@ -122,6 +141,15 @@ function parseArgs(argv: string[]): CliArgs {
continue;
}

const valueFlag = VALUE_FLAGS.find(([flag]) => arg === flag || arg.startsWith(`${flag}=`));
if (valueFlag) {
const [flag, field] = valueFlag;
const value = arg === flag ? argv[++i] : arg.slice(flag.length + 1);
if (!value) throw new Error(`${flag} requires a value`);
parsed[field] = value;
continue;
}

throw new Error(`Unknown option: ${arg}`);
}

Expand Down Expand Up @@ -149,6 +177,16 @@ function loadSeedFile(filePath: string): EmulatorSeedConfig {
return parseYaml(content) as EmulatorSeedConfig;
}

/** Read a pinned signing key from disk. Undefined path means "generate one", not an error. */
function readSigningKey(filePath?: string): string | undefined {
if (!filePath) return undefined;
const resolved = resolve(filePath);
if (!existsSync(resolved)) {
throw new Error(`Signing key file not found: ${resolved}`);
}
return readFileSync(resolved, 'utf-8');
}

function autoDetectSeedFile(): EmulatorSeedConfig | undefined {
for (const name of SEED_CANDIDATES) {
const filePath = resolve(name);
Expand Down Expand Up @@ -200,10 +238,17 @@ async function main(): Promise<void> {
}
}

// Flags win over environment, so a compose file can set a default a command can override.
const signingKeyPath = argv.signingKey ?? process.env.WORKOS_EMULATE_SIGNING_KEY;
const kid = argv.kid ?? process.env.WORKOS_EMULATE_KID;
const issuer = argv.issuer ?? process.env.WORKOS_EMULATE_ISSUER;

const emulator = await createEmulator({
port: argv.port,
hostname: argv.host,
seed: seedConfig,
issuer,
signingKey: signingKeyPath || kid ? { privateKey: readSigningKey(signingKeyPath), kid } : undefined,
interactiveAuth: argv.interactive,
});

Expand Down
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export {
type CursorPaginationOptions,
type CursorPaginatedResult,
} from './pagination.js';
export { JWTManager, type JWTPayload } from './jwt.js';
export { JWTManager, type JWTPayload, type SigningKeyOptions } from './jwt.js';
export { createServer, type ServerOptions } from './server.js';
export { type ServicePlugin, type RouteContext } from './plugin.js';
export {
Expand Down
82 changes: 82 additions & 0 deletions src/core/jwt.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, setSystemTime, afterEach } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { JWTManager } from './jwt.js';

describe('JWTManager', () => {
Expand Down Expand Up @@ -87,4 +88,85 @@ describe('JWTManager', () => {
const pem = jwt.getPublicKeyPem();
expect(pem).toContain('-----BEGIN PUBLIC KEY-----');
});

describe('template claims', () => {
it('merges claims into the token', () => {
const token = jwt.sign(
{ sub: 'user_01ABC', aud: 'client_01XYZ' },
{ claims: { 'urn:myapp:tenant': 'tenant_123', 'urn:myapp:seats': 5 } },
);
const payload = jwt.verify(token);
expect(payload['urn:myapp:tenant']).toBe('tenant_123');
expect(payload['urn:myapp:seats']).toBe(5);
});

it('lets a claim override a resolved, non-reserved claim', () => {
const token = jwt.sign({ sub: 'user_01ABC', aud: 'client_01XYZ', role: 'member' }, { claims: { role: 'admin' } });
expect(jwt.verify(token).role).toBe('admin');
});

it('never lets a claim override the token identity', () => {
const token = jwt.sign(
{ sub: 'user_01ABC', aud: 'client_01XYZ' },
{ claims: { sub: 'user_01SPOOFED', iss: 'https://evil.test', exp: 1, iat: 1, jti: 'x', nbf: 1 } },
);
const payload = jwt.verify(token);
expect(payload.sub).toBe('user_01ABC');
expect(payload.iss).toBe('https://api.workos.test');
expect(payload.jti).toBeUndefined();
expect(payload.nbf).toBeUndefined();
expect(payload.exp).toBe(payload.iat + 3600);
});
});

describe('pinned signing key', () => {
// A fresh keypair per run, so the fixture is never a checked-in private key.
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;

it('survives a restart: a new manager verifies the old token', () => {
const before = new JWTManager('https://api.workos.test', { privateKey: pem });
const token = before.sign({ sub: 'user_01ABC', aud: 'client_01XYZ' });

const after = new JWTManager('https://api.workos.test', { privateKey: pem });
expect(after.verify(token).sub).toBe('user_01ABC');
});

it('publishes the same JWKS and kid for the same key', () => {
const a = new JWTManager('https://api.workos.test', { privateKey: pem });
const b = new JWTManager('https://api.workos.test', { privateKey: pem });
expect(a.getJWKS()).toEqual(b.getJWKS());
});

it('derives a different kid for a different key', () => {
const other = new JWTManager('https://api.workos.test');
expect(other.getJWKS().keys[0].kid).not.toBe(
new JWTManager('https://api.workos.test', { privateKey: pem }).getJWKS().keys[0].kid,
);
});

it('honors an explicit kid in the JWKS and the token header', () => {
const pinned = new JWTManager('https://api.workos.test', { privateKey: pem, kid: 'my_kid' });
expect(pinned.getJWKS().keys[0].kid).toBe('my_kid');

const header = JSON.parse(
Buffer.from(pinned.sign({ sub: 'user_01ABC', aud: 'c' }).split('.')[0], 'base64url').toString('utf-8'),
);
expect(header.kid).toBe('my_kid');
});

it('rejects a key that is not a PEM private key', () => {
expect(() => new JWTManager('https://api.workos.test', { privateKey: 'not a key' })).toThrow(
'could not parse as a PEM private key',
);
});

it('rejects a non-RSA key, since tokens are signed RS256', () => {
const ed25519 = generateKeyPairSync('ed25519').privateKey.export({
type: 'pkcs8',
format: 'pem',
}) as string;
expect(() => new JWTManager('https://api.workos.test', { privateKey: ed25519 })).toThrow('expected an RSA key');
});
});
});
Loading