Skip to content
Open
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
29 changes: 29 additions & 0 deletions src/user-management/fixtures/list-authorized-applications.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"object": "list",
"data": [
{
"object": "authorized_connect_application",
"id": "authorized_connect_app_123",
"granted_scopes": ["openid", "email"],
"oauth_resource": "https://api.example.com/resource",
"application": {
"object": "connect_application",
"id": "conn_app_123",
"client_id": "client_123",
"description": null,
"name": "My Application",
"scopes": ["openid", "profile", "email"],
"created_at": "2026-01-15T12:00:00.000Z",
"updated_at": "2026-01-15T12:00:00.000Z",
"application_type": "oauth",
"redirect_uris": [
{ "uri": "https://example.com/callback", "default": true }
],
"cors_origins": ["https://example.com"],
"uses_pkce": true,
"is_first_party": true
}
}
],
"list_metadata": { "before": null, "after": null }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {
ConnectApplication,
ConnectApplicationResponse,
} from '../../connect/interfaces';

export interface AuthorizedConnectApplication {
object: 'authorized_connect_application';
id: string;
grantedScopes: string[];
oauthResource?: string;
application: ConnectApplication;
}

export interface AuthorizedConnectApplicationResponse {
object: 'authorized_connect_application';
id: string;
granted_scopes: string[];
oauth_resource?: string;
application: ConnectApplicationResponse;
}
1 change: 1 addition & 0 deletions src/user-management/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from './authentication-factor.interface';
export * from './authentication-radar-risk-detected-event.interface';
export * from './authentication-response.interface';
export * from './authorization-url-options.interface';
export * from './authorized-connect-application.interface';
export * from './create-magic-auth-options.interface';
export * from './create-organization-membership-options.interface';
export * from './create-password-reset-options.interface';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { AuthorizedConnectApplicationResponse } from '../interfaces';
import { deserializeAuthorizedConnectApplication } from './authorized-connect-application.serializer';

