Skip to content
Open
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
26 changes: 26 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Lint

on:
push:
branches: [main]
pull_request:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: npm

- name: Install
run: npm ci

- name: Lint
run: npm run lint

- name: Format check
run: npm run fmt:check
6 changes: 6 additions & 0 deletions .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"printWidth": 120,
"sortPackageJson": false,
"ignorePatterns": ["package-lock.json", "CHANGELOG.md"]
}
11 changes: 11 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error"
},
"rules": {},
"env": {
"builtin": true
}
}
13 changes: 7 additions & 6 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
"oxc.fmt.configPath": ".oxfmtrc.json",
"[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
}
}
100 changes: 82 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,12 @@ The same pattern works for any language with a WorkOS SDK (Ruby, Go, Java, etc.)
## Programmatic API (Node.js)

```ts
import { createEmulator } from "@workos/emulate";
import { createEmulator } from '@workos/emulate';

const emulator = await createEmulator({
port: 0,
seed: {
users: [{ email: "test@example.com", password: "secret" }],
users: [{ email: 'test@example.com', password: 'secret' }],
},
});

Expand Down Expand Up @@ -137,6 +137,72 @@ permissions:
name: Write Posts
```

## Testing Your Login Flow End-to-End

The emulator implements the full [workos.com/docs](https://workos.com/docs) login story: every resource creation and authentication outcome fires a signed webhook, with event names and payload shapes generated from the WorkOS OpenAPI spec. You can run your app's entire login flow — hosted authorize, callback, token exchange, webhook handling — against the emulator without touching the real API.

### 1. Register a webhook endpoint

Seed it (an empty `events` list subscribes to everything):

```yaml
webhookEndpoints:
- endpoint_url: http://localhost:5005/webhooks
events: []
```

Or register at runtime and choose your own signing secret:

```bash
curl -X POST http://localhost:4100/webhook_endpoints \
-H "Authorization: Bearer sk_test_default" \
-H "Content-Type: application/json" \
-d '{"endpoint_url":"http://localhost:5005/webhooks","secret":"whsec_test","events":[]}'
```

### 2. Walk the login flow

Point your SDK's base URL at the emulator and follow the AuthKit quickstart exactly as documented:

1. **Create a user** — `POST /user_management/users` → a `user.created` webhook arrives.
2. **Redirect to AuthKit** — send the browser to `GET /user_management/authorize?redirect_uri=...&state=...`. By default the emulator immediately redirects back to your callback with a `code`; with `--interactive` it serves a real login page first.
3. **Exchange the code** — your callback calls `POST /user_management/authenticate` with `grant_type=authorization_code`. You get back the user, `access_token`, and `refresh_token` — and `session.created` plus `authentication.oauth_succeeded` webhooks arrive.
4. **Other methods work the same way** — password, Magic Auth, email verification, MFA, and SSO logins all emit their spec-named `authentication.*_succeeded` events; failed attempts emit `authentication.*_failed` with an `error: { code, message }` object.

Codes that WorkOS would deliver by email are delivered to you in the webhook payload instead: `magic_auth.created` carries the Magic Auth `code`, `password_reset.created` carries the reset `token`, and `email_verification.created` carries the verification `code`. Your test can drive the whole flow from webhooks alone — see `src/e2e.spec.ts` for a complete worked example.

### 3. Verify signatures

Webhooks are signed exactly like production WorkOS: `WorkOS-Signature: t=<timestamp>,v1=<hmac>` where the HMAC-SHA256 is computed over `"{timestamp}.{body}"` with the endpoint's secret. The official SDKs' `webhooks.constructEvent` verifies them unchanged.

### Emitted events

Authentication events carry the spec payload `{ type, status, user_id, email, ip_address, user_agent }` (plus `error` on failures and `sso` details on SSO events).

| Trigger | Events |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Login success (per method) | `authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_succeeded` |
| Login failure (bad/expired credential) | `authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_failed` |
| Sessions | `session.created`, `session.revoked` |
| Users | `user.created`, `user.updated`, `user.deleted` |
| Login-flow resources | `magic_auth.created`, `email_verification.created`, `password_reset.created`, `password_reset.succeeded` |
| Organizations & domains | `organization.*`, `organization_domain.*` (incl. `organization_domain.verified`) |
| Memberships & invitations | `organization_membership.*`, `invitation.{created,accepted,revoked,resent}` |
| Connections | `connection.activated`, `connection.deactivated`, `connection.deleted` |
| Directory Sync | `dsync.activated`, `dsync.deleted`, `dsync.user.*`, `dsync.group.*` |
| Roles & permissions | `role.*`, `organization_role.*`, `permission.*` |
| API keys & feature flags | `api_key.{created,updated,revoked}`, `flag.{created,updated,deleted}` |

The full catalog (including names the emulator never emits, like `authentication.passkey_*` and `vault.*`) lives in `src/workos/generated/events.ts`, generated from the [`@workos/openapi-spec`](https://www.npmjs.com/package/@workos/openapi-spec) package.

All events are also queryable at `GET /events` (filter with `?events[]=user.created`).

### Caveats

- Delivery is fire-and-forget with a 5-second timeout and no retries — poll your receiver in tests rather than asserting immediately.
- 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.

## 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 All @@ -151,19 +217,19 @@ errorHooks:
path: /user_management/users
status: 422
body:
message: "Validation failed"
code: "unprocessable_entity"
message: 'Validation failed'
code: 'unprocessable_entity'
errors:
- field: email
code: invalid
message: "must be a valid email"
message: 'must be a valid email'

- method: GET
path: /user_management/users
status: 500

# Fail the first 3 requests, then let them through
- method: "*"
- method: '*'
path: /organizations
status: 503
count: 3
Expand Down Expand Up @@ -201,10 +267,10 @@ const emulator = await createEmulator({ port: 0 });

// Make user creation return a 422
const hook = emulator.addErrorHook({
method: "POST",
path: "/user_management/users",
method: 'POST',
path: '/user_management/users',
status: 422,
body: { message: "Email is invalid", code: "unprocessable_entity" },
body: { message: 'Email is invalid', code: 'unprocessable_entity' },
});

// Your app code under test handles the error...
Expand Down Expand Up @@ -233,11 +299,9 @@ workos-emulate --interactive --seed workos-emulate.config.yaml
const emulator = await createEmulator({
interactiveAuth: true,
seed: {
users: [{ email: "test@example.com", password: "secret" }],
connections: [
{ name: "Test SSO", organization: "Acme", domains: ["example.com"] },
],
organizations: [{ name: "Acme" }],
users: [{ email: 'test@example.com', password: 'secret' }],
connections: [{ name: 'Test SSO', organization: 'Acme', domains: ['example.com'] }],
organizations: [{ name: 'Acme' }],
},
});
```
Expand All @@ -261,12 +325,12 @@ The `login_hint` parameter pre-fills the email field, so agent browsers can skip
### E2E example with Playwright

```ts
test("SSO login flow", async ({ page }) => {
await page.goto("http://localhost:3000/login");
await page.click("text=Sign in with SSO");
test('SSO login flow', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.click('text=Sign in with SSO');

// Emulator serves the login page
await page.fill('input[name="email"]', "alice@example.com");
await page.fill('input[name="email"]', 'alice@example.com');
await page.click('button[type="submit"]');

// Redirected back to your app with a valid session
Expand Down
Loading