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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ workos-emulate --interactive # serve login pages for E2E browser testin
workos-emulate --interactive-password # ...and ask a user who has a password for it
workos-emulate --signing-key ci-key.pem --issuer https://api.workos.com # stable JWKS and iss
workos-emulate --redirect-hosts app.example.test # allow a non-localhost redirect_uri
workos-emulate --requests-per-minute 10000 # widen the per-key request budget (default 1000)
workos-emulate --version
```

Expand Down Expand Up @@ -205,6 +206,17 @@ emulator.reset();
await emulator.close();
```

The emulator's request budget defaults to 1,000 per API key in each 60-second window. Larger
integration suites that share an API key can set `requestsPerMinute` to a positive safe integer,
for example `createEmulator({ port: 0, requestsPerMinute: 10_000 })`. The full configured budget is
accepted, so a budget of 1 allows one request before HTTP 429 responses with `Retry-After`. The
last accepted request has `X-RateLimit-Remaining: 0`; the budget resets 60 seconds after the first
request in the window. Anonymous requests share a separate budget.

From the CLI, `--requests-per-minute 10000` sets the same budget, and
`WORKOS_EMULATE_REQUESTS_PER_MINUTE=10000` is the environment equivalent, for a compose file or the
Docker image. The flag wins over the environment.

### ⚠️ Important: EventBus Reset Limitation

The `reset()` method clears all data and re-seeds from the original config, but **route-level authentication events will not work after reset**. This is because Hono's router cannot be modified after it's built, so the EventBus cannot be re-registered with the collection hooks.
Expand Down Expand Up @@ -1734,7 +1746,7 @@ The WorkOS Emulator is designed for testing and development environments. When u
### Authentication & Authorization

- **No real authentication**: The emulator uses a simple API key system (`sk_test_default`) that provides no real security. Anyone with access to the emulator can make API calls.
- **No rate limiting**: By default, the emulator has no rate limiting. In production scenarios, implement rate limiting to prevent abuse.
- **Local rate limiting**: The emulator defaults to a 1,000-request budget per API key per minute, configurable through `requestsPerMinute`, `--requests-per-minute` or `WORKOS_EMULATE_REQUESTS_PER_MINUTE`. This in-memory limit is intended for tests, not production abuse protection.
- **Public error hook endpoints**: The `/_emulate/hooks` endpoints require no authentication and can be used by anyone who can reach the server.

### Data Security
Expand Down
81 changes: 81 additions & 0 deletions src/cli.test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Drive the CLI the way a shell or a compose file does: as a subprocess. Nothing in `cli.ts` can
* be imported without starting a server, so a spec that wants the flag parsing, the environment
* fallback or the exit code has to spawn it.
*/

const CLI = new URL('./cli.ts', import.meta.url).pathname;

/** What `--json` prints on startup. */
export interface CliStartup {
url: string;
port: number;
apiKey: string;
health: string;
}

/**
* The inherited environment with every WORKOS_EMULATE_* variable removed, so a developer's shell
* cannot configure the process under test, and update checks off. `env` is applied on top.
*/
function cliEnv(env: Record<string, string>): Record<string, string | undefined> {
const base: Record<string, string | undefined> = { ...process.env, NO_UPDATE_NOTIFIER: '1' };
for (const name of Object.keys(base)) {
if (name.startsWith('WORKOS_EMULATE_')) delete base[name];
}
return { ...base, ...env };
}

function spawnCli(args: string[], env: Record<string, string>) {
return Bun.spawn([process.execPath, CLI, '--port', '0', '--json', ...args], {
env: cliEnv(env),
stdout: 'pipe',
stderr: 'pipe',
});
}