describe('deserializeAuthorizedConnectApplication', () => {
it('deserializes an authorization without an OAuth resource and a nested M2M application', () => {
const response: AuthorizedConnectApplicationResponse = {
object: 'authorized_connect_application',
id: 'authorized_connect_app_123',
granted_scopes: [],
application: {
object: 'connect_application',
id: 'conn_app_123',
client_id: 'client_123',
name: 'M2M Application',
description: null,
scopes: [],
created_at: '2026-01-15T12:00:00.000Z',
updated_at: '2026-01-15T12:00:00.000Z',
application_type: 'm2m',
organization_id: 'org_123',
},
};

expect(deserializeAuthorizedConnectApplication(response)).toEqual({
object: 'authorized_connect_application',
id: 'authorized_connect_app_123',
grantedScopes: [],
oauthResource: undefined,
application: {
object: 'connect_application',
id: 'conn_app_123',
clientId: 'client_123',
name: 'M2M Application',
description: null,
scopes: [],
createdAt: new Date('2026-01-15T12:00:00.000Z'),
updatedAt: new Date('2026-01-15T12:00:00.000Z'),
applicationType: 'm2m',
organizationId: 'org_123',
},
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { deserializeConnectApplication } from '../../connect/serializers/connect-application.serializer';
import type {
AuthorizedConnectApplication,
AuthorizedConnectApplicationResponse,
} from '../interfaces';

export const deserializeAuthorizedConnectApplication = (
response: AuthorizedConnectApplicationResponse,
): AuthorizedConnectApplication => ({
object: response.object,
id: response.id,
grantedScopes: response.granted_scopes,
oauthResource: response.oauth_resource,
application: deserializeConnectApplication(response.application),
});
1 change: 1 addition & 0 deletions src/user-management/serializers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './authenticate-with-totp-options.serializer';
export * from './authentication-event.serializer';
export * from './authentication-factor.serializer';
export * from './authentication-response.serializer';
export * from './authorized-connect-application.serializer';
export * from './create-magic-auth-options.serializer';
export * from './create-password-reset-options.serializer';
export * from './create-user-api-key-options.serializer';
Expand Down
155 changes: 155 additions & 0 deletions src/user-management/user-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import invitationFixture from './fixtures/invitation.json';
import listInvitationsFixture from './fixtures/list-invitations.json';
import listOrganizationMembershipsFixture from './fixtures/list-organization-memberships.json';
import listSessionsFixture from './fixtures/list-sessions.json';
import listAuthorizedApplicationsFixture from './fixtures/list-authorized-applications.json';
import listUserApiKeysFixture from './fixtures/list-user-api-keys.json';
import listUsersFixture from './fixtures/list-users.json';
import magicAuthFixture from './fixtures/magic_auth.json';
Expand Down Expand Up @@ -2677,6 +2678,160 @@ describe('UserManagement', () => {
});
});

describe('listAuthorizedApplications', () => {
it('lists and deserializes user authorizations and nested applications', async () => {
fetchOnce(listAuthorizedApplicationsFixture);

const result =
await workos.userManagement.listAuthorizedApplications(userId);

expect(fetchMethod()).toBe('GET');
expect(new URL(String(fetchURL())).pathname).toBe(
`/user_management/users/${userId}/authorized_applications`,
);
expect(fetchSearchParams()).toEqual({ order: 'desc' });
expect(result.object).toBe('list');
expect(result.listMetadata).toEqual({ before: null, after: null });
expect(result.data).toEqual([
{
object: 'authorized_connect_application',
id: 'authorized_connect_app_123',
grantedScopes: ['openid', 'email'],
oauthResource: 'https://api.example.com/resource',
application: {
object: 'connect_application',
id: 'conn_app_123',
clientId: 'client_123',
description: null,
name: 'My Application',
scopes: ['openid', 'profile', 'email'],
createdAt: new Date('2026-01-15T12:00:00.000Z'),
updatedAt: new Date('2026-01-15T12:00:00.000Z'),
applicationType: 'oauth',
redirectUris: [
{ uri: 'https://example.com/callback', default: true },
],
usesPkce: true,
isFirstParty: true,
},
},
]);
});

it.each(['desc', 'asc'] as const)(
'passes pagination parameters with %s order',
async (order) => {
fetchOnce(listAuthorizedApplicationsFixture);

await workos.userManagement.listAuthorizedApplications(userId, {
before: 'authorized_before',
after: 'authorized_after',
limit: 5,
order,
});

expect(fetchSearchParams()).toEqual({
before: 'authorized_before',
after: 'authorized_after',
limit: '5',
order,
});
},
);

it('preserves the encoded user ID and order on subsequent pages and drops before', async () => {
fetchOnce({
...listAuthorizedApplicationsFixture,
list_metadata: { before: 'previous', after: 'next' },
});
fetchOnce({
...listAuthorizedApplicationsFixture,
data: [
{
...listAuthorizedApplicationsFixture.data[0],
id: 'authorized_connect_app_456',
},
],
});

const encodedUserId = 'user/with ?#%';
const result = await workos.userManagement.listAuthorizedApplications(
encodedUserId,
{ before: 'previous', order: 'asc' },
);
const data = await result.autoPagination();

expect(data.map((authorization) => authorization.id)).toEqual([
'authorized_connect_app_123',
'authorized_connect_app_456',
]);
expect(fetch).toHaveBeenCalledTimes(2);
for (const [url, options] of fetch.mock.calls) {
expect(new URL(String(url)).pathname).toBe(
`/user_management/users/${encodeURIComponent(encodedUserId)}/authorized_applications`,
);
expect(options?.method).toBe('GET');
}
expect(
Object.fromEntries(
new URL(String(fetch.mock.calls[1][0])).searchParams,
),
).toEqual({ after: 'next', limit: '100', order: 'asc' });
});

it('does not fetch subsequent pages when an explicit limit is provided', async () => {
fetchOnce({
...listAuthorizedApplicationsFixture,
list_metadata: { before: null, after: 'next' },
});

const result = await workos.userManagement.listAuthorizedApplications(
userId,
{ limit: 1 },
);

expect(await result.autoPagination()).toEqual(result.data);
expect(fetch).toHaveBeenCalledTimes(1);
});

it('returns an empty list without fetching another page', async () => {
fetchOnce({
...listAuthorizedApplicationsFixture,
data: [],
});

const result =
await workos.userManagement.listAuthorizedApplications(userId);

expect(await result.autoPagination()).toEqual([]);
expect(fetch).toHaveBeenCalledTimes(1);
});
});

describe('deleteAuthorizedApplication', () => {
it.each(['conn_app_123', 'client_123', 'app/with ?#%'])(
'deletes only the user authorization for %s and accepts an empty response',
async (applicationId) => {
fetch.mockResponseOnce('', { status: 204 });
const encodedUserId = 'user/with ?#%';

await expect(
workos.userManagement.deleteAuthorizedApplication(
encodedUserId,
applicationId,
),
).resolves.toBeUndefined();

expect(fetchMethod()).toBe('DELETE');
expect(new URL(String(fetchURL())).pathname).toBe(
`/user_management/users/${encodeURIComponent(encodedUserId)}/authorized_applications/${encodeURIComponent(applicationId)}`,
);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetchBody({ raw: true })).toBeUndefined();
},
);
});

describe('deleteUser', () => {
it('sends a deleteUser request', async () => {
fetchOnce();
Expand Down
53 changes: 52 additions & 1 deletion src/user-management/user-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
deserializeSessionCookieData,
SealedSessionCookieData,
} from './serializers/session-cookie-data.serializer';
import { List, ListResponse } from '../common/interfaces';
import { List, ListResponse, PaginationOptions } from '../common/interfaces';
import { deserializeList } from '../common/serializers';
import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';
import { AutoPaginatable } from '../common/utils/pagination';
Expand All @@ -20,6 +20,8 @@ import {
AuthenticateWithTotpOptions,
AuthenticationResponse,
AuthenticationResponseResponse,
AuthorizedConnectApplication,
AuthorizedConnectApplicationResponse,
CreateMagicAuthOptions,
CreateMagicAuthResponse,
CreateMagicAuthResponseResponse,
Expand Down Expand Up @@ -152,6 +154,7 @@ import {
} from './interfaces/update-organization-membership-options.interface';
import {
deserializeAuthenticationResponse,
deserializeAuthorizedConnectApplication,
deserializeEmailVerification,
deserializeMagicAuth,
deserializePasswordReset,
Expand Down Expand Up @@ -1155,6 +1158,54 @@ export class UserManagement {
);
}

/**
* List authorized applications
*
* Get a list of Connect applications that the user has authorized.
* @param userId - The ID of the user.
* @param options - Pagination options.
* @returns {Promise<AutoPaginatable<AuthorizedConnectApplication>>}
* @throws {NotFoundException} 404
* @throws {UnprocessableEntityException} 422
*/
async listAuthorizedApplications(
userId: string,
options?: PaginationOptions,
): Promise<AutoPaginatable<AuthorizedConnectApplication>> {
const path = `/user_management/users/${encodePathParameter(userId)}/authorized_applications`;

return new AutoPaginatable(
await fetchAndDeserialize<
AuthorizedConnectApplicationResponse,
AuthorizedConnectApplication
>(this.workos, path, deserializeAuthorizedConnectApplication, options),
(params) =>
fetchAndDeserialize<
AuthorizedConnectApplicationResponse,
AuthorizedConnectApplication
>(this.workos, path, deserializeAuthorizedConnectApplication, params),
options,
);
}

/**
* Delete an authorized application
*
* Removes a user's authorization without deleting the Connect application.
* @param userId - The ID of the user.
* @param applicationId - The ID or client ID of the application.
* @returns {Promise<void>}
* @throws {NotFoundException} 404
*/
async deleteAuthorizedApplication(
userId: string,
applicationId: string,
): Promise<void> {
await this.workos.delete(
`/user_management/users/${encodePathParameter(userId)}/authorized_applications/${encodePathParameter(applicationId)}`,
);
}

/**
* Delete a user
*
Expand Down
Loading