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
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ 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 --redirect-hosts app.example.test # allow a non-localhost redirect_uri
workos-emulate --version
```

Expand Down Expand Up @@ -679,6 +680,66 @@ 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.

## Redirect URI Hosts

The authorize endpoints refuse to redirect anywhere but `localhost`, `127.0.0.1` and `[::1]`, so a
reachable emulator cannot be turned into an open redirect. If your test environment fakes
production-like hostnames, list them:

```bash
workos-emulate --redirect-hosts app.example.test,auth.example.test

# Repeatable, and each occurrence may be a comma-separated list
workos-emulate --redirect-hosts app.example.test --redirect-hosts auth.example.test

# Any subdomain of example.test (the apex itself is not matched)
workos-emulate --redirect-hosts '*.example.test'

# Any host at all — the check is off
workos-emulate --redirect-hosts '*'
```

`WORKOS_EMULATE_REDIRECT_HOSTS=app.example.test,*.internal.test` is the environment equivalent, for
a compose file. The flag wins over the environment.

Programmatically:

```ts
const emulator = await createEmulator({
allowedRedirectHosts: ['app.example.test', '*.internal.test'],
});
```

Notes:

- Configured hosts **add to** the localhost set rather than replacing it, so existing callbacks keep
working.
- An entry is a hostname (`app.example.test`), a subdomain wildcard (`*.example.test`), or `*`. A
whole origin (`https://app.example.test:8443`) is accepted and reduced to its hostname — ports and
schemes are never part of the host check.
- The check applies to `redirect_uri` on `/user_management/authorize`, `/sso/authorize` and
`/data-integrations/:slug/authorize`, and to `return_to` on `/user_management/sessions/logout`.
- An internationalized hostname may be written either way: `møller.test` and its punycode
(`xn--mller-vua.test`) normalize to the same entry, since that is the form a request carries.
- An IP address may be written any legal way. `[FD00::0001]` and `[fd00:0:0:0:0:0:0:1]` are the same
entry as `[fd00::1]`; `10.1`, `192.168.001.1` and `2130706433` are the same entries as `10.0.0.1`,
`192.168.1.1` and `127.0.0.1`. A trailing dot is optional too (`app.example.test.` matches
`app.example.test`). Both sides are reduced to the one form a request carries.
- An underscore is fine (`my_host.example.test`, the shape a Docker Compose service name takes).
It is not DNS-conformant, but a request really does arrive carrying it.
- A host that could never match (`https://`, anything with whitespace) fails at startup rather than
silently rejecting every request. So does an entry that would only reduce to `*` by having
something stripped off it (`*:3000`, `https://*`) — `*` means every host, and it may only be
spelled that way rather than arrived at by accident.
- A `redirect_uri` carrying an unencoded control character is a 400, since URL parsing strips those
rather than failing on them: `http://local<TAB>host/` would otherwise validate as localhost and
reach the `Location` header raw.
- `javascript:`, `data:`, `vbscript:`, `blob:` and `file:` redirect URIs are always refused, `*`
included — `javascript://localhost/…` parses with an allowed hostname it never navigates to. So
is any URI with no authority at all (`view-source:javascript:…`, `jar:`, `about:blank`), which
the host check has nothing to say about and `*` would otherwise wave through. Custom app schemes
(`myapp://callback`, for native clients) keep their host and are allowed if that host is.

## 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 Expand Up @@ -997,6 +1058,7 @@ The WorkOS Emulator is designed for testing and development environments. When u
### Network Security

