Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions src/workos/routes/directories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,43 @@ 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);
});

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}`);
Expand All @@ -133,13 +154,38 @@ 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);
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}`);
Expand Down
16 changes: 13 additions & 3 deletions src/workos/routes/directories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,18 @@ 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 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;
},
});
Expand All @@ -74,12 +78,18 @@ 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 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;
},
});
Expand Down
15 changes: 15 additions & 0 deletions src/workos/routes/organizations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
4 changes: 2 additions & 2 deletions src/workos/routes/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions src/workos/routes/sso.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
8 changes: 5 additions & 3 deletions src/workos/routes/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(`${STORE_KEY_PREFIXES.ssoLogout}${logoutToken}`);
Expand Down
Loading