From 0a1791a63faa1f6cecbf076dd5020ca281e1e516 Mon Sep 17 00:00:00 2001 From: awartoft Date: Tue, 22 Sep 2026 03:23:11 +0000 Subject: [PATCH 1/7] fix: accept the event and SSO queries workos-go v10 sends The Go SDK repeats events= rather than events[], and it filters with range_start, range_end, and organization_id. Social login is provider=GoogleOAuth. Seeding a directory user into a group never emitted dsync.group.user_added or user_removed. --- README.md | 4 +- SUPPORTED.md | 56 +++++++++++++-------------- scripts/gen-supported-lib.ts | 2 +- src/workos/index.ts | 30 ++++++++++++-- src/workos/routes/directories.spec.ts | 14 +++++++ src/workos/routes/events.spec.ts | 41 ++++++++++++++++++++ src/workos/routes/events.ts | 33 +++++++++++++++- src/workos/routes/sso.spec.ts | 26 +++++++++++++ src/workos/routes/sso.ts | 11 +++++- 9 files changed, 180 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index c8e89a5..e5e5c8d 100644 --- a/README.md +++ b/README.md @@ -1065,13 +1065,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 00d526a..da4df1e 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 | ⚠️ 4/5 | ⚠️ 5/8 | ✅ seed `connectApplications` | | -| 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 | ⚠️ 4/5 | ⚠️ 5/8 | ✅ seed `connectApplications` | | +| 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 c84f65c..2b65a09 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/index.ts b/src/workos/index.ts index 43fbce4..36a168c 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -85,6 +85,7 @@ import { import type { WorkOSConnectionType, WorkOSDirectoryGroup, + WorkOSDirectoryUser, WorkOSOrganizationMembership, PipeProvider, PipeConnectionStatus, @@ -1241,10 +1242,33 @@ 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 }, + }, + }); + }; 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..b68f10c 100644 --- a/src/workos/routes/events.spec.ts +++ b/src/workos/routes/events.spec.ts @@ -53,6 +53,47 @@ describe('Events routes', () => { expect(list.data).toHaveLength(0); }); + it('filters events by the repeated events parameter', 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 }); + + const res = await req('/events?events=user.created&events=user.updated'); + const list = await json(res); + 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({ + object: 'event', + event: 'dsync.user.created', + data: { organization_id: 'org_1' }, + environment_id: null, + }); + ws.events.insert({ + object: 'event', + event: 'dsync.user.created', + data: { organization_id: 'org_2' }, + environment_id: null, + }); + + 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); + }); + 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..d3e17b2 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,41 @@ 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[]'); + // workos-go encodes the filter as repeated `events=` (no brackets). The + // documented form is `events[]`. Both name the same parameter. + const eventTypes = [...url.searchParams.getAll('events'), ...url.searchParams.getAll('events[]')]; + 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.data.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..6534129 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -59,6 +59,32 @@ 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'); + }); + // 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..165064d 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; } @@ -42,7 +43,7 @@ export function ssoRoutes(ctx: RouteContext): void { const ws = getWorkOSStore(store); 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); @@ -56,6 +57,10 @@ export function ssoRoutes(ctx: RouteContext): void { connection = ws.connections .all() .find((cn) => cn.state === 'active' && cn.domains.some((d) => d.domain === domainHint)); + } else if (provider) { + // workos-go sends social login as `provider=GoogleOAuth|MicrosoftOAuth`, which is the + // connection type, not a connection id. + connection = ws.connections.all().find((cn) => cn.state === 'active' && cn.connection_type === provider); } if (!connection || connection.state !== 'active') { @@ -107,6 +112,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 +131,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 +150,7 @@ export function ssoRoutes(ctx: RouteContext): void { connectionId, organizationId, domainHint, + provider, email: loginHint, }); }); @@ -160,6 +168,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, }); }); From f87ed3984cf607f477a4a29a0d2ac87921dfb815 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 13:19:12 -0400 Subject: [PATCH 2/7] fix(events): scope organization_id by where the event occurred The filter compared `data.organization_id`, so an organization-scoped poller never saw `group.member_added`/`member_removed` (ids only), `dsync.group.user_added`/`user_removed` (organization nested under the user) or `organization.*` (the organization is the payload). Resolving the scope when the event is recorded keeps it after the group or directory it refers to is deleted; it stays off the wire because the spec's Event has no such field. --- src/workos/entities.ts | 8 +++ src/workos/event-bus.ts | 4 ++ src/workos/helpers.ts | 2 +- src/workos/index.ts | 17 ++++-- src/workos/routes/events.spec.ts | 94 ++++++++++++++++++++++++++++---- src/workos/routes/events.ts | 2 +- 6 files changed, 109 insertions(+), 18 deletions(-) diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 35f1853..d0f72d3 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -566,6 +566,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 e054082..fa9f13b 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -1283,7 +1283,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 36a168c..0f9009b 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -86,6 +86,7 @@ import type { WorkOSConnectionType, WorkOSDirectoryGroup, WorkOSDirectoryUser, + WorkOSOrganization, WorkOSOrganizationMembership, PipeProvider, PipeConnectionStatus, @@ -1110,10 +1111,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) }), @@ -1131,7 +1136,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) }), @@ -1142,11 +1148,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 @@ -1251,6 +1259,7 @@ export const workosPlugin: ServicePlugin = { user: formatDirectoryUser(user), group: group ? formatDirectoryGroup(group) : { object: 'directory_group', id: groupId }, }, + organization_id: user.organization_id, }); }; ws.directoryUsers.setHooks({ diff --git a/src/workos/routes/events.spec.ts b/src/workos/routes/events.spec.ts index b68f10c..1147f0c 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); @@ -55,9 +56,9 @@ describe('Events routes', () => { it('filters events by the repeated events parameter', 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); @@ -68,16 +69,16 @@ describe('Events routes', () => { it('filters events by organization and range', async () => { const ws = getWorkOSStore(store); ws.events.insert({ - object: 'event', + ...eventRow, event: 'dsync.user.created', data: { organization_id: 'org_1' }, - environment_id: null, + organization_id: 'org_1', }); ws.events.insert({ - object: 'event', + ...eventRow, event: 'dsync.user.created', data: { organization_id: 'org_2' }, - environment_id: null, + organization_id: 'org_2', }); const past = new Date(Date.now() - 60_000).toISOString(); @@ -94,6 +95,75 @@ describe('Events routes', () => { 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 d3e17b2..8356100 100644 --- a/src/workos/routes/events.ts +++ b/src/workos/routes/events.ts @@ -34,7 +34,7 @@ function eventInScope( rangeStart: string | null, rangeEnd: string | null, ): boolean { - if (organizationId && event.data.organization_id !== organizationId) return false; + if (organizationId && event.organization_id !== organizationId) return false; const createdAt = Date.parse(event.created_at); if (rangeStart) { From cd850ce09dba5d48f65794987c62174c77432058 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 13:19:13 -0400 Subject: [PATCH 3/7] fix(sso): resolve provider before domain_hint, refuse ambiguity `domain_hint` ran as a selector ahead of `provider`, so `provider=MicrosoftOAuth&domain_hint=acme.com` 404ed unless some connection claimed the domain, though the spec calls it a pre-fill hint. And a provider matching connections in several organizations took the first, minting the code under whichever tenant was created first. The hint now narrows same-type connections; anything still ambiguous is a 400 rather than a guess. --- src/workos/routes/sso.spec.ts | 46 +++++++++++++++++++++++++++++++++++ src/workos/routes/sso.ts | 29 +++++++++++++++++++--- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 6534129..98bef91 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -85,6 +85,52 @@ describe('SSO routes', () => { 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, 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); + + const ambiguous = await app.request( + '/sso/authorize?provider=GoogleOAuth&redirect_uri=http://localhost:3000/callback', + ); + expect(ambiguous.status).toBe(400); + expect((await json(ambiguous)).code).toBe('invalid_request'); + }); + // 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 165064d..ad2e3bf 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -42,6 +42,29 @@ export function ssoRoutes(ctx: RouteContext): void { const { app, store, jwt } = ctx; const ws = getWorkOSStore(store); + /** + * 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. What the hint leaves ambiguous is refused: picking + * whichever organization was created first would mint the code under the wrong tenant. + */ + 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) => cn.domains.some((d) => d.domain === domainHint)) : []; + const candidates = hinted.length > 0 ? hinted : ofType; + if (candidates.length > 1) { + throw new WorkOSApiError( + 400, + `Multiple active ${provider} connections; select one with connection, organization or domain_hint`, + 'invalid_request', + ); + } + return candidates[0]; + } + function resolveAndRedirect(c: any, params: SSOAuthorizeParams) { const { redirectUri, state, connectionId, organizationId, domainHint, provider, email: loginHint } = params; @@ -53,14 +76,12 @@ 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)); - } else if (provider) { - // workos-go sends social login as `provider=GoogleOAuth|MicrosoftOAuth`, which is the - // connection type, not a connection id. - connection = ws.connections.all().find((cn) => cn.state === 'active' && cn.connection_type === provider); } if (!connection || connection.state !== 'active') { From d3a402c861e7e8a78565a4d40f3d5f316bfc408b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 13:43:57 -0400 Subject: [PATCH 4/7] fix(sso): match domain_hint to connection domains case-insensitively Domains are case-insensitive, but connections keep theirs as written, so a hint of `acme.com` failed to select a connection stored as `Acme.com` and an otherwise resolvable provider login was refused as ambiguous. --- src/workos/routes/sso.spec.ts | 9 +++++---- src/workos/routes/sso.ts | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 98bef91..c654255 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -115,13 +115,14 @@ describe('SSO routes', () => { }); // Production has one OAuth connection per type per environment; the emulator lets each - // organization hold one. The hint picks among them, and what it leaves ambiguous is refused - // rather than resolved to whichever organization came first. + // 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 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=GLOBEX.com')).toBe(globex.id); expect(await authorizedOrganization('provider=GoogleOAuth&domain_hint=acme.com')).toBe(acme.id); const ambiguous = await app.request( diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index ad2e3bf..661ab04 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -53,7 +53,9 @@ export function ssoRoutes(ctx: RouteContext): void { */ 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) => cn.domains.some((d) => d.domain === domainHint)) : []; + // Domains are case-insensitive; stored ones are kept as written. + const hint = domainHint?.trim().toLowerCase(); + const hinted = hint ? ofType.filter((cn) => cn.domains.some((d) => d.domain.trim().toLowerCase() === hint)) : []; const candidates = hinted.length > 0 ? hinted : ofType; if (candidates.length > 1) { throw new WorkOSApiError( From c707fbb3cec24a52b58156e742cee88c998006af Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 14:48:47 -0400 Subject: [PATCH 5/7] fix(sso): read domain_hint case-insensitively on the hint-only path too The provider path stopped caring about domain case; the selector that takes a bare domain_hint still did, so the same hint resolved a connection on one path and 404ed on the other. --- src/workos/routes/sso.spec.ts | 2 ++ src/workos/routes/sso.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index c654255..2122542 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -124,6 +124,8 @@ describe('SSO routes', () => { 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 ambiguous = await app.request( '/sso/authorize?provider=GoogleOAuth&redirect_uri=http://localhost:3000/callback', diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 661ab04..1ace269 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -42,6 +42,10 @@ 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()); + /** * 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, @@ -53,9 +57,7 @@ export function ssoRoutes(ctx: RouteContext): void { */ function findProviderConnection(provider: string, domainHint: string | null): WorkOSConnection | undefined { const ofType = ws.connections.all().filter((cn) => cn.state === 'active' && cn.connection_type === provider); - // Domains are case-insensitive; stored ones are kept as written. - const hint = domainHint?.trim().toLowerCase(); - const hinted = hint ? ofType.filter((cn) => cn.domains.some((d) => d.domain.trim().toLowerCase() === hint)) : []; + const hinted = domainHint ? ofType.filter((cn) => claimsDomain(cn, domainHint)) : []; const candidates = hinted.length > 0 ? hinted : ofType; if (candidates.length > 1) { throw new WorkOSApiError( @@ -81,9 +83,7 @@ export function ssoRoutes(ctx: RouteContext): void { } 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 = ws.connections.all().find((cn) => cn.state === 'active' && claimsDomain(cn, domainHint)); } if (!connection || connection.state !== 'active') { From e94db2cd149addb565616e6089c0419fa277751b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 14:54:32 -0400 Subject: [PATCH 6/7] fix(sso): refuse an ambiguous domain_hint like an ambiguous provider Nothing stops two organizations claiming the same domain, and the hint-only selector took the first match, so a bare domain_hint could mint the code under the wrong tenant, which is the guess the provider path already declines to make. One rule for both selectors. --- src/workos/routes/sso.spec.ts | 14 +++++++++----- src/workos/routes/sso.ts | 34 ++++++++++++++++++++++------------ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 2122542..cd1e084 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -127,11 +127,15 @@ describe('SSO routes', () => { // The hint-only selector reads domains the same way. expect(await authorizedOrganization('domain_hint=ACME.com')).toBe(acme.id); - const ambiguous = await app.request( - '/sso/authorize?provider=GoogleOAuth&redirect_uri=http://localhost:3000/callback', - ); - expect(ambiguous.status).toBe(400); - expect((await json(ambiguous)).code).toBe('invalid_request'); + 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 diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 1ace269..d611b35 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -46,27 +46,34 @@ export function ssoRoutes(ctx: RouteContext): void { 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. What the hint leaves ambiguous is refused: picking - * whichever organization was created first would mint the code under the wrong tenant. + * 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)) : []; - const candidates = hinted.length > 0 ? hinted : ofType; - if (candidates.length > 1) { - throw new WorkOSApiError( - 400, - `Multiple active ${provider} connections; select one with connection, organization or domain_hint`, - 'invalid_request', - ); - } - return candidates[0]; + return theOnly(hinted.length > 0 ? hinted : ofType, `provider ${provider}`); } function resolveAndRedirect(c: any, params: SSOAuthorizeParams) { @@ -83,7 +90,10 @@ export function ssoRoutes(ctx: RouteContext): void { } else if (provider) { connection = findProviderConnection(provider, domainHint); } else if (domainHint) { - connection = ws.connections.all().find((cn) => cn.state === 'active' && claimsDomain(cn, domainHint)); + connection = theOnly( + ws.connections.all().filter((cn) => cn.state === 'active' && claimsDomain(cn, domainHint)), + `domain_hint ${domainHint}`, + ); } if (!connection || connection.state !== 'active') { From 6d54950bc578bde3971551d470dddf7a7af77b47 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 15:37:44 -0400 Subject: [PATCH 7/7] fix(events): accept every wire form SDKs use for the events filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading `events=` alongside `events[]=` covered workos-go, -node and -ruby, but turned the spec's own form — `style: form, explode: false`, i.e. `events=a,b`, sent by workos-python, -kotlin, -elixir and -rust — from an ignored filter into an empty result, since the joined value was compared as one event name. Production parses the query with `qs` and splits a scalar on commas, so it also takes workos-php's indexed `events[0]=`. Normalize all four forms the way production does, and pin each one in the spec. --- src/workos/routes/events.spec.ts | 13 ++++++++++--- src/workos/routes/events.ts | 10 +++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/workos/routes/events.spec.ts b/src/workos/routes/events.spec.ts index 1147f0c..83721a9 100644 --- a/src/workos/routes/events.spec.ts +++ b/src/workos/routes/events.spec.ts @@ -54,14 +54,21 @@ describe('Events routes', () => { expect(list.data).toHaveLength(0); }); - it('filters events by the repeated events parameter', async () => { + // 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 res = await req('/events?events=user.created&events=user.updated'); - const list = await json(res); + 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); }); diff --git a/src/workos/routes/events.ts b/src/workos/routes/events.ts index 8356100..9feee79 100644 --- a/src/workos/routes/events.ts +++ b/src/workos/routes/events.ts @@ -10,9 +10,13 @@ export function eventRoutes(ctx: RouteContext): void { app.get('/events', (c) => { const url = new URL(c.req.url); const params = parseListParams(url); - // workos-go encodes the filter as repeated `events=` (no brackets). The - // documented form is `events[]`. Both name the same parameter. - const eventTypes = [...url.searchParams.getAll('events'), ...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');