/**
* The startup line only, not the whole stream: a served emulator never closes stdout, so reading
* to EOF would wait for the process the caller is about to make requests against.
*/
async function readStartupLine(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
let buffered = '';
try {
for (;;) {
const { value, done } = await reader.read();
if (value) buffered += new TextDecoder().decode(value);
const line = buffered.split('\n').find((l) => l.startsWith('{'));
if (line && buffered.includes('\n')) return line;
if (done) throw new Error(`CLI printed no startup JSON: ${buffered}`);
}
} finally {
reader.releaseLock();
}
}

/** Start the CLI on a free port, hand its startup details to `body`, and make sure the process is reaped. */
export async function withCli(
args: string[],
env: Record<string, string>,
body: (startup: CliStartup) => Promise<void>,
): Promise<void> {
const proc = spawnCli(args, env);
try {
const line = await readStartupLine(proc.stdout);
await body(JSON.parse(line) as CliStartup);
} finally {
proc.kill();
await proc.exited;
}
}

/** Run the CLI to completion, for an invocation that is expected to exit rather than serve. */
export async function runCli(
args: string[],
env: Record<string, string> = {},
): Promise<{ exitCode: number; stderr: string }> {
const proc = spawnCli(args, env);
const stderr = await Bun.readableStreamToText(proc.stderr);
return { exitCode: await proc.exited, stderr };
}
43 changes: 37 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { validateSeedConfig, formatValidationErrors } from './workos/config-vali

