diff --git a/README.md b/README.md index 4e064eae..f496d6bf 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/apps/web/app/docs/workos/page.mdx b/apps/web/app/docs/workos/page.mdx index 3552dc9e..101ea857 100644 --- a/apps/web/app/docs/workos/page.mdx +++ b/apps/web/app/docs/workos/page.mdx @@ -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 diff --git a/packages/@emulators/workos/src/__tests__/workos.test.ts b/packages/@emulators/workos/src/__tests__/workos.test.ts index 8d4e5863..d90922b3 100644 --- a/packages/@emulators/workos/src/__tests__/workos.test.ts +++ b/packages/@emulators/workos/src/__tests__/workos.test.ts @@ -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({ diff --git a/packages/@emulators/workos/src/manifest.ts b/packages/@emulators/workos/src/manifest.ts index 14d6e40c..946c1e3f 100644 --- a/packages/@emulators/workos/src/manifest.ts +++ b/packages/@emulators/workos/src/manifest.ts @@ -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", diff --git a/packages/@emulators/workos/src/routes/openapi.ts b/packages/@emulators/workos/src/routes/openapi.ts index f27c9a22..fd912423 100644 --- a/packages/@emulators/workos/src/routes/openapi.ts +++ b/packages/@emulators/workos/src/routes/openapi.ts @@ -77,6 +77,18 @@ function buildSpec(baseUrl: string): Record { }, }, }, + "/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", diff --git a/packages/@emulators/workos/src/routes/user-management.ts b/packages/@emulators/workos/src/routes/user-management.ts index 23d6eb81..16290bd3 100644 --- a/packages/@emulators/workos/src/routes/user-management.ts +++ b/packages/@emulators/workos/src/routes/user-management.ts @@ -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."); diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index eb7c84e5..f97c8b69 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -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 diff --git a/skills/workos/SKILL.md b/skills/workos/SKILL.md index 55772a6a..da5a6f75 100644 --- a/skills/workos/SKILL.md +++ b/skills/workos/SKILL.md @@ -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`.