diff --git a/README.md b/README.md index 5309f9537..c2543e01a 100644 --- a/README.md +++ b/README.md @@ -1559,6 +1559,48 @@ await descopeClient.management.outboundApplication.deleteUserTokens(undefined, ' // Delete a specific token by its ID // Token deletion cannot be undone. Use carefully. await descopeClient.management.outboundApplication.deleteTokenById('token-id'); + +// List the IDs of the outbound apps a user currently holds a valid token for. +// Use this for connection-status UIs instead of calling fetchToken once per app. +const connectedApps = await descopeClient.management.outboundApplication.listAppsWithUserToken( + 'user-id', + 'tenant-id' // optional +); +// connectedApps.data => ['app-1', 'app-2'] + +// Store a static API key for a user / tenant on an apikey-type outbound app +await descopeClient.management.outboundApplication.uploadUserApiKey( + 'my-app-id', + 'user-id', + 'the-users-api-key', + 'tenant-id' // optional +); +await descopeClient.management.outboundApplication.uploadTenantApiKey( + 'my-app-id', + 'tenant-id', + 'the-tenants-api-key' +); + +// Upload (migrate) an existing OAuth token for a user / tenant on an oauth-type outbound app, +// without requiring the user to re-run the OAuth flow. +await descopeClient.management.outboundApplication.uploadUserToken({ + appId: 'my-app-id', + userId: 'user-id', + refreshToken: 'the-refresh-token', + scopes: ['read', 'write'], +}); +await descopeClient.management.outboundApplication.uploadTenantToken({ + appId: 'my-app-id', + tenantId: 'tenant-id', + accessToken: 'the-access-token', +}); + +// Batch upload OAuth tokens (all-or-nothing): inspect `failures` to see rejected items +const batchRes = await descopeClient.management.outboundApplication.batchUploadUserTokens([ + { appId: 'my-app-id', userId: 'user-1', accessToken: 'token-1' }, + { appId: 'my-app-id', userId: 'user-2', accessToken: 'token-2' }, +]); +// batchRes.data.failures => [{ appId, userId, errorCode, reason }, ...] ``` ### Manage Inbound Applications diff --git a/lib/management/outboundapplication.test.ts b/lib/management/outboundapplication.test.ts index c71079e63..c12b8cfb6 100644 --- a/lib/management/outboundapplication.test.ts +++ b/lib/management/outboundapplication.test.ts @@ -725,4 +725,203 @@ describe('Management OutboundApplication', () => { }); }); }); + + describe('listAppsWithUserToken', () => { + it('should send the correct request and return the app ids', async () => { + const listResponse = { appIds: ['app1', 'app2'] }; + const httpResponse = { + ok: true, + json: () => listResponse, + clone: () => ({ json: () => Promise.resolve(listResponse) }), + status: 200, + }; + mockHttpClient.get.mockResolvedValue(httpResponse); + + const resp: SdkResponse = + await management.outboundApplication.listAppsWithUserToken('user456', 'tenant789'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + apiPaths.outboundApplication.listAppsWithUserToken, + { queryParams: { userId: 'user456', tenantId: 'tenant789' } }, + ); + + expect(resp).toEqual({ + code: 200, + data: ['app1', 'app2'], + ok: true, + response: httpResponse, + }); + }); + + it('should omit tenantId when not provided', async () => { + const listResponse = { appIds: ['app1'] }; + const httpResponse = { + ok: true, + json: () => listResponse, + clone: () => ({ json: () => Promise.resolve(listResponse) }), + status: 200, + }; + mockHttpClient.get.mockResolvedValue(httpResponse); + + await management.outboundApplication.listAppsWithUserToken('user456'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + apiPaths.outboundApplication.listAppsWithUserToken, + { queryParams: { userId: 'user456' } }, + ); + }); + }); + + describe('uploadUserApiKey', () => { + it('should send the correct request', async () => { + const httpResponse = { + ok: true, + json: () => ({}), + clone: () => ({ json: () => Promise.resolve({}) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + await management.outboundApplication.uploadUserApiKey( + 'app123', + 'user456', + 'secret-key', + 'tenant789', + ); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.outboundApplication.uploadUserApiKey, + { appId: 'app123', userId: 'user456', apiKey: 'secret-key', tenantId: 'tenant789' }, + ); + }); + }); + + describe('uploadTenantApiKey', () => { + it('should send the correct request', async () => { + const httpResponse = { + ok: true, + json: () => ({}), + clone: () => ({ json: () => Promise.resolve({}) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + await management.outboundApplication.uploadTenantApiKey('app123', 'tenant789', 'secret-key'); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.outboundApplication.uploadTenantApiKey, + { appId: 'app123', tenantId: 'tenant789', apiKey: 'secret-key' }, + ); + }); + }); + + describe('uploadUserToken', () => { + it('should send the correct request', async () => { + const httpResponse = { + ok: true, + json: () => ({}), + clone: () => ({ json: () => Promise.resolve({}) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + await management.outboundApplication.uploadUserToken({ + appId: 'app123', + userId: 'user456', + refreshToken: 'refresh', + scopes: ['read'], + verifyRefresh: true, + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.outboundApplication.uploadUserToken, + { + appId: 'app123', + userId: 'user456', + refreshToken: 'refresh', + scopes: ['read'], + verifyRefresh: true, + }, + ); + }); + }); + + describe('uploadTenantToken', () => { + it('should send the correct request', async () => { + const httpResponse = { + ok: true, + json: () => ({}), + clone: () => ({ json: () => Promise.resolve({}) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + await management.outboundApplication.uploadTenantToken({ + appId: 'app123', + tenantId: 'tenant789', + accessToken: 'access', + }); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.outboundApplication.uploadTenantToken, + { appId: 'app123', tenantId: 'tenant789', accessToken: 'access' }, + ); + }); + }); + + describe('batchUploadUserTokens', () => { + it('should send the tokens and return failures', async () => { + const batchResponse = { + failures: [{ appId: 'app123', userId: 'user2', errorCode: 'E152110', reason: 'bad token' }], + }; + const httpResponse = { + ok: true, + json: () => batchResponse, + clone: () => ({ json: () => Promise.resolve(batchResponse) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const tokens = [ + { appId: 'app123', userId: 'user1', accessToken: 'a1' }, + { appId: 'app123', userId: 'user2', accessToken: 'a2' }, + ]; + const resp = await management.outboundApplication.batchUploadUserTokens(tokens); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.outboundApplication.batchUploadUserTokens, + { tokens }, + ); + + expect(resp).toEqual({ + code: 200, + data: batchResponse, + ok: true, + response: httpResponse, + }); + }); + }); + + describe('batchUploadTenantTokens', () => { + it('should send the tokens', async () => { + const batchResponse = { failures: [] }; + const httpResponse = { + ok: true, + json: () => batchResponse, + clone: () => ({ json: () => Promise.resolve(batchResponse) }), + status: 200, + }; + mockHttpClient.post.mockResolvedValue(httpResponse); + + const tokens = [{ appId: 'app123', tenantId: 'tenant1', accessToken: 'a1' }]; + const resp = await management.outboundApplication.batchUploadTenantTokens(tokens); + + expect(mockHttpClient.post).toHaveBeenCalledWith( + apiPaths.outboundApplication.batchUploadTenantTokens, + { tokens }, + ); + + expect(resp.data).toEqual(batchResponse); + }); + }); }); diff --git a/lib/management/outboundapplication.ts b/lib/management/outboundapplication.ts index 8d7e770ff..12af0a501 100644 --- a/lib/management/outboundapplication.ts +++ b/lib/management/outboundapplication.ts @@ -6,6 +6,11 @@ import { FetchOutboundAppTokenOptions, OutboundAppTokenResponse, CreateOutboundAppByTemplateOptions, + OutboundAppUserTokenToUpload, + OutboundAppTenantTokenToUpload, + UploadOutboundAppUserTokenRequest, + UploadOutboundAppTenantTokenRequest, + BatchUploadOutboundAppTokensResponse, } from './types'; type OutboundApplicationResponse = { @@ -130,6 +135,78 @@ const withOutboundApplication = (httpClient: HttpClient) => ({ queryParams: { id }, }), ), + /** + * List the IDs of the outbound applications the given user currently holds a valid token for. + * Replaces calling fetchToken once per app to derive connection status. + * @param userId the user to look up + * @param tenantId optional tenant to scope the lookup to + */ + listAppsWithUserToken: (userId: string, tenantId?: string): Promise> => + transformResponse<{ appIds: string[] }, string[]>( + httpClient.get(apiPaths.outboundApplication.listAppsWithUserToken, { + queryParams: { userId, ...(tenantId ? { tenantId } : {}) }, + }), + (data) => data.appIds, + ), + /** Upload/set a static API key for a user on an apikey-type outbound application. */ + uploadUserApiKey: ( + appId: string, + userId: string, + apiKey: string, + tenantId?: string, + ): Promise> => + transformResponse( + httpClient.post(apiPaths.outboundApplication.uploadUserApiKey, { + appId, + userId, + apiKey, + tenantId, + }), + ), + /** Upload/set a static API key for a tenant on an apikey-type outbound application. */ + uploadTenantApiKey: ( + appId: string, + tenantId: string, + apiKey: string, + ): Promise> => + transformResponse( + httpClient.post(apiPaths.outboundApplication.uploadTenantApiKey, { + appId, + tenantId, + apiKey, + }), + ), + /** + * Upload (migrate) an existing OAuth token for a user on an oauth-type outbound application, + * without requiring the user to re-run the OAuth flow. + */ + uploadUserToken: (token: UploadOutboundAppUserTokenRequest): Promise> => + transformResponse(httpClient.post(apiPaths.outboundApplication.uploadUserToken, { ...token })), + /** Upload (migrate) an existing OAuth token for a tenant on an oauth-type outbound application. */ + uploadTenantToken: (token: UploadOutboundAppTenantTokenRequest): Promise> => + transformResponse( + httpClient.post(apiPaths.outboundApplication.uploadTenantToken, { ...token }), + ), + /** + * Batch upload (migrate) existing OAuth tokens for users. All-or-nothing: if any item fails + * per-item validation, the returned `failures` are populated and no tokens are committed. + */ + batchUploadUserTokens: ( + tokens: OutboundAppUserTokenToUpload[], + ): Promise> => + transformResponse( + httpClient.post(apiPaths.outboundApplication.batchUploadUserTokens, { tokens }), + ), + /** + * Batch upload (migrate) existing OAuth tokens for tenants. All-or-nothing: if any item fails + * per-item validation, the returned `failures` are populated and no tokens are committed. + */ + batchUploadTenantTokens: ( + tokens: OutboundAppTenantTokenToUpload[], + ): Promise> => + transformResponse( + httpClient.post(apiPaths.outboundApplication.batchUploadTenantTokens, { tokens }), + ), }); export default withOutboundApplication; diff --git a/lib/management/paths.ts b/lib/management/paths.ts index 01af98c9b..7566e0bdf 100644 --- a/lib/management/paths.ts +++ b/lib/management/paths.ts @@ -111,6 +111,13 @@ export default { fetchTenantTokenByScopes: '/v1/mgmt/outbound/app/tenant/token', deleteUserTokens: '/v1/mgmt/outbound/user/tokens', deleteTokenById: '/v1/mgmt/outbound/token', + listAppsWithUserToken: '/v1/mgmt/outbound/apps-with-user-token', + uploadUserApiKey: '/v1/mgmt/outbound/app/user/apikey/upload', + uploadTenantApiKey: '/v1/mgmt/outbound/app/tenant/apikey/upload', + uploadUserToken: '/v1/mgmt/outbound/app/user/oauthtoken/upload', + uploadTenantToken: '/v1/mgmt/outbound/app/tenant/oauthtoken/upload', + batchUploadUserTokens: '/v1/mgmt/outbound/app/user/oauthtoken/batch/upload', + batchUploadTenantTokens: '/v1/mgmt/outbound/app/tenant/oauthtoken/batch/upload', }, sso: { settings: '/v1/mgmt/sso/settings', diff --git a/lib/management/types.ts b/lib/management/types.ts index 16a0ce770..fb076cf39 100644 --- a/lib/management/types.ts +++ b/lib/management/types.ts @@ -1162,6 +1162,74 @@ export type FetchLatestOutboundAppTenantTokenRequest = { options?: FetchOutboundAppTokenOptions; }; +/** + * A single OAuth token to upload (migrate) for a user. At least one of `refreshToken` or + * `accessToken` must be provided. `accessTokenExpiry` is in epoch seconds (0/absent means unknown). + * Used both as a single-upload payload and as a batch item. + */ +export type OutboundAppUserTokenToUpload = { + appId: string; + userId: string; + tenantId?: string; + refreshToken?: string; + accessToken?: string; + accessTokenExpiry?: number; + accessTokenType?: string; + scopes?: string[]; + externalIdentifier?: string; + idToken?: string; + grantedBy?: string; +}; + +/** + * A single OAuth token to upload (migrate) for a tenant. At least one of `refreshToken` or + * `accessToken` must be provided. + */ +export type OutboundAppTenantTokenToUpload = { + appId: string; + tenantId: string; + refreshToken?: string; + accessToken?: string; + accessTokenExpiry?: number; + accessTokenType?: string; + scopes?: string[]; + externalIdentifier?: string; + idToken?: string; + grantedBy?: string; +}; + +/** + * Single-upload payload for a user OAuth token. When `verifyRefresh` is true, the refresh token is + * verified against the provider before persisting; nothing is written if verification fails. + */ +export type UploadOutboundAppUserTokenRequest = OutboundAppUserTokenToUpload & { + verifyRefresh?: boolean; +}; + +/** + * Single-upload payload for a tenant OAuth token. See `UploadOutboundAppUserTokenRequest`. + */ +export type UploadOutboundAppTenantTokenRequest = OutboundAppTenantTokenToUpload & { + verifyRefresh?: boolean; +}; + +/** A single per-item failure returned by the batch upload endpoints. */ +export type OutboundAppTokenUploadFailure = { + appId: string; + userId?: string; + tenantId?: string; + errorCode: string; + reason: string; +}; + +/** + * Response from the batch upload endpoints. Batch upload is all-or-nothing: a non-empty `failures` + * array means no tokens were committed. + */ +export type BatchUploadOutboundAppTokensResponse = { + failures: OutboundAppTokenUploadFailure[]; +}; + export type ManagementFlowOptions = { input?: Record; preview?: boolean;