From 4a191e3bb84c78f2d6ea4fe5e1a2860bf5745d4a Mon Sep 17 00:00:00 2001 From: Kyle Steger Date: Thu, 6 Aug 2026 15:36:48 -0400 Subject: [PATCH 1/2] Fix: Directory User Group Filter --- src/workos/routes/directories.spec.ts | 10 +++++----- src/workos/routes/directories.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/workos/routes/directories.spec.ts b/src/workos/routes/directories.spec.ts index 33ff11f..dd3ee26 100644 --- a/src/workos/routes/directories.spec.ts +++ b/src/workos/routes/directories.spec.ts @@ -108,18 +108,18 @@ describe('Directory Sync routes', () => { expect(await (await req(`/directory_groups/${group.id}`)).status).toBe(404); }); - it('lists directory users with directory_id filter', async () => { + it('lists directory users with directory filter', async () => { const { dir } = seedDirectory(); - const res = await req(`/directory_users?directory_id=${dir.id}`); + const res = await req(`/directory_users?directory=${dir.id}`); expect(res.status).toBe(200); const list = await json(res); expect(list.data).toHaveLength(1); expect(list.data[0].email).toBe('jane@acme.com'); }); - it('lists directory users with group_id filter', async () => { + it('lists directory users with group filter', async () => { const { group } = seedDirectory(); - const res = await req(`/directory_users?group_id=${group.id}`); + const res = await req(`/directory_users?group=${group.id}`); const list = await json(res); expect(list.data).toHaveLength(1); }); @@ -133,7 +133,7 @@ describe('Directory Sync routes', () => { it('lists directory groups', async () => { const { dir } = seedDirectory(); - const res = await req(`/directory_groups?directory_id=${dir.id}`); + const res = await req(`/directory_groups?directory=${dir.id}`); expect(res.status).toBe(200); const list = await json(res); expect(list.data).toHaveLength(1); diff --git a/src/workos/routes/directories.ts b/src/workos/routes/directories.ts index ad6257a..d734b3c 100644 --- a/src/workos/routes/directories.ts +++ b/src/workos/routes/directories.ts @@ -48,8 +48,8 @@ export function directoryRoutes(ctx: RouteContext): void { app.get('/directory_users', (c) => { const url = new URL(c.req.url); const params = parseListParams(url); - const directoryId = url.searchParams.get('directory_id') ?? undefined; - const groupId = url.searchParams.get('group_id') ?? undefined; + const directoryId = url.searchParams.get('directory') ?? undefined; + const groupId = url.searchParams.get('group') ?? undefined; const result = ws.directoryUsers.list({ ...params, @@ -74,7 +74,7 @@ export function directoryRoutes(ctx: RouteContext): void { app.get('/directory_groups', (c) => { const url = new URL(c.req.url); const params = parseListParams(url); - const directoryId = url.searchParams.get('directory_id') ?? undefined; + const directoryId = url.searchParams.get('directory') ?? undefined; const result = ws.directoryGroups.list({ ...params, From 9107eb5b99156ce438e2cd6032d0096f46f764c6 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 15:51:56 -0400 Subject: [PATCH 2/2] fix(routes): align list query params with the spec Four list endpoints read query params the API does not define, so a caller passing the documented name got a silently unfiltered list rather than an error. That is the failure mode a fixture holding one record cannot catch, which is how it survived having tests. /sso/logout takes `token`. Only the Logout Authorize response body names it `logout_token`, and that field is required there, so the asymmetry is deliberate. The new `idp_id` and `email` filters do not enforce the spec's "requires the directory parameter" precondition: its 422 body is generic, and inventing the message would mean fabricating a value the emulator cannot know. --- src/workos/routes/directories.spec.ts | 46 +++++++++++++++++++++++++ src/workos/routes/directories.ts | 10 ++++++ src/workos/routes/organizations.spec.ts | 15 ++++++++ src/workos/routes/organizations.ts | 4 +-- src/workos/routes/sso.spec.ts | 24 +++++++++++++ src/workos/routes/sso.ts | 8 +++-- 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/workos/routes/directories.spec.ts b/src/workos/routes/directories.spec.ts index dd3ee26..66ffede 100644 --- a/src/workos/routes/directories.spec.ts +++ b/src/workos/routes/directories.spec.ts @@ -124,6 +124,27 @@ describe('Directory Sync routes', () => { expect(list.data).toHaveLength(1); }); + it('lists directory users with idp_id filter', async () => { + const { dir } = seedDirectory(); + const res = await req(`/directory_users?directory=${dir.id}&idp_id=idp_usr_1`); + expect(res.status).toBe(200); + expect((await json(res)).data).toHaveLength(1); + + const miss = await req(`/directory_users?directory=${dir.id}&idp_id=idp_usr_absent`); + expect((await json(miss)).data).toHaveLength(0); + }); + + it('lists directory users with email filter', async () => { + const { dir } = seedDirectory(); + // Email addresses identify the same person whatever their casing. + const res = await req(`/directory_users?directory=${dir.id}&email=JANE@ACME.COM`); + expect(res.status).toBe(200); + expect((await json(res)).data).toHaveLength(1); + + const miss = await req(`/directory_users?directory=${dir.id}&email=nobody@acme.com`); + expect((await json(miss)).data).toHaveLength(0); + }); + it('gets a directory user by id', async () => { const { user } = seedDirectory(); const res = await req(`/directory_users/${user.id}`); @@ -140,6 +161,31 @@ describe('Directory Sync routes', () => { expect(list.data[0].name).toBe('Engineering'); }); + it('lists directory groups with user filter', async () => { + const { dir, group, user } = seedDirectory(); + const ws = getWorkOSStore(store); + // A second group in the same directory that the user does not belong to — without it, + // an ignored `user` param would return the same single group as a working one. + const unrelated = ws.directoryGroups.insert({ + object: 'directory_group', + directory_id: dir.id, + organization_id: 'org_123', + idp_id: 'idp_grp_2', + name: 'Sales', + raw_attributes: {}, + }); + + const res = await req(`/directory_groups?user=${user.id}`); + expect(res.status).toBe(200); + const list = await json(res); + expect(list.data).toHaveLength(1); + expect(list.data[0].id).toBe(group.id); + expect(list.data.map((g: { id: string }) => g.id)).not.toContain(unrelated.id); + + const miss = await req('/directory_groups?user=directory_user_absent'); + expect((await json(miss)).data).toHaveLength(0); + }); + it('gets a directory group by id', async () => { const { group } = seedDirectory(); const res = await req(`/directory_groups/${group.id}`); diff --git a/src/workos/routes/directories.ts b/src/workos/routes/directories.ts index d734b3c..06c78df 100644 --- a/src/workos/routes/directories.ts +++ b/src/workos/routes/directories.ts @@ -50,12 +50,16 @@ export function directoryRoutes(ctx: RouteContext): void { const params = parseListParams(url); const directoryId = url.searchParams.get('directory') ?? undefined; const groupId = url.searchParams.get('group') ?? undefined; + const idpId = url.searchParams.get('idp_id') ?? undefined; + const email = url.searchParams.get('email') ?? undefined; const result = ws.directoryUsers.list({ ...params, filter: (u) => { if (directoryId && u.directory_id !== directoryId) return false; if (groupId && !u.groups.some((g) => g.id === groupId)) return false; + if (idpId && u.idp_id !== idpId) return false; + if (email && u.email?.toLowerCase() !== email.toLowerCase()) return false; return true; }, }); @@ -75,11 +79,17 @@ export function directoryRoutes(ctx: RouteContext): void { const url = new URL(c.req.url); const params = parseListParams(url); const directoryId = url.searchParams.get('directory') ?? undefined; + const userId = url.searchParams.get('user') ?? undefined; + + // Resolve the user's group membership once rather than per candidate group. An unknown + // user id yields an empty set, so the filter matches nothing. + const userGroupIds = userId ? new Set(ws.directoryUsers.get(userId)?.groups.map((g) => g.id) ?? []) : undefined; const result = ws.directoryGroups.list({ ...params, filter: (g) => { if (directoryId && g.directory_id !== directoryId) return false; + if (userGroupIds && !userGroupIds.has(g.id)) return false; return true; }, }); diff --git a/src/workos/routes/organizations.spec.ts b/src/workos/routes/organizations.spec.ts index effdaf5..31bb67c 100644 --- a/src/workos/routes/organizations.spec.ts +++ b/src/workos/routes/organizations.spec.ts @@ -120,6 +120,21 @@ describe('Organization routes', () => { expect(getRes.status).toBe(404); }); + it('filters organizations by search', async () => { + for (const name of ['Acme Corp', 'Globex']) { + await req('/organizations', { method: 'POST', body: JSON.stringify({ name }) }); + } + + const res = await req('/organizations?search=acme'); + expect(res.status).toBe(200); + const list = await json(res); + expect(list.data).toHaveLength(1); + expect(list.data[0].name).toBe('Acme Corp'); + + const miss = await req('/organizations?search=initech'); + expect((await json(miss)).data).toHaveLength(0); + }); + it('lists with cursor pagination', async () => { for (let i = 1; i <= 5; i++) { await req('/organizations', { diff --git a/src/workos/routes/organizations.ts b/src/workos/routes/organizations.ts index 442fad8..ff314f4 100644 --- a/src/workos/routes/organizations.ts +++ b/src/workos/routes/organizations.ts @@ -51,13 +51,13 @@ export function organizationRoutes(ctx: RouteContext): void { app.get('/organizations', (c) => { const url = new URL(c.req.url); const params = parseListParams(url); - const nameFilter = url.searchParams.get('name') ?? undefined; + const search = url.searchParams.get('search') ?? undefined; const domainsFilter = url.searchParams.get('domains') ?? undefined; const result = ws.organizations.list({ ...params, filter: (org) => { - if (nameFilter && !org.name.toLowerCase().includes(nameFilter.toLowerCase())) { + if (search && !org.name.toLowerCase().includes(search.toLowerCase())) { return false; } if (domainsFilter) { diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index ecaa9b5..6c0c306 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -466,4 +466,28 @@ describe('SSO authentication events', () => { expect(res.status).toBe(400); expect(await res.json()).toEqual({ error: 'invalid_request', error_description: 'grant_type is required.' }); }); + + // The redirect endpoint takes `token`; only the Logout Authorize response body names it + // `logout_token`. Reading the wrong one made every logout_url the emulator handed out + // unusable against the emulator itself. + it('single logout accepts the token param and the logout_url it issues', async () => { + const { conn } = await createOrgWithConnection(); + await app.request( + `/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback&login_hint=bye%40sso.example.com`, + ); + const profile = getWorkOSStore(store).ssoProfiles.all()[0]; + + const authorize = await json( + await req('/sso/logout/authorize', { method: 'POST', body: JSON.stringify({ profile_id: profile.id }) }), + ); + expect(new URL(authorize.logout_url).searchParams.get('token')).toBe(authorize.logout_token); + + // The issued URL works as handed out, and the old param name is not accepted. + const stale = await app.request(`/sso/logout?logout_token=${authorize.logout_token}`); + expect(stale.status).toBe(400); + + const res = await app.request(new URL(authorize.logout_url).pathname + new URL(authorize.logout_url).search); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + }); }); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 0dd07c3..7c97ff4 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -313,17 +313,19 @@ export function ssoRoutes(ctx: RouteContext): void { return c.json({ logout_token: logoutToken, - logout_url: `${ctx.baseUrl}/sso/logout?logout_token=${logoutToken}`, + logout_url: `${ctx.baseUrl}/sso/logout?token=${logoutToken}`, }); }); // SSO Single Logout — redirect (public, no auth) app.get('/sso/logout', (c) => { const url = new URL(c.req.url); - const logoutToken = url.searchParams.get('logout_token'); + // The redirect endpoint reads the token from `token`; only the Logout Authorize + // response body names it `logout_token`. + const logoutToken = url.searchParams.get('token'); if (!logoutToken) { - throw new WorkOSApiError(400, 'logout_token is required', 'invalid_request'); + throw new WorkOSApiError(400, 'token is required', 'invalid_request'); } const profileId = store.getData(`${STORE_KEY_PREFIXES.ssoLogout}${logoutToken}`);