interface CliArgs {
port: number;
requestsPerMinute?: number;
host?: string;
seed?: string;
signingKey?: string;
Expand Down Expand Up @@ -57,6 +58,9 @@ Options:
(always allowed). Use for test environments with production-like
hostnames. Accepts subdomain wildcards ("*.example.test") and "*" to
allow any host. Repeatable.
--requests-per-minute <n>
Requests each API key may make in a 60-second window before the emulator
answers 429 (default: 1000). Raise it for a large suite sharing one key.
--interactive, -i Show login pages for SSO/AuthKit (for E2E browser testing)
--interactive-password
Also ask a user who has a password for it on the AuthKit login page, as
Expand All @@ -67,12 +71,13 @@ Options:
--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
WORKOS_EMULATE_REDIRECT_HOSTS=<hosts> Same as --redirect-hosts
NO_UPDATE_NOTIFIER=1 Disable update checks
WORKOS_EMULATE_DISABLE_UPDATE_CHECK=1 Disable update checks
WORKOS_EMULATE_SIGNING_KEY=<path> Same as --signing-key
WORKOS_EMULATE_KID=<id> Same as --kid
WORKOS_EMULATE_ISSUER=<url> Same as --issuer
WORKOS_EMULATE_REDIRECT_HOSTS=<hosts> Same as --redirect-hosts
WORKOS_EMULATE_REQUESTS_PER_MINUTE=<n> Same as --requests-per-minute
NO_UPDATE_NOTIFIER=1 Disable update checks
WORKOS_EMULATE_DISABLE_UPDATE_CHECK=1 Disable update checks
`);
}

Expand Down Expand Up @@ -135,6 +140,13 @@ function parseArgs(argv: string[]): CliArgs {
continue;
}

if (arg === '--requests-per-minute' || arg.startsWith('--requests-per-minute=')) {
const value = arg === '--requests-per-minute' ? argv[++i] : arg.slice('--requests-per-minute='.length);
if (!value) throw new Error('--requests-per-minute requires a value');
parsed.requestsPerMinute = parseRequestsPerMinute(value, '--requests-per-minute');
continue;
}

if (arg === '--host') {
const value = argv[++i];
if (!value) throw new Error(`${arg} requires a value`);
Expand Down Expand Up @@ -205,6 +217,18 @@ function parsePort(value: string): number {
return port;
}

/**
* The same rule createServer enforces (a positive safe integer), applied here so a bad value fails
* naming the flag or variable it came from rather than an option the shell user never typed.
*/
function parseRequestsPerMinute(value: string, source: string): number {
const requestsPerMinute = Number(value);
if (!Number.isSafeInteger(requestsPerMinute) || requestsPerMinute < 1) {
throw new Error(`Invalid ${source}: ${value} (must be a positive integer)`);
}
return requestsPerMinute;
}

function loadSeedFile(filePath: string): EmulatorSeedConfig {
const resolved = resolve(filePath);
if (!existsSync(resolved)) {
Expand Down Expand Up @@ -284,9 +308,16 @@ async function main(): Promise<void> {
const kid = argv.kid ?? process.env.WORKOS_EMULATE_KID;
const issuer = argv.issuer ?? process.env.WORKOS_EMULATE_ISSUER;
const allowedRedirectHosts = argv.redirectHosts ?? splitHosts(process.env.WORKOS_EMULATE_REDIRECT_HOSTS ?? '');
const requestsPerMinuteEnv = process.env.WORKOS_EMULATE_REQUESTS_PER_MINUTE;
const requestsPerMinute =
argv.requestsPerMinute ??
(requestsPerMinuteEnv
? parseRequestsPerMinute(requestsPerMinuteEnv, 'WORKOS_EMULATE_REQUESTS_PER_MINUTE')
: undefined);

const emulator = await createEmulator({
port: argv.port,
requestsPerMinute,
hostname: argv.host,
seed: seedConfig,
issuer,
Expand Down
17 changes: 12 additions & 5 deletions src/core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { ServicePlugin, RouteContext } from './plugin.js';

export interface ServerOptions {
port?: number;
/** Maximum requests per API key in each 60-second window; defaults to 1,000. */
requestsPerMinute?: number;
/**
* The externally reachable root at which the emulator's routes are served. Every link the
* emulator mints — invitation, password reset, device verification, SSO logout — and the default
Expand All @@ -34,6 +36,10 @@ export interface ServerOptions {

export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) {
const port = options.port ?? 4100;
const requestsPerMinute = options.requestsPerMinute ?? 1000;
if (!Number.isSafeInteger(requestsPerMinute) || requestsPerMinute < 1) {
throw new RangeError('requestsPerMinute must be a positive safe integer');
}
// Every link the emulator mints is `${baseUrl}/<route>`, so a trailing slash on the option would
// double up in all of them (and in the default `iss`, built the same way). A path prefix is kept:
// see ServerOptions.baseUrl for the contract it states.
Expand Down Expand Up @@ -132,17 +138,15 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})

let counter = rateLimitCounters.get(key);
if (!counter || counter.resetAt <= now) {
counter = { remaining: 1000, resetAt: now + 60 };
counter = { remaining: requestsPerMinute, resetAt: now + 60 };
Comment thread
greptile-apps[bot] marked this conversation as resolved.
rateLimitCounters.set(key, counter);
}

counter.remaining = Math.max(0, counter.remaining - 1);

c.header('X-RateLimit-Limit', '1000');
c.header('X-RateLimit-Remaining', String(counter.remaining));
c.header('X-RateLimit-Limit', String(requestsPerMinute));
c.header('X-RateLimit-Reset', String(counter.resetAt));

if (counter.remaining === 0) {
c.header('X-RateLimit-Remaining', '0');
c.header('Retry-After', String(counter.resetAt - now));
return c.json(
{
Expand All @@ -153,6 +157,9 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {})
);
}

counter.remaining -= 1;
c.header('X-RateLimit-Remaining', String(counter.remaining));

await next();
});

Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface EmulatorSeedConfig {

export interface EmulatorOptions {
port?: number;
/** Maximum requests per API key in each 60-second window; defaults to 1,000. */
requestsPerMinute?: number;
/**
* Network interface to bind to. Defaults to `localhost`, which keeps the
* emulator's unauthenticated endpoints reachable only from the local
Expand Down Expand Up @@ -139,6 +141,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise<Emu

const { app, store, jwt, ctx } = createServer(workosPlugin, {
port,
requestsPerMinute: options.requestsPerMinute,
baseUrl,
apiKeys,
issuer: options.issuer,
Expand Down
Loading
Loading