diff --git a/README.md b/README.md index e2de5a5..32e5ced 100644 --- a/README.md +++ b/README.md @@ -1103,13 +1103,13 @@ Authentication events carry the spec payload `{ type, status, user_id, email, ip 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`). +All events are also queryable at `GET /events` (filter with `?events[]=user.created`, or repeated `?events=`, which is what the Go SDK sends). ### 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. +- Seeding a directory user into a group emits `dsync.group.user_added`. Removing that membership emits `dsync.group.user_removed`. There is still no HTTP route to mutate a directory; production connects one in the dashboard. ## JWT Templates (custom claims) diff --git a/SUPPORTED.md b/SUPPORTED.md index 5375ab3..7cf5f34 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -17,34 +17,34 @@ answers "can I actually emulate this?". ✅ full · ⚠️ partial · ❌ none · — not applicable -| Feature | Read | Write | Set up | Notes | -| ------------------------ | -------- | -------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Organizations | ⚠️ 5/6 | ⚠️ 6/10 | ✅ seed `organizations` | IT contact endpoints are not implemented. | -| User Management | ⚠️ 8/11 | ⚠️ 7/13 | ✅ seed `users` | Email-change confirm/send and waitlist endpoints are not implemented. | -| Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. | -| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. | -| Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. | -| Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | | -| SSO | ⚠️ 5/8 | ⚠️ 4/11 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | -| Directory Sync | ✅ 6/6 | ✅ 1/1 | ✅ seed `directories` | Read-only over HTTP, as production is: a directory is connected in the dashboard, so there is no POST route to emulate. Seed `directories` to get one, with its groups, its users, and group-to-role mappings resolved onto the organization membership (which then reports `directory_managed`); seeding emits `dsync.activated` (for a `linked` directory), `dsync.group.created` and `dsync.user.created` (queryable at `GET /events`, not delivered — seeded endpoints register after), and `DELETE /directories/:id` emits a delivered `dsync.deleted`. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface. | -| Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. | -| FGA / Authorization | ⚠️ 15/19 | ⚠️ 18/26 | ✅ seed `roles`, `permissions` | Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. Resource types are not modeled: any `resource_type_slug` is accepted on roles and permissions, and permission scopes are not checked against the role scope. Every organization owns an implicit `organization` resource whose `external_id` is the organization `external_id`, falling back to the organization id; resources created without parent fields attach to it, and it cannot be created, updated, or deleted directly. | -| Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | -| Vault | ✅ 5/5 | ⚠️ 3/6 | ⚠️ API only | Object CRUD is implemented; data-key encryption endpoints are not. | -| Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. | -| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | -| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connected-account CRUD and access-token retrieval are supported; a refresh mints a local `di_mock_` token rather than contacting the provider. The older `/pipes/connections` routes remain emulator-specific. | -| Applications | ✅ 5/5 | ✅ 8/8 | ✅ seed `connectApplications` | A redirect URI is stored as a bare string, so `default` is accepted on create and update but always reported as `false`. `uses_pkce` and `is_first_party` are stored and reported, but nothing is registered dynamically, so `was_dynamically_registered` is always `false` and the list route's `registration_types` filter only ever matches `authenticated`. `POST /client/token` mints a signed, short-lived token, but the spec documents only the `{ token }` envelope — the claims inside are an emulator convention, and no Client GraphQL API is served for it to authenticate against. | -| JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | -| Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. | -| Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. | -| AuthKit Configuration | ❌ 0/2 | ⚠️ 2/3 | ⚠️ API only | Redirect URIs are accepted but not enforced against authorize requests. | -| Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | -| Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. | -| Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | -| Agent Auth | ✅ 6/6 | ✅ 7/7 | ✅ seed `agentBlueprints` | Blueprint CRUD, all four token grants (`user_delegated`, `autonomous`, `agent_delegated`, `refresh`), token validation, and instance and session listing, lookup, deletion and revocation. Agent access tokens are signed with the emulator key (`typ: at+jwt`, `sub_profile: ai_agent`) so JWKS validation works; delegated permissions are recomputed from the member’s current role at every mint and refresh. Agent tokens carry the `workos-emulate` placeholder audience, since nothing at the API-key-authenticated token endpoint names a client. | -| Agent Registration | ❌ 0/1 | ❌ 0/2 | ❌ none | Not implemented. | -| Platform Teams | ❌ 0/1 | ❌ 0/1 | ❌ none | Not implemented. | +| Feature | Read | Write | Set up | Notes | +| ------------------------ | -------- | -------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Organizations | ⚠️ 5/6 | ⚠️ 6/10 | ✅ seed `organizations` | IT contact endpoints are not implemented. | +| User Management | ⚠️ 8/11 | ⚠️ 7/13 | ✅ seed `users` | Email-change confirm/send and waitlist endpoints are not implemented. | +| Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. | +| Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. | +| Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. | +| Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | | +| SSO | ⚠️ 5/8 | ⚠️ 4/11 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | +| Directory Sync | ✅ 6/6 | ✅ 1/1 | ✅ seed `directories` | Read-only over HTTP, as production is: a directory is connected in the dashboard, so there is no POST route to emulate. Seed `directories` to get one, with its groups, its users, and group-to-role mappings resolved onto the organization membership (which then reports `directory_managed`); seeding emits `dsync.activated` (for a `linked` directory), `dsync.group.created`, `dsync.user.created`, and `dsync.group.user_added` for each seeded membership (queryable at `GET /events`, not delivered — seeded endpoints register after), and `DELETE /directories/:id` emits a delivered `dsync.deleted`. Removing a user from a group emits `dsync.group.user_removed`. | +| Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. | +| FGA / Authorization | ⚠️ 15/19 | ⚠️ 18/26 | ✅ seed `roles`, `permissions` | Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. Resource types are not modeled: any `resource_type_slug` is accepted on roles and permissions, and permission scopes are not checked against the role scope. Every organization owns an implicit `organization` resource whose `external_id` is the organization `external_id`, falling back to the organization id; resources created without parent fields attach to it, and it cannot be created, updated, or deleted directly. | +| Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | +| Vault | ✅ 5/5 | ⚠️ 3/6 | ⚠️ API only | Object CRUD is implemented; data-key encryption endpoints are not. | +| Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. | +| API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | +| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connected-account CRUD and access-token retrieval are supported; a refresh mints a local `di_mock_` token rather than contacting the provider. The older `/pipes/connections` routes remain emulator-specific. | +| Applications | ✅ 5/5 | ✅ 8/8 | ✅ seed `connectApplications` | A redirect URI is stored as a bare string, so `default` is accepted on create and update but always reported as `false`. `uses_pkce` and `is_first_party` are stored and reported, but nothing is registered dynamically, so `was_dynamically_registered` is always `false` and the list route's `registration_types` filter only ever matches `authenticated`. `POST /client/token` mints a signed, short-lived token, but the spec documents only the `{ token }` envelope — the claims inside are an emulator convention, and no Client GraphQL API is served for it to authenticate against. | +| JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | +| Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. | +| Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. | +| AuthKit Configuration | ❌ 0/2 | ⚠️ 2/3 | ⚠️ API only | Redirect URIs are accepted but not enforced against authorize requests. | +| Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | +| Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. | +| Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | +| Agent Auth | ✅ 6/6 | ✅ 7/7 | ✅ seed `agentBlueprints` | Blueprint CRUD, all four token grants (`user_delegated`, `autonomous`, `agent_delegated`, `refresh`), token validation, and instance and session listing, lookup, deletion and revocation. Agent access tokens are signed with the emulator key (`typ: at+jwt`, `sub_profile: ai_agent`) so JWKS validation works; delegated permissions are recomputed from the member’s current role at every mint and refresh. Agent tokens carry the `workos-emulate` placeholder audience, since nothing at the API-key-authenticated token endpoint names a client. | +| Agent Registration | ❌ 0/1 | ❌ 0/2 | ❌ none | Not implemented. | +| Platform Teams | ❌ 0/1 | ❌ 0/1 | ❌ none | Not implemented. | ## How this file is generated diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index 24c92ad..1194584 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -145,7 +145,7 @@ export const FEATURES: FeatureDef[] = [ tags: ['directories', 'directory-users', 'directory-groups'], seedKeys: ['directories'], notes: - 'Read-only over HTTP, as production is: a directory is connected in the dashboard, so there is no POST route to emulate. Seed `directories` to get one, with its groups, its users, and group-to-role mappings resolved onto the organization membership (which then reports `directory_managed`); seeding emits `dsync.activated` (for a `linked` directory), `dsync.group.created` and `dsync.user.created` (queryable at `GET /events`, not delivered — seeded endpoints register after), and `DELETE /directories/:id` emits a delivered `dsync.deleted`. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface.', + 'Read-only over HTTP, as production is: a directory is connected in the dashboard, so there is no POST route to emulate. Seed `directories` to get one, with its groups, its users, and group-to-role mappings resolved onto the organization membership (which then reports `directory_managed`); seeding emits `dsync.activated` (for a `linked` directory), `dsync.group.created`, `dsync.user.created`, and `dsync.group.user_added` for each seeded membership (queryable at `GET /events`, not delivered — seeded endpoints register after), and `DELETE /directories/:id` emits a delivered `dsync.deleted`. Removing a user from a group emits `dsync.group.user_removed`.', }, { name: 'Multi-Factor Auth', diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 23c7837..512d111 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -573,6 +573,14 @@ export interface WorkOSEvent extends Entity { object: 'event'; event: string; data: Record; + /** + * The organization the event occurred within, which is what the list endpoint's + * `organization_id` filter selects on. Internal — the spec's Event has no such field — and + * kept apart from `data`, which cannot stand in for it: `group.member_added` carries only + * ids, and `organization.created` carries the organization itself. Resolved when the event + * is recorded, so it outlives the rows it refers to. + */ + organization_id: string | null; environment_id: string | null; /** * The spec's per-event `context` envelope. Only flag events populate it so far — the diff --git a/src/workos/event-bus.ts b/src/workos/event-bus.ts index 05954d8..9f5f4b0 100644 --- a/src/workos/event-bus.ts +++ b/src/workos/event-bus.ts @@ -7,6 +7,8 @@ import type { WorkOSEventName } from './constants.js'; export interface EventPayload { event: WorkOSEventName | string; data: Record; + /** The organization the event occurred within, for payloads whose `data.organization_id` does not say. */ + organization_id?: string | null; environment_id?: string; /** Spec `context` envelope, delivered alongside `data` to webhook endpoints. */ context?: Record; @@ -67,11 +69,13 @@ export class EventBus { emit(payload: EventPayload): void { const ws = getWorkOSStore(this.store); + const dataOrganizationId = payload.data.organization_id; const event = ws.events.insert({ object: 'event', event: payload.event, data: payload.data, + organization_id: payload.organization_id ?? (typeof dataOrganizationId === 'string' ? dataOrganizationId : null), environment_id: payload.environment_id ?? null, ...(payload.context ? { context: payload.context } : {}), }); diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 7668355..2136c09 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -1296,7 +1296,7 @@ export function formatApiKeyRecord(k: WorkOSApiKey): Record { }; } -const EVENT_EXCLUDE = new Set([...INTERNAL_FIELDS, 'updated_at']); +const EVENT_EXCLUDE = new Set([...INTERNAL_FIELDS, 'updated_at', 'organization_id']); export function formatEvent(e: WorkOSEvent): Record { return formatEntity(e, { exclude: EVENT_EXCLUDE }); diff --git a/src/workos/index.ts b/src/workos/index.ts index 604c572..45262b4 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -86,6 +86,8 @@ import { import type { WorkOSConnectionType, WorkOSDirectoryGroup, + WorkOSDirectoryUser, + WorkOSOrganization, WorkOSOrganizationMembership, PipeProvider, PipeConnectionStatus, @@ -1126,10 +1128,14 @@ export const workosPlugin: ServicePlugin = { onUpdate: (u) => eventBus.emit({ event: EVENTS.userUpdated, data: formatUser(u) }), onDelete: (u) => eventBus.emit({ event: EVENTS.userDeleted, data: formatUser(u) }), }); + // The organization's own events occur within it: `data` is the organization, so its id is + // the scope, not a `data.organization_id`. + const organizationEvent = (event: string) => (o: WorkOSOrganization) => + eventBus.emit({ event, data: formatOrganization(o, ws), organization_id: o.id }); ws.organizations.setHooks({ - onInsert: (o) => eventBus.emit({ event: EVENTS.organizationCreated, data: formatOrganization(o, ws) }), - onUpdate: (o) => eventBus.emit({ event: EVENTS.organizationUpdated, data: formatOrganization(o, ws) }), - onDelete: (o) => eventBus.emit({ event: EVENTS.organizationDeleted, data: formatOrganization(o, ws) }), + onInsert: organizationEvent(EVENTS.organizationCreated), + onUpdate: organizationEvent(EVENTS.organizationUpdated), + onDelete: organizationEvent(EVENTS.organizationDeleted), }); ws.organizationDomains.setHooks({ onInsert: (d) => eventBus.emit({ event: EVENTS.organizationDomainCreated, data: formatDomain(d) }), @@ -1147,7 +1153,8 @@ export const workosPlugin: ServicePlugin = { }); // AuthKit groups. `group.created`/`updated`/`deleted` carry the full Group object the // spec's event data requires; `group.member_added`/`member_removed` carry only the two - // ids. Hook-driven (not inline in the routes) so seeded groups fire the same events. + // ids, so the group's organization is recorded alongside for the events filter to scope + // on. Hook-driven (not inline in the routes) so seeded groups fire the same events. ws.groups.setHooks({ onInsert: (g) => eventBus.emit({ event: EVENTS.groupCreated, data: formatGroup(g) }), onUpdate: (g) => eventBus.emit({ event: EVENTS.groupUpdated, data: formatGroup(g) }), @@ -1158,11 +1165,13 @@ export const workosPlugin: ServicePlugin = { eventBus.emit({ event: EVENTS.groupMemberAdded, data: { group_id: gm.group_id, organization_membership_id: gm.organization_membership_id }, + organization_id: ws.groups.get(gm.group_id)?.organization_id ?? null, }), onDelete: (gm) => eventBus.emit({ event: EVENTS.groupMemberRemoved, data: { group_id: gm.group_id, organization_membership_id: gm.organization_membership_id }, + organization_id: ws.groups.get(gm.group_id)?.organization_id ?? null, }), }); // Pipes connected accounts. The event is named by the state the row lands in, so the @@ -1258,10 +1267,34 @@ export const workosPlugin: ServicePlugin = { }, onDelete: (d) => eventBus.emit({ event: EVENTS.dsyncDeleted, data: formatDirectory(d) }), }); + const emitDirectoryGroupMembership = (user: WorkOSDirectoryUser, groupId: string, added: boolean) => { + const group = ws.directoryGroups.get(groupId); + eventBus.emit({ + event: added ? EVENTS.dsyncGroupUserAdded : EVENTS.dsyncGroupUserRemoved, + data: { + directory_id: user.directory_id, + user: formatDirectoryUser(user), + group: group ? formatDirectoryGroup(group) : { object: 'directory_group', id: groupId }, + }, + organization_id: user.organization_id, + }); + }; ws.directoryUsers.setHooks({ - onInsert: (u) => eventBus.emit({ event: EVENTS.dsyncUserCreated, data: formatDirectoryUser(u) }), - onUpdate: (u) => eventBus.emit({ event: EVENTS.dsyncUserUpdated, data: formatDirectoryUser(u) }), - onDelete: (u) => eventBus.emit({ event: EVENTS.dsyncUserDeleted, data: formatDirectoryUser(u) }), + onInsert: (u) => { + eventBus.emit({ event: EVENTS.dsyncUserCreated, data: formatDirectoryUser(u) }); + for (const group of u.groups) emitDirectoryGroupMembership(u, group.id, true); + }, + onUpdate: (u, previous) => { + eventBus.emit({ event: EVENTS.dsyncUserUpdated, data: formatDirectoryUser(u) }); + const before = new Set(previous.groups.map((group) => group.id)); + const after = new Set(u.groups.map((group) => group.id)); + for (const groupId of after) if (!before.has(groupId)) emitDirectoryGroupMembership(u, groupId, true); + for (const groupId of before) if (!after.has(groupId)) emitDirectoryGroupMembership(u, groupId, false); + }, + onDelete: (u) => { + for (const group of u.groups) emitDirectoryGroupMembership(u, group.id, false); + eventBus.emit({ event: EVENTS.dsyncUserDeleted, data: formatDirectoryUser(u) }); + }, }); ws.directoryGroups.setHooks({ onInsert: (g) => eventBus.emit({ event: EVENTS.dsyncGroupCreated, data: formatDirectoryGroup(g) }), diff --git a/src/workos/routes/directories.spec.ts b/src/workos/routes/directories.spec.ts index 66ffede..fd37477 100644 --- a/src/workos/routes/directories.spec.ts +++ b/src/workos/routes/directories.spec.ts @@ -192,4 +192,18 @@ describe('Directory Sync routes', () => { expect(res.status).toBe(200); expect((await json(res)).name).toBe('Engineering'); }); + + it('emits group membership events when a directory user joins and leaves a group', async () => { + const { user } = seedDirectory(); + const added = await json(await req('/events?events=dsync.group.user_added')); + expect(added.data).toHaveLength(1); + expect(added.data[0].data.user.email).toBe('jane@acme.com'); + expect(added.data[0].data.group.name).toBe('Engineering'); + expect(added.data[0].data.directory_id).toBe(user.directory_id); + + getWorkOSStore(store).directoryUsers.update(user.id, { groups: [] }); + const removed = await json(await req('/events?events=dsync.group.user_removed')); + expect(removed.data).toHaveLength(1); + expect(removed.data[0].data.group.name).toBe('Engineering'); + }); }); diff --git a/src/workos/routes/events.spec.ts b/src/workos/routes/events.spec.ts index ceba038..83721a9 100644 --- a/src/workos/routes/events.spec.ts +++ b/src/workos/routes/events.spec.ts @@ -21,11 +21,12 @@ describe('Events routes', () => { const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); const json = (res: Response) => res.json() as Promise; + const eventRow = { object: 'event', data: {}, organization_id: null, environment_id: null } as const; it('lists events', async () => { const ws = getWorkOSStore(store); - ws.events.insert({ object: 'event', event: 'user.created', data: { id: 'user_1' }, environment_id: null }); - ws.events.insert({ object: 'event', event: 'organization.created', data: { id: 'org_1' }, environment_id: null }); + ws.events.insert({ ...eventRow, event: 'user.created', data: { id: 'user_1' } }); + ws.events.insert({ ...eventRow, event: 'organization.created', data: { id: 'org_1' }, organization_id: 'org_1' }); const res = await req('/events'); expect(res.status).toBe(200); @@ -37,9 +38,9 @@ describe('Events routes', () => { it('filters events by type', async () => { const ws = getWorkOSStore(store); - ws.events.insert({ object: 'event', event: 'user.created', data: {}, environment_id: null }); - ws.events.insert({ object: 'event', event: 'user.updated', data: {}, environment_id: null }); - ws.events.insert({ object: 'event', event: 'organization.created', data: {}, environment_id: null }); + ws.events.insert({ ...eventRow, event: 'user.created' }); + ws.events.insert({ ...eventRow, event: 'user.updated' }); + ws.events.insert({ ...eventRow, event: 'organization.created' }); const res = await req('/events?events[]=user.created&events[]=user.updated'); const list = await json(res); @@ -53,6 +54,123 @@ describe('Events routes', () => { expect(list.data).toHaveLength(0); }); + // Every wire form an SDK encodes the `events` array as. Production's `qs` parser plus a + // comma split accepts all of them; a poller whose form is not read here sees either every + // event or none. + it.each([ + ['comma-joined (spec form; python, kotlin, elixir, rust)', 'events=user.created,user.updated'], + ['repeated (go, node, ruby)', 'events=user.created&events=user.updated'], + ['bracketed (dotnet)', 'events[]=user.created&events[]=user.updated'], + ['indexed (php)', 'events[0]=user.created&events[1]=user.updated'], + ])('filters events by the %s events parameter', async (_form, query) => { + const ws = getWorkOSStore(store); + ws.events.insert({ ...eventRow, event: 'user.created' }); + ws.events.insert({ ...eventRow, event: 'user.updated' }); + ws.events.insert({ ...eventRow, event: 'organization.created' }); + + const list = await json(await req(`/events?${query}`)); + expect(list.data).toHaveLength(2); + expect(list.data.every((e: any) => e.event.startsWith('user.'))).toBe(true); + }); + + it('filters events by organization and range', async () => { + const ws = getWorkOSStore(store); + ws.events.insert({ + ...eventRow, + event: 'dsync.user.created', + data: { organization_id: 'org_1' }, + organization_id: 'org_1', + }); + ws.events.insert({ + ...eventRow, + event: 'dsync.user.created', + data: { organization_id: 'org_2' }, + organization_id: 'org_2', + }); + + const past = new Date(Date.now() - 60_000).toISOString(); + const future = new Date(Date.now() + 60_000).toISOString(); + + const kept = await json(await req(`/events?organization_id=org_1&range_start=${encodeURIComponent(past)}`)); + expect(kept.data).toHaveLength(1); + expect(kept.data[0].data.organization_id).toBe('org_1'); + + const later = await json(await req(`/events?range_start=${encodeURIComponent(future)}`)); + expect(later.data).toHaveLength(0); + + const ended = await json(await req(`/events?range_end=${encodeURIComponent(past)}`)); + expect(ended.data).toHaveLength(0); + }); + + // Scope is the organization an event occurred within, not whether its payload names one: + // organization.* events carry the organization itself, group.member_* carry only ids, and + // dsync.group.user_* nest theirs. An organization-scoped poller must see all of them. + it('scopes organization and membership events to their organization', async () => { + const post = async (path: string, body: Record) => + json(await req(path, { method: 'POST', body: JSON.stringify(body) })); + const org = await post('/organizations', { name: 'Acme' }); + const other = await post('/organizations', { name: 'Globex' }); + const user = await post('/user_management/users', { email: 'jane@acme.com' }); + const membership = await post('/user_management/organization_memberships', { + user_id: user.id, + organization_id: org.id, + }); + const group = await post(`/organizations/${org.id}/groups`, { name: 'Engineering' }); + await post(`/organizations/${org.id}/groups/${group.id}/organization-memberships`, { + organization_membership_id: membership.id, + }); + + const ws = getWorkOSStore(store); + const directory = ws.directories.insert({ + object: 'directory', + name: 'Okta', + organization_id: org.id, + domain: 'acme.com', + type: 'okta scim v2.0', + state: 'linked', + external_key: 'ext_1', + }); + const directoryGroup = ws.directoryGroups.insert({ + object: 'directory_group', + directory_id: directory.id, + organization_id: org.id, + idp_id: 'idp_grp_1', + name: 'Engineering', + raw_attributes: {}, + }); + ws.directoryUsers.insert({ + object: 'directory_user', + directory_id: directory.id, + organization_id: org.id, + idp_id: 'idp_usr_1', + first_name: 'Jane', + last_name: 'Doe', + email: 'jane@acme.com', + username: 'jdoe', + state: 'active', + role: null, + custom_attributes: {}, + raw_attributes: {}, + groups: [{ object: 'directory_group', id: directoryGroup.id, name: 'Engineering' }], + }); + // Deleting the directory removes its users and groups; the removal it emits must still + // land in the organization's history once nothing it refers to exists. + await req(`/directories/${directory.id}`, { method: 'DELETE' }); + + const scoped = await json(await req(`/events?organization_id=${org.id}&limit=100`)); + const types = scoped.data.map((e: any) => e.event); + expect(types).toContain('organization.created'); + expect(types).toContain('group.member_added'); + expect(types).toContain('dsync.group.user_added'); + expect(types).toContain('dsync.group.user_removed'); + expect(types).not.toContain('user.created'); + // The scope is the emulator's, not the spec's: it never reaches the wire. + expect(scoped.data.some((e: any) => 'organization_id' in e)).toBe(false); + + const others = await json(await req(`/events?organization_id=${other.id}`)); + expect(others.data.map((e: any) => e.event)).toEqual(['organization.created']); + }); + it('event from user creation appears in events list', async () => { // Create a user which should trigger an event via collection hooks await req('/user_management/users', { diff --git a/src/workos/routes/events.ts b/src/workos/routes/events.ts index fdfad77..9feee79 100644 --- a/src/workos/routes/events.ts +++ b/src/workos/routes/events.ts @@ -1,6 +1,7 @@ import { type RouteContext, parseListParams } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatEvent, formatListResponse } from '../helpers.js'; +import type { WorkOSEvent } from '../entities.js'; export function eventRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -9,13 +10,45 @@ export function eventRoutes(ctx: RouteContext): void { app.get('/events', (c) => { const url = new URL(c.req.url); const params = parseListParams(url); - const eventTypes = url.searchParams.getAll('events[]'); + // The spec says `style: form, explode: false` (`events=a,b`), which is what workos-python, + // -kotlin, -elixir and -rust send. Production parses the query with `qs` and splits a scalar + // on commas, so it also takes repeated `events=` (workos-go, -node, -ruby), `events[]=` + // (workos-dotnet) and indexed `events[0]=` (workos-php). Accept all four. + const eventTypes = [...url.searchParams] + .filter(([key]) => key === 'events' || /^events\[\d*\]$/.test(key)) + .flatMap(([, value]) => value.split(',')); + const organizationId = url.searchParams.get('organization_id'); + const rangeStart = url.searchParams.get('range_start'); + const rangeEnd = url.searchParams.get('range_end'); const result = ws.events.list({ ...params, - filter: eventTypes.length > 0 ? (e) => eventTypes.includes(e.event) : undefined, + filter: (event) => + (eventTypes.length === 0 || eventTypes.includes(event.event)) && + eventInScope(event, organizationId, rangeStart, rangeEnd), }); return c.json(formatListResponse(result, formatEvent)); }); } + +function eventInScope( + event: WorkOSEvent, + organizationId: string | null, + rangeStart: string | null, + rangeEnd: string | null, +): boolean { + if (organizationId && event.organization_id !== organizationId) return false; + + const createdAt = Date.parse(event.created_at); + if (rangeStart) { + const start = Date.parse(rangeStart); + if (!Number.isNaN(createdAt) && !Number.isNaN(start) && createdAt < start) return false; + } + if (rangeEnd) { + const end = Date.parse(rangeEnd); + if (!Number.isNaN(createdAt) && !Number.isNaN(end) && createdAt > end) return false; + } + + return true; +} diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index b29d34f..cd1e084 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -59,6 +59,85 @@ describe('SSO routes', () => { expect(url.searchParams.get('state')).toBe('abc'); }); + it('sso authorize resolves a social provider to that connection type', async () => { + const org = await json( + await req('/organizations', { + method: 'POST', + body: JSON.stringify({ name: 'Social Org' }), + }), + ); + await req('/connections', { + method: 'POST', + body: JSON.stringify({ + name: 'Google', + organization_id: org.id, + connection_type: 'GoogleOAuth', + domains: ['gmail.example.com'], + }), + }); + + const res = await app.request( + '/sso/authorize?provider=GoogleOAuth&redirect_uri=http://localhost:3000/callback&state=abc', + ); + expect(res.status).toBe(302); + const url = new URL(res.headers.get('location')!); + expect(url.searchParams.get('code')).toBeTruthy(); + expect(url.searchParams.get('state')).toBe('abc'); + }); + + async function createProviderConnection(orgName: string, provider: string, domains: string[]) { + const org = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: orgName }) })); + await req('/connections', { + method: 'POST', + body: JSON.stringify({ name: provider, organization_id: org.id, connection_type: provider, domains }), + }); + return org; + } + + async function authorizedOrganization(query: string) { + const authRes = await app.request(`/sso/authorize?${query}&redirect_uri=http://localhost:3000/callback`); + expect(authRes.status).toBe(302); + const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; + const tokenRes = await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code }), + }); + return (await json(tokenRes)).profile.organization_id; + } + + // The spec's domain_hint pre-fills Microsoft's tenant field; it is not a selector. A provider + // login that carries one must not 404 because no connection happens to claim the domain. + it('treats domain_hint as a hint when a provider is named', async () => { + const org = await createProviderConnection('Microsoft Org', 'MicrosoftOAuth', []); + + expect(await authorizedOrganization('provider=MicrosoftOAuth&domain_hint=acme.com')).toBe(org.id); + }); + + // Production has one OAuth connection per type per environment; the emulator lets each + // organization hold one. The hint picks among them — domains being case-insensitive, in + // whatever case either side was written — and what it leaves ambiguous is refused rather + // than resolved to whichever organization came first. + it('selects among same-type provider connections by domain_hint, and refuses to guess', async () => { + const acme = await createProviderConnection('Acme', 'GoogleOAuth', ['Acme.com']); + const globex = await createProviderConnection('Globex', 'GoogleOAuth', ['globex.com']); + + expect(await authorizedOrganization('provider=GoogleOAuth&domain_hint=GLOBEX.com')).toBe(globex.id); + expect(await authorizedOrganization('provider=GoogleOAuth&domain_hint=acme.com')).toBe(acme.id); + // The hint-only selector reads domains the same way. + expect(await authorizedOrganization('domain_hint=ACME.com')).toBe(acme.id); + + const refused = async (query: string) => { + const res = await app.request(`/sso/authorize?${query}&redirect_uri=http://localhost:3000/callback`); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invalid_request'); + }; + await refused('provider=GoogleOAuth'); + // Nothing stops two organizations claiming one domain, so the hint alone can be ambiguous too. + await createProviderConnection('Acme Shadow', 'GenericSAML', ['acme.com']); + await refused('domain_hint=acme.com'); + }); + // The last exact-match lookup by email. A login_hint differing only in case is the same // federated person, so it reuses the profile rather than minting a second one for the same // connection — which is the pair of records no lookup by email can tell apart, in profile form. diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index c15ca56..d611b35 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -34,6 +34,7 @@ interface SSOAuthorizeParams { connectionId: string | null; organizationId: string | null; domainHint: string | null; + provider: string | null; email: string | null; } @@ -41,8 +42,42 @@ export function ssoRoutes(ctx: RouteContext): void { const { app, store, jwt } = ctx; const ws = getWorkOSStore(store); + /** Domains are case-insensitive; connections keep theirs as written. */ + const claimsDomain = (cn: WorkOSConnection, domain: string) => + cn.domains.some((d) => d.domain.trim().toLowerCase() === domain.trim().toLowerCase()); + + /** + * The one active connection a selector names, or a refusal. The chosen connection supplies + * the organization the profile and code are minted under, so picking whichever organization + * was created first would authenticate the user into the wrong tenant. + */ + function theOnly(candidates: WorkOSConnection[], selector: string): WorkOSConnection | undefined { + if (candidates.length > 1) { + throw new WorkOSApiError( + 400, + `Multiple active connections match ${selector}; select one with connection or organization`, + 'invalid_request', + ); + } + return candidates[0]; + } + + /** + * workos-go sends social login as `provider=GoogleOAuth|MicrosoftOAuth`: the connection type, + * not a connection id. Production holds one OAuth connection of each type per environment, + * so the type alone names it. The emulator lets every organization hold one, so the spec's + * `domain_hint` — Microsoft's tenant pre-fill — narrows the candidates when one of them + * claims the domain, and is otherwise the hint the spec says it is rather than a selector + * that 404s a provider it does not name. + */ + function findProviderConnection(provider: string, domainHint: string | null): WorkOSConnection | undefined { + const ofType = ws.connections.all().filter((cn) => cn.state === 'active' && cn.connection_type === provider); + const hinted = domainHint ? ofType.filter((cn) => claimsDomain(cn, domainHint)) : []; + return theOnly(hinted.length > 0 ? hinted : ofType, `provider ${provider}`); + } + function resolveAndRedirect(c: any, params: SSOAuthorizeParams) { - const { redirectUri, state, connectionId, organizationId, domainHint, email: loginHint } = params; + const { redirectUri, state, connectionId, organizationId, domainHint, provider, email: loginHint } = params; assertAllowedRedirectUri(redirectUri, store); @@ -52,10 +87,13 @@ export function ssoRoutes(ctx: RouteContext): void { connection = ws.connections.get(connectionId); } else if (organizationId) { connection = ws.connections.findBy('organization_id', organizationId).find((cn) => cn.state === 'active'); + } else if (provider) { + connection = findProviderConnection(provider, domainHint); } else if (domainHint) { - connection = ws.connections - .all() - .find((cn) => cn.state === 'active' && cn.domains.some((d) => d.domain === domainHint)); + connection = theOnly( + ws.connections.all().filter((cn) => cn.state === 'active' && claimsDomain(cn, domainHint)), + `domain_hint ${domainHint}`, + ); } if (!connection || connection.state !== 'active') { @@ -107,6 +145,7 @@ export function ssoRoutes(ctx: RouteContext): void { const connectionId = url.searchParams.get('connection'); const organizationId = url.searchParams.get('organization'); const domainHint = url.searchParams.get('domain_hint'); + const provider = url.searchParams.get('provider'); const loginHint = url.searchParams.get('login_hint'); if (!redirectUri) { @@ -125,6 +164,7 @@ export function ssoRoutes(ctx: RouteContext): void { if (connectionId) hiddenFields.connection = connectionId; if (organizationId) hiddenFields.organization = organizationId; if (domainHint) hiddenFields.domain_hint = domainHint; + if (provider) hiddenFields.provider = provider; return c.html( renderLoginPage({ @@ -143,6 +183,7 @@ export function ssoRoutes(ctx: RouteContext): void { connectionId, organizationId, domainHint, + provider, email: loginHint, }); }); @@ -160,6 +201,7 @@ export function ssoRoutes(ctx: RouteContext): void { connectionId: (form.connection as string) ?? null, organizationId: (form.organization as string) ?? null, domainHint: (form.domain_hint as string) ?? null, + provider: (form.provider as string) ?? null, email: (form.email as string) ?? null, }); });