From 89d872d712b06f0b012ec8d24268c35ff7e69400 Mon Sep 17 00:00:00 2001 From: BoogeyMan24 Date: Sat, 7 Nov 2026 23:36:57 -0500 Subject: [PATCH] feat(user-management): Add authorized application endpoints --- .../list-authorized-applications.json | 29 ++++ ...uthorized-connect-application.interface.ts | 20 +++ src/user-management/interfaces/index.ts | 1 + ...zed-connect-application.serializer.spec.ts | 43 +++++ ...thorized-connect-application.serializer.ts | 15 ++ src/user-management/serializers/index.ts | 1 + src/user-management/user-management.spec.ts | 155 ++++++++++++++++++ src/user-management/user-management.ts | 53 +++++- 8 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/user-management/fixtures/list-authorized-applications.json create mode 100644 src/user-management/interfaces/authorized-connect-application.interface.ts create mode 100644 src/user-management/serializers/authorized-connect-application.serializer.spec.ts create mode 100644 src/user-management/serializers/authorized-connect-application.serializer.ts diff --git a/src/user-management/fixtures/list-authorized-applications.json b/src/user-management/fixtures/list-authorized-applications.json new file mode 100644 index 000000000..7ebc38dfc --- /dev/null +++ b/src/user-management/fixtures/list-authorized-applications.json @@ -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 } +} diff --git a/src/user-management/interfaces/authorized-connect-application.interface.ts b/src/user-management/interfaces/authorized-connect-application.interface.ts new file mode 100644 index 000000000..094472816 --- /dev/null +++ b/src/user-management/interfaces/authorized-connect-application.interface.ts @@ -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; +} diff --git a/src/user-management/interfaces/index.ts b/src/user-management/interfaces/index.ts index 884ae2fa3..27fb06485 100644 --- a/src/user-management/interfaces/index.ts +++ b/src/user-management/interfaces/index.ts @@ -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'; diff --git a/src/user-management/serializers/authorized-connect-application.serializer.spec.ts b/src/user-management/serializers/authorized-connect-application.serializer.spec.ts new file mode 100644 index 000000000..4791dda50 --- /dev/null +++ b/src/user-management/serializers/authorized-connect-application.serializer.spec.ts @@ -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', + }, + }); + }); +}); diff --git a/src/user-management/serializers/authorized-connect-application.serializer.ts b/src/user-management/serializers/authorized-connect-application.serializer.ts new file mode 100644 index 000000000..42c01255e --- /dev/null +++ b/src/user-management/serializers/authorized-connect-application.serializer.ts @@ -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), +}); diff --git a/src/user-management/serializers/index.ts b/src/user-management/serializers/index.ts index f8c5755dc..d4f7c9b69 100644 --- a/src/user-management/serializers/index.ts +++ b/src/user-management/serializers/index.ts @@ -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'; diff --git a/src/user-management/user-management.spec.ts b/src/user-management/user-management.spec.ts index b207f6a0e..718165712 100644 --- a/src/user-management/user-management.spec.ts +++ b/src/user-management/user-management.spec.ts @@ -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'; @@ -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(); diff --git a/src/user-management/user-management.ts b/src/user-management/user-management.ts index cdf618b47..87d9fd778 100644 --- a/src/user-management/user-management.ts +++ b/src/user-management/user-management.ts @@ -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'; @@ -20,6 +20,8 @@ import { AuthenticateWithTotpOptions, AuthenticationResponse, AuthenticationResponseResponse, + AuthorizedConnectApplication, + AuthorizedConnectApplicationResponse, CreateMagicAuthOptions, CreateMagicAuthResponse, CreateMagicAuthResponseResponse, @@ -152,6 +154,7 @@ import { } from './interfaces/update-organization-membership-options.interface'; import { deserializeAuthenticationResponse, + deserializeAuthorizedConnectApplication, deserializeEmailVerification, deserializeMagicAuth, deserializePasswordReset, @@ -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>} + * @throws {NotFoundException} 404 + * @throws {UnprocessableEntityException} 422 + */ + async listAuthorizedApplications( + userId: string, + options?: PaginationOptions, + ): Promise> { + 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} + * @throws {NotFoundException} 404 + */ + async deleteAuthorizedApplication( + userId: string, + applicationId: string, + ): Promise { + await this.workos.delete( + `/user_management/users/${encodePathParameter(userId)}/authorized_applications/${encodePathParameter(applicationId)}`, + ); + } + /** * Delete a user *