From 0f161f625ea63c3745e2c9c76254028889a97b07 Mon Sep 17 00:00:00 2001 From: dorsha Date: Sat, 27 Jun 2026 10:33:20 +0300 Subject: [PATCH 1/2] feat(outbound): add token/api-key upload and connection-status list methods Wrap the outbound-application management REST endpoints that the SDK didn't expose, on management.outboundApplication: - uploadUserApiKey / uploadTenantApiKey (apikey-type apps) - uploadUserToken / uploadTenantToken + batchUpload{User,Tenant}Tokens (oauth-type migration) - listAppsWithUserToken (connection-status read, replaces N fetchToken calls) Closes descope/etc#16568 Closes descope/etc#16569 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 42 +++++ lib/management/outboundapplication.test.ts | 201 +++++++++++++++++++++ lib/management/outboundapplication.ts | 86 +++++++++ lib/management/paths.ts | 7 + lib/management/types.ts | 68 +++++++ 5 files changed, 404 insertions(+) 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..cb40fd643 100644 --- a/lib/management/outboundapplication.test.ts +++ b/lib/management/outboundapplication.test.ts @@ -725,4 +725,205 @@ 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..44d0988c2 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,87 @@ 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; From 50f704d3ac01a92c3ddc93614b00d1bf27845cc8 Mon Sep 17 00:00:00 2001 From: dorsha Date: Sat, 27 Jun 2026 10:45:40 +0300 Subject: [PATCH 2/2] style(outbound): apply prettier formatting to new outbound methods Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/management/outboundapplication.test.ts | 4 +--- lib/management/outboundapplication.ts | 17 ++++------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/lib/management/outboundapplication.test.ts b/lib/management/outboundapplication.test.ts index cb40fd643..c12b8cfb6 100644 --- a/lib/management/outboundapplication.test.ts +++ b/lib/management/outboundapplication.test.ts @@ -872,9 +872,7 @@ describe('Management OutboundApplication', () => { describe('batchUploadUserTokens', () => { it('should send the tokens and return failures', async () => { const batchResponse = { - failures: [ - { appId: 'app123', userId: 'user2', errorCode: 'E152110', reason: 'bad token' }, - ], + failures: [{ appId: 'app123', userId: 'user2', errorCode: 'E152110', reason: 'bad token' }], }; const httpResponse = { ok: true, diff --git a/lib/management/outboundapplication.ts b/lib/management/outboundapplication.ts index 44d0988c2..12af0a501 100644 --- a/lib/management/outboundapplication.ts +++ b/lib/management/outboundapplication.ts @@ -141,10 +141,7 @@ const withOutboundApplication = (httpClient: HttpClient) => ({ * @param userId the user to look up * @param tenantId optional tenant to scope the lookup to */ - listAppsWithUserToken: ( - userId: string, - tenantId?: string, - ): Promise> => + listAppsWithUserToken: (userId: string, tenantId?: string): Promise> => transformResponse<{ appIds: string[] }, string[]>( httpClient.get(apiPaths.outboundApplication.listAppsWithUserToken, { queryParams: { userId, ...(tenantId ? { tenantId } : {}) }, @@ -183,16 +180,10 @@ const withOutboundApplication = (httpClient: HttpClient) => ({ * 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 }), - ), + 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> => + uploadTenantToken: (token: UploadOutboundAppTenantTokenRequest): Promise> => transformResponse( httpClient.post(apiPaths.outboundApplication.uploadTenantToken, { ...token }), ),