- **Bind to localhost**: By default, the emulator binds to `localhost`, so its unauthenticated endpoints are only reachable from the local machine. To intentionally expose it to other hosts, pass `--host 0.0.0.0` (CLI) or `hostname: '0.0.0.0'` (`createEmulator`), and protect it with a firewall or VPN.
- **Open redirect protection**: The authorize endpoints only redirect to localhost by default. `--redirect-hosts` (or `allowedRedirectHosts`) widens that for test environments with production-like hostnames; `--redirect-hosts '*'` disables the host check entirely, so only use it on an emulator nothing untrusted can reach. Script-bearing schemes (`javascript:`, `data:`) and URIs with no authority for the host check to speak about (`view-source:javascript:…`, `about:`) are refused regardless. See [Redirect URI Hosts](#redirect-uri-hosts).
- **No CORS restrictions**: The emulator doesn't enforce CORS. Configure CORS in your application if needed.
- **No TLS/SSL**: The emulator doesn't provide HTTPS. Use a reverse proxy (nginx, Caddy) for TLS termination in production.

Expand Down
30 changes: 30 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface CliArgs {
signingKey?: string;
kid?: string;
issuer?: string;
redirectHosts?: string[];
json: boolean;
help: boolean;
version: boolean;
Expand Down Expand Up @@ -48,6 +49,11 @@ Options:
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)
--redirect-hosts <hosts>
Comma-separated hosts a redirect_uri may point at, on top of localhost
(always allowed). Use for test environments with production-like
hostnames. Accepts subdomain wildcards ("*.example.test") and "*" to
allow any host. Repeatable.
--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
Expand All @@ -58,6 +64,7 @@ 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
`);
Expand Down Expand Up @@ -129,6 +136,20 @@ function parseArgs(argv: string[]): CliArgs {
continue;
}

if (arg === '--redirect-hosts' || arg.startsWith('--redirect-hosts=')) {
const value = arg === '--redirect-hosts' ? argv[++i] : arg.slice('--redirect-hosts='.length);
if (!value) throw new Error('--redirect-hosts requires a value');
// Repeatable, and each occurrence may itself be a comma-separated list.
const hosts = splitHosts(value);
// A value that contributes no entries (',' or ' ') would leave an empty array, which is
// not nullish — so it would also discard WORKOS_EMULATE_REDIRECT_HOSTS on the way past.
// Two silent no-ops for the price of one, from a flag that was clearly meant to configure
// something.
if (hosts.length === 0) throw new Error('--redirect-hosts requires at least one host');
parsed.redirectHosts = [...(parsed.redirectHosts ?? []), ...hosts];
continue;
}

if (arg === '--seed' || arg === '-s') {
const value = argv[++i];
if (!value) throw new Error(`${arg} requires a value`);
Expand Down Expand Up @@ -156,6 +177,13 @@ function parseArgs(argv: string[]): CliArgs {
return parsed;
}

function splitHosts(value: string): string[] {
return value
.split(',')
.map((host) => host.trim())
.filter((host) => host !== '');
}

function parsePort(value: string): number {
const port = Number(value);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
Expand Down Expand Up @@ -242,13 +270,15 @@ async function main(): Promise<void> {
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 allowedRedirectHosts = argv.redirectHosts ?? splitHosts(process.env.WORKOS_EMULATE_REDIRECT_HOSTS ?? '');

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

Expand Down
32 changes: 22 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from './core/index.js';
import { workosPlugin, seedFromConfig, type WorkOSSeedConfig } from './workos/index.js';
import { STORE_KEYS } from './workos/constants.js';
import { normalizeRedirectHosts } from './workos/helpers.js';
import { serve } from '@hono/node-server';
import { parseJsonBody } from './core/index.js';

Expand Down Expand Up @@ -64,6 +65,14 @@ export interface EmulatorOptions {
* or to pre-sign tokens offline with the same key the emulator verifies.
*/
signingKey?: SigningKeyOptions;
/**
* Extra hosts a `redirect_uri` (or a session logout `return_to`) may point at. The emulator
* refuses to redirect anywhere else so it cannot be used as an open redirect; `localhost`,
* `127.0.0.1` and `[::1]` are always allowed. Add the production-like hostnames your test
* environment fakes — `['app.example.test']` — or a subdomain wildcard
* (`['*.example.test']`). `['*']` allows any host, which turns the check off entirely.
*/
allowedRedirectHosts?: string[];
interactiveAuth?: boolean;
webhookRetryConfig?: {
maxRetries?: number;
Expand Down Expand Up @@ -115,17 +124,19 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise<Emu
signingKey: options.signingKey,
});

if (options.interactiveAuth) {
store.setData(STORE_KEYS.interactiveAuth, true);
}
// Normalized here so a malformed host fails at startup instead of never matching a request.
const allowedRedirectHosts = normalizeRedirectHosts(options.allowedRedirectHosts ?? []);

if (options.webhookRetryConfig) {
store.setData('webhookRetryConfig', options.webhookRetryConfig);
}

if (options.webhookDebugMode) {
store.setData('webhookDebugMode', true);
}
// store.reset() drops every data entry, so anything set from `options` has to be re-applied
// from reset() as well. Kept in one place because the failure is silent otherwise: an option
// set here and not restored there simply stops taking effect after the first reset.
const applyOptionData = () => {
if (options.interactiveAuth) store.setData(STORE_KEYS.interactiveAuth, true);
if (allowedRedirectHosts.length > 0) store.setData(STORE_KEYS.allowedRedirectHosts, allowedRedirectHosts);
if (options.webhookRetryConfig) store.setData('webhookRetryConfig', options.webhookRetryConfig);
if (options.webhookDebugMode) store.setData('webhookDebugMode', true);
};
applyOptionData();

// Health check endpoint
app.get('/health', (c) => c.json({ status: 'ok' }));
Expand Down Expand Up @@ -225,6 +236,7 @@ export async function createEmulator(options: EmulatorOptions = {}): Promise<Emu
for (const key of Object.keys(apiKeys)) delete apiKeys[key];
Object.assign(apiKeys, initialApiKeys);
store.setData(STORE_KEYS.apiKeyMap, apiKeys);
applyOptionData();
seedFn();
// Note: EventBus is not re-registered after reset because Hono's router
// cannot be modified after it's built. Route-level authentication events
Expand Down
1 change: 1 addition & 0 deletions src/workos/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const STORE_KEYS = {
apiKeyMap: 'apiKeyMap',
jwtTemplate: 'jwt_template',
interactiveAuth: 'interactiveAuth',
allowedRedirectHosts: 'allowedRedirectHosts',
} as const;

/** Prefix for dynamic store keys */
Expand Down
Loading
Loading