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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ All services start with sensible defaults. No config file needed:
- **Clerk** on `http://localhost:4011`
- **Spotify** on `http://localhost:4012`
- **X** on `http://localhost:4013`
- **WorkOS** on `http://localhost:4014` (AuthKit, organizations, organization domains, Vault, and OAuth)
- **WorkOS** on `http://localhost:4014` (AuthKit, user directory, organizations, organization domains, Vault, and OAuth)
- **Autumn** on `http://localhost:4015`
- **PostHog** on `http://localhost:4016`
- **MCP** on `http://localhost:4017`
Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/docs/workos/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ npx emulate --service workos

The local WorkOS emulator listens on `http://localhost:4014` when all services run together.

## Users

- `GET /user_management/users` — list users, optionally filtering by `email` and `organization_id`
- `GET /user_management/users/:id` — retrieve a user

## Organizations

- `POST /organizations` — create an organization
Expand Down
27 changes: 27 additions & 0 deletions packages/@emulators/workos/src/__tests__/workos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,33 @@ describe("workos emulator with the real @workos-inc/node SDK", () => {
}
});

it("lists users by email and organization through the SDK", async () => {
const code = await signInAndGetCode("Indexed.User@example.com");
const auth = await workos.userManagement.authenticateWithCode({
code,
clientId: CLIENT_ID,
session: { sealSession: true, cookiePassword: COOKIE_PASSWORD },
});
const memberOrg = await workos.organizations.createOrganization({ name: "Indexed User Org" });
const otherOrg = await workos.organizations.createOrganization({ name: "Other Indexed User Org" });
await workos.userManagement.createOrganizationMembership({
organizationId: memberOrg.id,
userId: auth.user.id,
});

const matching = await workos.userManagement.listUsers({
email: "indexed.user@example.com",
organizationId: memberOrg.id,
});
expect(matching.data.map((user) => user.id)).toEqual([auth.user.id]);

const wrongOrganization = await workos.userManagement.listUsers({
email: "indexed.user@example.com",
organizationId: otherOrg.id,
});
expect(wrongOrganization.data).toEqual([]);
});

it("ends the session via the SDK's logout URL and rejects later refreshes", async () => {
const code = await signInAndGetCode("carol@example.com");
const auth = await workos.userManagement.authenticateWithCode({
Expand Down
6 changes: 6 additions & 0 deletions packages/@emulators/workos/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export const manifest: ServiceManifest = {
path: "/user_management/authenticate",
status: "hand-authored",
},
{
operationId: "userManagement.listUsers",
method: "GET",
path: "/user_management/users",
status: "hand-authored",
},
{
operationId: "userManagement.getUser",
method: "GET",
Expand Down
12 changes: 12 additions & 0 deletions packages/@emulators/workos/src/routes/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ function buildSpec(baseUrl: string): Record<string, unknown> {
},
},
},
"/user_management/users": {
get: {
operationId: "userManagement.listUsers",
tags: ["user-management"],
summary: "List users",
parameters: [
query("email", "Filter by email address."),
query("organization_id", "Filter by organization membership."),
],
responses: { "200": ok("User list.") },
},
},
"/user_management/users/{id}": {
get: {
operationId: "userManagement.getUser",
Expand Down
19 changes: 19 additions & 0 deletions packages/@emulators/workos/src/routes/user-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,25 @@ export function userManagementRoutes(ctx: RouteContext): void {
});

// --- Users ----------------------------------------------------------------
app.get("/user_management/users", (c) => {
const email = c.req.query("email");
const organizationId = c.req.query("organization_id");
let users = ws().users.all();
if (email) {
const normalizedEmail = email.toLowerCase();
users = users.filter((user) => user.email.toLowerCase() === normalizedEmail);
}
if (organizationId) {
const memberIds = new Set(
ws()
.memberships.findBy("organization_id", organizationId)
.map((membership) => membership.user_id),
);
users = users.filter((user) => memberIds.has(user.workos_id));
}
return c.json(listEnvelope(users.map(serializeUser)));
});

app.get("/user_management/users/:id", (c) => {
const user = ws().users.findOneBy("workos_id", c.req.param("id"));
if (!user) return workosError(c, 404, "entity_not_found", "User not found.");
Expand Down
5 changes: 3 additions & 2 deletions packages/emulate/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ Control plane (under /_emulate on each service):
GET /_emulate/connections copyable SDK, CLI, env, and curl snippets
GET /_emulate/openapi OpenAPI document (when supported)
Stripe also serves its OpenAPI subset at /openapi.json and /openapi.yaml.
WorkOS domains support POST /organization_domains/:id/verify and the
test control route POST /_emulate/organization_domains/:id/verify.
WorkOS supports filtered GET /user_management/users queries. Its domains
support POST /organization_domains/:id/verify and the test control route
POST /_emulate/organization_domains/:id/verify.
GET /_emulate/graphql GraphQL surface (when supported)
GET /_emulate/mcp MCP surface (when supported)
GET /_emulate/state current emulator state
Expand Down
1 change: 1 addition & 0 deletions skills/workos/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Sealed sessions are sealed and unsealed locally by the SDK with your cookie pass

## Other surfaces

- User directory: `GET /user_management/users` lists users and supports combined `email` and `organization_id` filters; `GET /user_management/users/:id` retrieves one user.
- OAuth authorization server for MCP clients: `/.well-known/oauth-authorization-server`, `/oauth2/register`, `/oauth2/authorize`, `/oauth2/token`, `/oauth2/jwks`. Set `EMULATE_WORKOS_AUDIENCE` to control the `aud` claim resource servers verify. AuthKit-faithful scope handling: the token grant carries exactly the scopes the client requested at `/oauth2/authorize`, a refresh token is issued only when `offline_access` is among them, and refresh tokens are single use (rotated on every redemption). Register with the emulate-only DCR field `access_token_ttl_seconds` to compress access-token expiry for lifecycle tests, or seed `{ "oauth": { "default_access_token_ttl_seconds": 15 } }` to compress it for every plain-DCR client (real MCP clients that cannot carry the extension); seed `null` to restore the default 3600.
- Organization domains: `POST /organization_domains`, `GET /organization_domains/:id`, `POST /organization_domains/:id/verify`, `DELETE /organization_domains/:id`. Domains begin pending with DNS verification fields. The provider verification route and `POST /_emulate/organization_domains/:id/verify` both mark one verified in a test.
- Vault KV: `POST /vault/v1/kv`, `GET /vault/v1/kv/name/:name`, `PUT /vault/v1/kv/:id`, `DELETE /vault/v1/kv/:id`.
Expand Down