diff --git a/README.md b/README.md index 19185fc2..f98672cc 100644 --- a/README.md +++ b/README.md @@ -622,13 +622,13 @@ await descopeClient.management.password.configureSettings('my-tenant-id', { You can create, update, delete or load SSO applications: ```typescript -// Create OIDC sso application +// Create OIDC SSO application await descopeClient.management.ssoApplication.createOidcApplication({ name: 'My OIDC app name', loginPageUrl: 'http://dummy.com/login', }); -// Create SAML sso application +// Create SAML SSO application await descopeClient.management.ssoApplication.createSamlApplication({ name: 'My SAML app name', loginPageUrl: 'http://dummy.com/login', @@ -636,7 +636,7 @@ await descopeClient.management.ssoApplication.createSamlApplication({ metadataUrl: 'http://dummy.com/metadata', }); -// Update OIDC sso application. +// Update OIDC SSO application. // Update will override all fields as is. Use carefully. await descopeClient.management.ssoApplication.updateOidcApplication({ id: 'my-app-id', @@ -644,7 +644,7 @@ await descopeClient.management.ssoApplication.updateOidcApplication({ loginPageUrl: 'http://dummy.com/login', }); -// Update SAML sso application. +// Update SAML SSO application. // Update will override all fields as is. Use carefully. await descopeClient.management.ssoApplication.updateSamlApplication({ id: 'my-app-id', @@ -657,13 +657,13 @@ await descopeClient.management.ssoApplication.updateSamlApplication({ certificate: 'certificate', }); -// Tenant deletion cannot be undone. Use carefully. +// SSO application deletion cannot be undone. Use carefully. await descopeClient.management.ssoApplication.delete('my-app-id'); -// Load sso application by id +// Load SSO application by id const app = await descopeClient.management.ssoApplication.load('my-app-id'); -// Load all sso applications +// Load all SSO applications const appsRes = await descopeClient.management.ssoApplication.loadAll(); appsRes.data.forEach((app) => { // do something @@ -1193,6 +1193,68 @@ const relations = await descopeClient.management.fga.check([ ]); ``` +### Manage Third Party Aplications + +You can create, update, delete or load third party applications: + +```typescript +// Create a third party application. +const { id, cleartext: secret } = + await descopeClient.management.thirdPartyApplication.createApplication({ + name: 'my new app', + description: 'my desc', + logo: 'data:image/png;..', + approvedCallbackUrls: ['dummy.com'], + permissionsScopes: [ + { + name: 'read_support', + description: 'read for support', + values: ['Support'], + }, + ], + attributesScopes: [ + { + name: 'read_email', + description: 'read user email', + values: ['email'], + }, + ], + loginPageUrl: 'http://dummy.com/login', + }); + +// Update a third party application. +// Update will override all fields as is. Use carefully. +await descopeClient.management.thirdPartyApplication.updateApplication({ + name: 'my updated app', + loginPageUrl: 'http://dummy.com/login', + approvedCallbackUrls: ['dummy.com', 'myawesomedomain.com'], +}); + +// third party application deletion cannot be undone. Use carefully. +await descopeClient.management.thirdPartyApplication.deleteApplication('my-app-id'); + +// Load third party application by id +const app = await descopeClient.management.thirdPartyApplication.loadApplication('my-app-id'); + +// Load all third party applications +const appsRes = await descopeClient.management.thirdPartyApplication.loadAllApplications(); +appsRes.data.forEach((app) => { + // do something +}); + +// Search in all consents. search consents by the given app id and offset to the third page. +const consentsRes = await descopeClient.management.thirdPartyApplication.searchConsents({ + appId: 'my-app', + page: 2, +}); + +// Delete consents. delete all user consents. +// third party application consents deletion cannot be undone. Use carefully. +await descopeClient.management.thirdPartyApplication.deleteConsents({ + userIds: ['user'], +}); +``` + ### Utils for your end to end (e2e) tests and integration tests To ease your e2e tests, we exposed dedicated management methods, diff --git a/lib/management/index.ts b/lib/management/index.ts index 3b9278fc..54801465 100644 --- a/lib/management/index.ts +++ b/lib/management/index.ts @@ -15,6 +15,7 @@ import WithAuthz from './authz'; import withSSOApplication from './ssoapplication'; import withPassword from './password'; import WithFGA from './fga'; +import withThirdPartyApplication from './thirdpartyapplication'; /** Constructs a higher level Management API that wraps the functions from code-js-sdk */ const withManagement = (sdk: CoreSdk, managementKey?: string) => ({ @@ -23,6 +24,7 @@ const withManagement = (sdk: CoreSdk, managementKey?: string) => ({ accessKey: withAccessKey(sdk, managementKey), tenant: withTenant(sdk, managementKey), ssoApplication: withSSOApplication(sdk, managementKey), + thirdPartyApplication: withThirdPartyApplication(sdk, managementKey), sso: withSSOSettings(sdk, managementKey), jwt: withJWT(sdk, managementKey), permission: withPermission(sdk, managementKey), diff --git a/lib/management/paths.ts b/lib/management/paths.ts index fa5da4d7..99c1d3da 100644 --- a/lib/management/paths.ts +++ b/lib/management/paths.ts @@ -76,6 +76,17 @@ export default { load: '/v1/mgmt/sso/idp/app/load', loadAll: '/v1/mgmt/sso/idp/apps/load', }, + thirdPartyApplication: { + create: '/v1/mgmt/thirdparty/app/create', + update: '/v1/mgmt/thirdparty/app/update', + delete: '/v1/mgmt/thirdparty/app/delete', + load: '/v1/mgmt/thirdparty/app/load', + loadAll: '/v1/mgmt/thirdparty/apps/load', + }, + thirdPartyApplicationConsents: { + delete: '/v1/mgmt/thirdparty/consents/delete', + search: '/v1/mgmt/thirdparty/consents/search', + }, sso: { settings: '/v1/mgmt/sso/settings', metadata: '/v1/mgmt/sso/metadata', diff --git a/lib/management/thirdpartyapplication.test.ts b/lib/management/thirdpartyapplication.test.ts new file mode 100644 index 00000000..f7a41a49 --- /dev/null +++ b/lib/management/thirdpartyapplication.test.ts @@ -0,0 +1,335 @@ +import { SdkResponse } from '@descope/core-js-sdk'; +import withManagement from '.'; +import apiPaths from './paths'; +import { + CreateThirdPartyApplicationResponse, + ThirdPartyApplication, + ThirdPartyApplicationConsent, +} from './types'; +import { mockCoreSdk, mockHttpClient } from './testutils'; + +const management = withManagement(mockCoreSdk, 'key'); + +const mockThirdPartyApplicationCreateResponse = { + id: 'foo', +}; + +const mockThirdPartyApplications: ThirdPartyApplication[] = [ + { + id: 'app1', + name: 'App1', + description: '', + logo: null, + clientId: 'my-client-1', + loginPageUrl: 'http://dummy.com', + permissionsScopes: [], + approvedCallbackUrls: ['test.com'], + }, + { + id: 'app2', + name: 'App2', + description: '', + logo: null, + clientId: 'my-client-1', + loginPageUrl: 'http://dummy.com', + permissionsScopes: [{ name: 'scope1', description: 'scope1 description' }], + attributesScopes: [{ name: 'attr1', description: 'attr1 description' }], + }, +]; + +const mockThirdPartyApplicationConsents: ThirdPartyApplicationConsent[] = [ + { + id: 'app1', + appId: 'app1', + userId: 'user1', + scopes: ['scope1'], + grantedBy: 'user1', + createdTime: new Date().getTime(), + }, + { + id: 'app2', + appId: 'app2', + userId: 'user2', + scopes: ['scope1'], + grantedBy: 'user2', + createdTime: new Date().getTime(), + }, +]; + +const mockAllThirdPartyApplicationsResponse = { + apps: mockThirdPartyApplications, +}; + +const mockThirdPartyApplicationConsentsResponse = { + consents: mockThirdPartyApplicationConsents, +}; + +describe('Management ThirdPartyApplication', () => { + afterEach(() => { + jest.clearAllMocks(); + mockHttpClient.reset(); + }); + + describe('createThirdPartyApplication', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => mockThirdPartyApplicationCreateResponse, + clone: () => ({ + json: () => Promise.resolve(mockThirdPartyApplicationCreateResponse), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = + await management.thirdPartyApplication.createApplication({ + name: 'name', + loginPageUrl: 'http://dummy.com', + permissionsScopes: [ + { + name: 'scope1', + description: 'scope1 description', + }, + ], + description: 'test', + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.thirdPartyApplication.create, + { + name: 'name', + loginPageUrl: 'http://dummy.com', + permissionsScopes: [ + { + name: 'scope1', + description: 'scope1 description', + }, + ], + description: 'test', + }, + { token: 'key' }, + ); + + expect(resp).toEqual({ + code: 200, + data: mockThirdPartyApplicationCreateResponse, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('updateThirdPartyApplication', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => {}, + clone: () => ({ + json: () => Promise.resolve({}), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp = await management.thirdPartyApplication.updateApplication({ + id: 'app1', + name: 'name', + permissionsScopes: [], + logo: 'logo', + description: 'desc', + approvedCallbackUrls: ['test.com'], + loginPageUrl: 'http://dummy.com', + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.thirdPartyApplication.update, + { + id: 'app1', + name: 'name', + permissionsScopes: [], + logo: 'logo', + description: 'desc', + approvedCallbackUrls: ['test.com'], + loginPageUrl: 'http://dummy.com', + }, + { token: 'key' }, + ); + + expect(resp).toEqual({ + code: 200, + data: {}, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('deleteThirdPartyApplication', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => {}, + clone: () => ({ + json: () => Promise.resolve({}), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp = await management.thirdPartyApplication.deleteApplication('app1'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.thirdPartyApplication.delete, + { id: 'app1' }, + { token: 'key' }, + ); + + expect(resp).toEqual({ + code: 200, + data: {}, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('loadThirdPartyApplication', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => mockThirdPartyApplications[0], + clone: () => ({ + json: () => Promise.resolve(mockThirdPartyApplications[0]), + }), + status: 200, + }; + mockHttpClient.get.mockResolvedValue(httpResponse); + + const resp: SdkResponse = + await management.thirdPartyApplication.loadApplication(mockThirdPartyApplications[0].id); + + expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.thirdPartyApplication.load, { + queryParams: { id: mockThirdPartyApplications[0].id }, + token: 'key', + }); + + expect(resp).toEqual({ + code: 200, + data: mockThirdPartyApplications[0], + ok: true, + response: httpResponse, + }); + }); + }); + + describe('loadAllThirdPartyApplication', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => mockAllThirdPartyApplicationsResponse, + clone: () => ({ + json: () => Promise.resolve(mockAllThirdPartyApplicationsResponse), + }), + status: 200, + }; + mockHttpClient.get.mockResolvedValue(httpResponse); + + const resp: SdkResponse = + await management.thirdPartyApplication.loadAllApplications(); + + expect(mockHttpClient.get).toHaveBeenCalledWith(apiPaths.thirdPartyApplication.loadAll, { + token: 'key', + }); + + expect(resp).toEqual({ + code: 200, + data: mockThirdPartyApplications, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('searchThirdPartyApplicationConsents', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => mockThirdPartyApplicationConsentsResponse, + clone: () => ({ + json: () => Promise.resolve(mockThirdPartyApplicationConsentsResponse), + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = + await management.thirdPartyApplication.searchConsents({ + appId: 'app1', + userId: 'user1', + consentId: 'consent1', + page: 1, + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.thirdPartyApplicationConsents.search, + { + appId: 'app1', + userId: 'user1', + consentId: 'consent1', + page: 1, + }, + { + token: 'key', + }, + ); + + expect(resp).toEqual({ + code: 200, + data: mockThirdPartyApplicationConsents, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('deleteThirdPartyApplicationConsents', () => { + it('should send the correct request and receive correct response', async () => { + const httpResponse = { + ok: true, + json: () => {}, + clone: () => ({ + json: () => {}, + }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const resp: SdkResponse = + await management.thirdPartyApplication.deleteConsents({ + appId: 'app1', + userIds: ['user1'], + consentIds: ['consent1'], + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.thirdPartyApplicationConsents.delete, + { + appId: 'app1', + userIds: ['user1'], + consentIds: ['consent1'], + }, + { + token: 'key', + }, + ); + + expect(resp).toEqual({ + code: 200, + data: undefined, + ok: true, + response: httpResponse, + }); + }); + }); +}); diff --git a/lib/management/thirdpartyapplication.ts b/lib/management/thirdpartyapplication.ts new file mode 100644 index 00000000..4404c555 --- /dev/null +++ b/lib/management/thirdpartyapplication.ts @@ -0,0 +1,89 @@ +import { SdkResponse, transformResponse } from '@descope/core-js-sdk'; +import { CoreSdk } from '../types'; +import apiPaths from './paths'; +import { + ThirdPartyApplication, + ThirdPartyApplicationConsent, + ThirdPartyApplicationConsentDeleteOptions, + ThirdPartyApplicationConsentSearchOptions, + CreateThirdPartyApplicationResponse, + ThirdPartyApplicationOptions, +} from './types'; + +type MultipleThirdPartyApplicationResponse = { + apps: ThirdPartyApplication[]; +}; + +type MultipleThirdPartyApplicationConsentsResponse = { + consents: ThirdPartyApplicationConsent[]; +}; + +const withThirdPartyApplication = (sdk: CoreSdk, managementKey?: string) => ({ + createApplication: ( + options: ThirdPartyApplicationOptions, + ): Promise> => + transformResponse( + sdk.httpClient.post( + apiPaths.thirdPartyApplication.create, + { + ...options, + }, + { token: managementKey }, + ), + ), + updateApplication: ( + options: ThirdPartyApplicationOptions & { id: string }, + ): Promise> => + transformResponse( + sdk.httpClient.post( + apiPaths.thirdPartyApplication.update, + { ...options }, + { token: managementKey }, + ), + ), + deleteApplication: (id: string): Promise> => + transformResponse( + sdk.httpClient.post(apiPaths.thirdPartyApplication.delete, { id }, { token: managementKey }), + ), + loadApplication: (id: string): Promise> => + transformResponse( + sdk.httpClient.get(apiPaths.thirdPartyApplication.load, { + queryParams: { id }, + token: managementKey, + }), + (data) => data, + ), + loadAllApplications: (): Promise> => + transformResponse( + sdk.httpClient.get(apiPaths.thirdPartyApplication.loadAll, { + token: managementKey, + }), + (data) => data.apps, + ), + searchConsents: ( + options?: ThirdPartyApplicationConsentSearchOptions, + ): Promise> => + transformResponse< + MultipleThirdPartyApplicationConsentsResponse, + ThirdPartyApplicationConsent[] + >( + sdk.httpClient.post( + apiPaths.thirdPartyApplicationConsents.search, + { ...options }, + { token: managementKey }, + ), + (data) => data.consents, + ), + deleteConsents: ( + options: ThirdPartyApplicationConsentDeleteOptions, + ): Promise> => + transformResponse( + sdk.httpClient.post( + apiPaths.thirdPartyApplicationConsents.delete, + { ...options }, + { token: managementKey }, + ), + ), +}); + +export default withThirdPartyApplication; diff --git a/lib/management/types.ts b/lib/management/types.ts index 8a13b531..5c3fa7b7 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -790,3 +790,62 @@ export type CheckResponseRelation = { allowed: boolean; tuple: FGARelation; }; + +export type ThirdPartyApplicationScope = { + name: string; + description: string; + values?: string[]; +}; + +/** + * Represents a third party application request in a project. + * This type is used to create a new third party application in a project. + */ +export type ThirdPartyApplicationOptions = { + name: string; + description?: string; + logo?: string; + loginPageUrl: string; + approvedCallbackUrls?: string[]; + permissionsScopes: ThirdPartyApplicationScope[]; + attributesScopes?: ThirdPartyApplicationScope[]; +}; + +/** + * Represents a third party application in a project. + */ +export type ThirdPartyApplication = ThirdPartyApplicationOptions & { + id: string; + clientId: string; +}; + +export type CreateThirdPartyApplicationResponse = { + id: string; + cleartext: string; +}; + +/** + * Represents a third party application consent for a single application + * for a specific user within the project. + */ +export type ThirdPartyApplicationConsent = { + id: string; + appId: string; + userId: string; + scopes: string[]; + grantedBy: string; + createdTime: number; +}; + +export type ThirdPartyApplicationConsentSearchOptions = { + appId?: string; + userId?: string; + consentId?: string; + page?: number; +}; + +export type ThirdPartyApplicationConsentDeleteOptions = { + consentIds?: string[]; + appId?: string; + userIds?: string[]; +};