From 464736f77dfd0c8ba51fedd0e925f7d3a01520c1 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:44:27 +0100 Subject: [PATCH 01/16] user token SO --- .../actions/server/constants/saved_objects.ts | 1 + .../shared/actions/server/plugin.test.ts | 11 +++++++ .../plugins/shared/actions/server/plugin.ts | 2 ++ .../actions/server/saved_objects/index.ts | 29 +++++++++++++++++++ .../actions/server/saved_objects/mappings.ts | 15 ++++++++++ .../saved_objects/model_versions/index.ts | 1 + .../user_connector_token_model_versions.ts | 19 ++++++++++++ .../schemas/raw_user_connector_token/index.ts | 8 +++++ .../schemas/raw_user_connector_token/v1.ts | 19 ++++++++++++ 9 files changed, 105 insertions(+) create mode 100644 x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/user_connector_token_model_versions.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/index.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/v1.ts diff --git a/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts b/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts index 62143fc239052..a4b925a395e2a 100644 --- a/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts +++ b/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts @@ -10,3 +10,4 @@ export const ALERT_SAVED_OBJECT_TYPE = 'alert'; export const ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE = 'action_task_params'; export const CONNECTOR_TOKEN_SAVED_OBJECT_TYPE = 'connector_token'; export const OAUTH_STATE_SAVED_OBJECT_TYPE = 'oauth_state'; +export const USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE = 'user_connector_token'; diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.test.ts b/x-pack/platform/plugins/shared/actions/server/plugin.test.ts index 9ffc2a1e9bc60..c97c67bb70356 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.test.ts @@ -32,6 +32,7 @@ import { } from '../common'; import { cloudMock } from '@kbn/cloud-plugin/server/mocks'; import { getConnectorType } from './fixtures'; +import { USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from './constants/saved_objects'; function getConfig(overrides = {}) { return { @@ -147,6 +148,16 @@ describe('Actions Plugin', () => { ); }); + it('should register user_connector_token saved object type and encryption', async () => { + await plugin.setup(coreSetup, pluginsSetup); + expect(coreSetup.savedObjects.registerType).toHaveBeenCalledWith( + expect.objectContaining({ name: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE }) + ); + expect(pluginsSetup.encryptedSavedObjects.registerType).toHaveBeenCalledWith( + expect.objectContaining({ type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE }) + ); + }); + describe('routeHandlerContext.getActionsClient()', () => { it('should not throw error when ESO plugin has encryption key', async () => { await plugin.setup(coreSetup, { diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.ts b/x-pack/platform/plugins/shared/actions/server/plugin.ts index fc5cf797e0131..d873af23a5760 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.ts @@ -80,6 +80,7 @@ import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, ALERT_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, } from './constants/saved_objects'; import { setupSavedObjects } from './saved_objects'; import { ACTIONS_FEATURE } from './feature'; @@ -219,6 +220,7 @@ const includedHiddenTypes = [ ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, ALERT_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, ]; export class ActionsPlugin diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts index 82c342f22d2df..0095c20ce5299 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts @@ -18,6 +18,7 @@ import { actionTaskParamsMappings, connectorTokenMappings, oauthStateMappings, + userConnectorTokenMappings, } from './mappings'; import { getActionsMigrations } from './actions_migrations'; import { getActionTaskParamsMigrations } from './action_task_params_migrations'; @@ -30,12 +31,14 @@ import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, OAUTH_STATE_SAVED_OBJECT_TYPE, + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, } from '../constants/saved_objects'; import { actionTaskParamsModelVersions, connectorModelVersions, connectorTokenModelVersions, oauthStateModelVersions, + userConnectorTokenModelVersions, } from './model_versions'; export function setupSavedObjects( @@ -147,6 +150,32 @@ export function setupSavedObjects( ]), }); + savedObjects.registerType({ + name: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'agnostic', + mappings: userConnectorTokenMappings, + management: { + importableAndExportable: false, + }, + modelVersions: userConnectorTokenModelVersions, + }); + + encryptedSavedObjects.registerType({ + type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + attributesToEncrypt: new Set(['credentials']), + attributesToIncludeInAAD: new Set([ + 'profileUid', + 'connectorId', + 'credentialType', + 'expiresAt', + 'refreshTokenExpiresAt', + 'createdAt', + 'updatedAt', + ]), + }); + savedObjects.registerType({ name: OAUTH_STATE_SAVED_OBJECT_TYPE, indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts index d23836ef00487..3354411bbb6c7 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts @@ -101,6 +101,21 @@ export const connectorTokenMappings: SavedObjectsTypeMappingDefinition = { }, }; +export const userConnectorTokenMappings: SavedObjectsTypeMappingDefinition = { + dynamic: false, + properties: { + profileUid: { + type: 'keyword', + }, + connectorId: { + type: 'keyword', + }, + credentialType: { + type: 'keyword', + }, + }, +}; + export const oauthStateMappings: SavedObjectsTypeMappingDefinition = { dynamic: false, properties: { diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts index 60a0cb4fe4f3f..e43386a5cbc4b 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts @@ -9,3 +9,4 @@ export { connectorModelVersions } from './connector_model_versions'; export { connectorTokenModelVersions } from './connector_token_model_versions'; export { actionTaskParamsModelVersions } from './action_task_params_model_versions'; export { oauthStateModelVersions } from './oauth_state_model_versions'; +export { userConnectorTokenModelVersions } from './user_connector_token_model_versions'; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/user_connector_token_model_versions.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/user_connector_token_model_versions.ts new file mode 100644 index 0000000000000..4254512d285fa --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/user_connector_token_model_versions.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawUserConnectorTokenSchemaV1 } from '../schemas/raw_user_connector_token'; + +export const userConnectorTokenModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawUserConnectorTokenSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawUserConnectorTokenSchemaV1, + }, + }, +}; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/index.ts new file mode 100644 index 0000000000000..267870838511f --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { rawUserConnectorTokenSchema as rawUserConnectorTokenSchemaV1 } from './v1'; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/v1.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/v1.ts new file mode 100644 index 0000000000000..af70b5faef139 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_user_connector_token/v1.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const rawUserConnectorTokenSchema = schema.object({ + profileUid: schema.string(), + connectorId: schema.string(), + credentialType: schema.string(), + credentials: schema.string({ defaultValue: '{}' }), + expiresAt: schema.maybe(schema.string()), + refreshTokenExpiresAt: schema.maybe(schema.string()), + createdAt: schema.string(), + updatedAt: schema.string(), +}); From f6762a2a533776ca5959abc4094d286779010d31 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:38:49 +0100 Subject: [PATCH 02/16] separated user_connector_token_client --- .../server/lib/connector_token_client.mock.ts | 1 + .../server/lib/connector_token_client.ts | 443 +++--------- .../lib/shared_connector_token_client.test.ts | 308 ++++++++ .../lib/shared_connector_token_client.ts | 436 ++++++++++++ .../lib/user_connector_token_client.test.ts | 587 ++++++++++++++++ .../server/lib/user_connector_token_client.ts | 655 ++++++++++++++++++ .../actions/server/routes/oauth_callback.ts | 4 +- .../plugins/shared/actions/server/types.ts | 22 + 8 files changed, 2115 insertions(+), 341 deletions(-) create mode 100644 x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts create mode 100644 x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts index e22d5f326d89f..0a3dd9be6017a 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts @@ -12,6 +12,7 @@ const createConnectorTokenClientMock = () => { const mocked: jest.Mocked> = { create: jest.fn(), get: jest.fn(), + getOAuthPersonalToken: jest.fn(), update: jest.fn(), deleteConnectorTokens: jest.fn(), updateOrReplace: jest.fn(), diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index 295709228d2ce..7069a644e6c85 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -5,16 +5,13 @@ * 2.0. */ -import { omitBy, isUndefined } from 'lodash'; import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; -import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; -import { SavedObjectsUtils } from '@kbn/core/server'; -import { retryIfConflicts } from './retry_if_conflicts'; -import type { ConnectorToken } from '../types'; -import { CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; +import type { Logger, SavedObjectsClientContract, SavedObjectAttributes } from '@kbn/core/server'; +import type { ConnectorToken, UserConnectorToken, UserConnectorOAuthToken } from '../types'; +import { SharedConnectorTokenClient } from './shared_connector_token_client'; +import { UserConnectorTokenClient } from './user_connector_token_client'; export const MAX_TOKENS_RETURNED = 1; -const MAX_RETRY_ATTEMPTS = 3; interface ConstructorOptions { encryptedSavedObjectsClient: EncryptedSavedObjectsClient; @@ -23,22 +20,28 @@ interface ConstructorOptions { } interface CreateOptions { + profileUid?: string; connectorId: string; - token: string; + token?: string; + credentials?: SavedObjectAttributes; expiresAtMillis?: string; tokenType?: string; + credentialType?: string; } export interface UpdateOptions { id: string; - token: string; + token?: string; + credentials?: SavedObjectAttributes; expiresAtMillis?: string; tokenType?: string; + credentialType?: string; } interface UpdateOrReplaceOptions { + profileUid?: string; connectorId: string; - token: ConnectorToken | null; + token: ConnectorToken | UserConnectorToken | null; newToken: string; expiresInSec?: number; tokenRequestDate: number; @@ -46,391 +49,153 @@ interface UpdateOrReplaceOptions { } export class ConnectorTokenClient { - private readonly logger: Logger; - private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; - private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + private readonly sharedClient: SharedConnectorTokenClient; + private readonly userClient: UserConnectorTokenClient; - constructor({ - unsecuredSavedObjectsClient, - encryptedSavedObjectsClient, - logger, - }: ConstructorOptions) { - this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; - this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; - this.logger = logger; + constructor(options: ConstructorOptions) { + this.sharedClient = new SharedConnectorTokenClient(options); + this.userClient = new UserConnectorTokenClient(options); + } + + private parseTokenId(id: string): { scope: 'personal' | 'shared'; actualId: string } { + if (id.startsWith('personal:')) { + return { scope: 'personal', actualId: id.substring(9) }; + } + if (id.startsWith('shared:')) { + return { scope: 'shared', actualId: id.substring(7) }; + } + return { scope: 'shared', actualId: id }; } /** - * Create new token for connector + * Create new token for connector (delegates to shared or user client) */ - public async create({ - connectorId, - token, - expiresAtMillis, - tokenType, - }: CreateOptions): Promise { - const id = SavedObjectsUtils.generateId(); - const createTime = Date.now(); - try { - const result = await this.unsecuredSavedObjectsClient.create( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - { - connectorId, - token, - expiresAt: expiresAtMillis, - tokenType: tokenType ?? 'access_token', - createdAt: new Date(createTime).toISOString(), - updatedAt: new Date(createTime).toISOString(), - }, - { id } - ); - - return result.attributes as ConnectorToken; - } catch (err) { - this.logger.error( - `Failed to create connector_token for connectorId "${connectorId}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: ${err.message}` - ); - throw err; + public async create(options: CreateOptions): Promise { + if (options.profileUid) { + return this.userClient.create(options as Parameters[0]); } + return this.sharedClient.create(options as Parameters[0]); } /** - * Update connector token + * Update connector token (delegates based on id prefix) */ - public async update({ - id, - token, - expiresAtMillis, - tokenType, - }: UpdateOptions): Promise { - const { attributes, references, version } = - await this.unsecuredSavedObjectsClient.get( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - id - ); - const createTime = Date.now(); - - try { - const updateOperation = () => { - // Exclude id from attributes since it's saved object metadata, not document data - const { id: _id, ...attributesWithoutId } = attributes; - return this.unsecuredSavedObjectsClient.create( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - { - ...attributesWithoutId, - token, - expiresAt: expiresAtMillis, - tokenType: tokenType ?? 'access_token', - updatedAt: new Date(createTime).toISOString(), - }, - omitBy( - { - id, - overwrite: true, - references, - version, - }, - isUndefined - ) - ); - }; - - const result = await retryIfConflicts( - this.logger, - `accessToken.create('${id}')`, - updateOperation, - MAX_RETRY_ATTEMPTS - ); - - return result.attributes as ConnectorToken; - } catch (err) { - this.logger.error( - `Failed to update connector_token for id "${id}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: ${err.message}` - ); - throw err; + public async update(options: UpdateOptions): Promise { + const { scope, actualId } = this.parseTokenId(options.id); + if (scope === 'personal') { + return this.userClient.update({ ...options, id: actualId }); } + if (options.token) { + return this.sharedClient.update({ + id: actualId, + token: options.token, + expiresAtMillis: options.expiresAtMillis, + tokenType: options.tokenType, + }); + } + throw new Error('Token is required for shared connector token update'); } /** - * Get connector token + * Get connector token (delegates to shared or user client) */ - public async get({ - connectorId, - tokenType, - }: { + public async get(options: { + profileUid?: string; connectorId: string; tokenType?: string; + credentialType?: string; }): Promise<{ hasErrors: boolean; - connectorToken: ConnectorToken | null; + connectorToken: ConnectorToken | UserConnectorToken | null; }> { - const connectorTokensResult = []; - const tokenTypeFilter = tokenType - ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` - : ''; - - try { - connectorTokensResult.push( - ...( - await this.unsecuredSavedObjectsClient.find({ - perPage: MAX_TOKENS_RETURNED, - type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, - sortField: 'updated_at', - sortOrder: 'desc', - }) - ).saved_objects - ); - } catch (err) { - this.logger.error( - `Failed to fetch connector_token for connectorId "${connectorId}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: ${err.message}` - ); - return { hasErrors: true, connectorToken: null }; - } - - if (connectorTokensResult.length === 0) { - return { hasErrors: false, connectorToken: null }; - } - - let accessToken: string; - try { - const { - attributes: { token }, - } = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - connectorTokensResult[0].id - ); - - accessToken = token; - } catch (err) { - this.logger.error( - `Failed to decrypt connector_token for connectorId "${connectorId}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: ${err.message}` - ); - return { hasErrors: true, connectorToken: null }; - } - - if ( - connectorTokensResult[0].attributes.expiresAt && - isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt)) - ) { - this.logger.error( - `Failed to get connector_token for connectorId "${connectorId}" and tokenType: "${ - tokenType ?? 'access_token' - }". Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"` - ); - return { hasErrors: true, connectorToken: null }; + if (options.profileUid) { + return this.userClient.get(options as Parameters[0]); } + return this.sharedClient.get(options as Parameters[0]); + } - return { - hasErrors: false, - connectorToken: { - id: connectorTokensResult[0].id, - ...connectorTokensResult[0].attributes, - token: accessToken, - }, - }; + /** + * Get OAuth personal token with parsed credentials + */ + public async getOAuthPersonalToken(options: { + profileUid: string; + connectorId: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: UserConnectorOAuthToken | null; + }> { + return this.userClient.getOAuthPersonalToken(options); } /** - * Delete all connector tokens + * Delete all connector tokens (delegates to shared or user client) */ - public async deleteConnectorTokens({ - connectorId, - tokenType, - }: { + public async deleteConnectorTokens(options: { + profileUid?: string; connectorId: string; tokenType?: string; + credentialType?: string; }) { - const tokenTypeFilter = tokenType - ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` - : ''; - try { - const result = await this.unsecuredSavedObjectsClient.find({ - type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, - }); - return Promise.all( - result.saved_objects.map( - async (obj) => - await this.unsecuredSavedObjectsClient.delete(CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, obj.id) - ) - ); - } catch (err) { - this.logger.error( - `Failed to delete connector_token records for connectorId "${connectorId}". Error: ${err.message}` + if (options.profileUid) { + return this.userClient.deleteConnectorTokens( + options as Parameters[0] ); - throw err; } + return this.sharedClient.deleteConnectorTokens( + options as Parameters[0] + ); } - public async updateOrReplace({ - connectorId, - token, - newToken, - expiresInSec, - tokenRequestDate, - deleteExisting, - }: UpdateOrReplaceOptions) { - expiresInSec = expiresInSec ?? 3600; - tokenRequestDate = tokenRequestDate ?? Date.now(); - if (token === null) { - if (deleteExisting) { - await this.deleteConnectorTokens({ - connectorId, - tokenType: 'access_token', - }); - } - - await this.create({ - connectorId, - token: newToken, - expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), - tokenType: 'access_token', - }); - } else { - await this.update({ - id: token.id!, - token: newToken, - expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), - tokenType: 'access_token', - }); + public async updateOrReplace(options: UpdateOrReplaceOptions) { + if (options.profileUid) { + return this.userClient.updateOrReplace( + options as Parameters[0] + ); } + return this.sharedClient.updateOrReplace( + options as Parameters[0] + ); } /** - * Create new token with refresh token support + * Create new token with refresh token support (delegates to shared or user client) */ - public async createWithRefreshToken({ - connectorId, - accessToken, - refreshToken, - expiresIn, - refreshTokenExpiresIn, - tokenType, - }: { + public async createWithRefreshToken(options: { + profileUid?: string; connectorId: string; accessToken: string; refreshToken?: string; expiresIn?: number; refreshTokenExpiresIn?: number; tokenType?: string; - }): Promise { - const id = SavedObjectsUtils.generateId(); - const now = Date.now(); - const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; - const refreshTokenExpiresInMillis = refreshTokenExpiresIn - ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() - : undefined; - - try { - const result = await this.unsecuredSavedObjectsClient.create( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - omitBy( - { - connectorId, - token: accessToken, - refreshToken, - expiresAt: expiresInMillis, - refreshTokenExpiresAt: refreshTokenExpiresInMillis, - tokenType: tokenType ?? 'access_token', - createdAt: new Date(now).toISOString(), - updatedAt: new Date(now).toISOString(), - }, - isUndefined - ), - { id } - ); - - return result.attributes as ConnectorToken; - } catch (err) { - this.logger.error( - `Failed to create connector_token with refresh token for connectorId "${connectorId}". Error: ${err.message}` + credentialType?: string; + }): Promise { + if (options.profileUid) { + return this.userClient.createWithRefreshToken( + options as Parameters[0] ); - throw err; } + return this.sharedClient.createWithRefreshToken( + options as Parameters[0] + ); } /** - * Update token with refresh token + * Update token with refresh token (delegates based on id prefix) */ - public async updateWithRefreshToken({ - id, - token, - refreshToken, - expiresIn, - refreshTokenExpiresIn, - tokenType, - }: { + public async updateWithRefreshToken(options: { id: string; token: string; refreshToken?: string; expiresIn?: number; refreshTokenExpiresIn?: number; tokenType?: string; - }): Promise { - const { attributes, references, version } = - await this.unsecuredSavedObjectsClient.get( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - id - ); - const now = Date.now(); - const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; - const refreshTokenExpiresInMillis = refreshTokenExpiresIn - ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() - : undefined; - - try { - const updateOperation = () => { - // Exclude id from attributes since it's saved object metadata, not document data - const { id: _id, ...attributesWithoutId } = attributes; - return this.unsecuredSavedObjectsClient.create( - CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - omitBy( - { - ...attributesWithoutId, - token, - refreshToken: refreshToken ?? attributes.refreshToken, - expiresAt: expiresInMillis, - refreshTokenExpiresAt: - refreshTokenExpiresInMillis ?? attributes.refreshTokenExpiresAt, - tokenType: tokenType ?? 'access_token', - updatedAt: new Date(now).toISOString(), - }, - isUndefined - ) as ConnectorToken, - omitBy( - { - id, - overwrite: true, - references, - version, - }, - isUndefined - ) - ); - }; - - const result = await retryIfConflicts( - this.logger, - `accessToken.updateWithRefreshToken('${id}')`, - updateOperation, - MAX_RETRY_ATTEMPTS - ); - - return result.attributes as ConnectorToken; - } catch (err) { - this.logger.error( - `Failed to update connector_token with refresh token for id "${id}". Error: ${err.message}` - ); - throw err; + credentialType?: string; + }): Promise { + const { scope, actualId } = this.parseTokenId(options.id); + if (scope === 'personal') { + return this.userClient.updateWithRefreshToken({ ...options, id: actualId }); } + return this.sharedClient.updateWithRefreshToken({ ...options, id: actualId }); } } diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts new file mode 100644 index 0000000000000..8e37d3cc087d3 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts @@ -0,0 +1,308 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import sinon from 'sinon'; +import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { SharedConnectorTokenClient } from './shared_connector_token_client'; +import type { Logger } from '@kbn/core/server'; +import type { ConnectorToken } from '../types'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; +jest.mock('@kbn/core-saved-objects-utils-server', () => { + const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); + return { + ...actual, + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, + }; +}); + +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); + +let sharedClient: SharedConnectorTokenClient; +let clock: sinon.SinonFakeTimers; + +beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); +}); +beforeEach(() => { + clock.reset(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + sharedClient = new SharedConnectorTokenClient({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }); +}); +afterAll(() => clock.restore()); + +describe('SharedConnectorTokenClient', () => { + describe('create()', () => { + test('creates connector_token with all given properties', async () => { + const expiresAt = new Date().toISOString(); + const savedObjectCreateResult = { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }, + references: [], + }; + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + const result = await sharedClient.create({ + connectorId: '123', + expiresAtMillis: expiresAt, + token: 'testtokenvalue', + }); + expect(result).toEqual({ + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( + 'testtokenvalue' + ); + }); + }); + + describe('get()', () => { + test('retrieves and decrypts connector_token', async () => { + const expiresAt = new Date().toISOString(); + const createdAt = new Date().toISOString(); + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + createdAt, + expiresAt, + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + token: 'testtokenvalue', + }, + }); + const result = await sharedClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + expect(result).toEqual({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + }); + }); + + test('returns null if no tokens found', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }); + + const result = await sharedClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + expect(result).toEqual({ connectorToken: null, hasErrors: false }); + }); + }); + + describe('update()', () => { + test('updates connector token', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }, + references: [], + }); + + const result = await sharedClient.update({ + id: '1', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAtMillis: expiresAt, + }); + expect(result).toEqual({ + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt, + }); + }); + }); + + describe('deleteConnectorTokens()', () => { + test('deletes all tokens for connector', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValue(expectedResult); + + const findResult = { + total: 2, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token1', + type: 'connector_token', + attributes: { + connectorId: '1', + tokenType: 'access_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + { + id: 'token2', + type: 'connector_token', + attributes: { + connectorId: '1', + tokenType: 'refresh_token', + createdAt: new Date().toISOString(), + expiresAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); + const result = await sharedClient.deleteConnectorTokens({ connectorId: '1' }); + expect(JSON.stringify(result)).toEqual(JSON.stringify([Symbol(), Symbol()])); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(2); + }); + }); + + describe('createWithRefreshToken()', () => { + test('creates token with refresh token', async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + expiresAt, + }, + references: [], + }); + + const result = await sharedClient.createWithRefreshToken({ + connectorId: '123', + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + expiresIn: 3600, + }); + + expect(result).toMatchObject({ + connectorId: '123', + token: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + }); + }); + }); + + describe('updateWithRefreshToken()', () => { + test('updates token with refresh token', async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'oldtoken', + refreshToken: 'oldrefresh', + createdAt: new Date().toISOString(), + }, + references: [], + }); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'newtoken', + refreshToken: 'newrefresh', + expiresAt, + }, + references: [], + }); + + const result = await sharedClient.updateWithRefreshToken({ + id: '1', + token: 'newtoken', + refreshToken: 'newrefresh', + expiresIn: 3600, + }); + + expect(result).toMatchObject({ + connectorId: '123', + token: 'newtoken', + refreshToken: 'newrefresh', + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts new file mode 100644 index 0000000000000..e2af0a0da8704 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts @@ -0,0 +1,436 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omitBy, isUndefined } from 'lodash'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; +import { retryIfConflicts } from './retry_if_conflicts'; +import type { ConnectorToken } from '../types'; +import { CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; + +export const MAX_TOKENS_RETURNED = 1; +const MAX_RETRY_ATTEMPTS = 3; + +interface ConstructorOptions { + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +interface CreateOptions { + connectorId: string; + token: string; + expiresAtMillis?: string; + tokenType?: string; +} + +export interface UpdateOptions { + id: string; + token: string; + expiresAtMillis?: string; + tokenType?: string; +} + +interface UpdateOrReplaceOptions { + connectorId: string; + token: ConnectorToken | null; + newToken: string; + expiresInSec?: number; + tokenRequestDate: number; + deleteExisting: boolean; +} + +export class SharedConnectorTokenClient { + private readonly logger: Logger; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + + constructor({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }: ConstructorOptions) { + this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.logger = logger; + } + + /** + * Create new token for connector + */ + public async create({ + connectorId, + token, + expiresAtMillis, + tokenType, + }: CreateOptions): Promise { + const id = SavedObjectsUtils.generateId(); + const createTime = Date.now(); + try { + const result = await this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + { + connectorId, + token, + expiresAt: expiresAtMillis, + tokenType: tokenType ?? 'access_token', + createdAt: new Date(createTime).toISOString(), + updatedAt: new Date(createTime).toISOString(), + }, + { id } + ); + + return result.attributes as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update connector token + */ + public async update({ + id, + token, + expiresAtMillis, + tokenType, + }: UpdateOptions): Promise { + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + id + ); + const createTime = Date.now(); + + try { + const updateOperation = () => { + // Exclude id from attributes since it's saved object metadata, not document data + const { id: _id, ...attributesWithoutId } = attributes; + return this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + { + ...attributesWithoutId, + token, + expiresAt: expiresAtMillis, + tokenType: tokenType ?? 'access_token', + updatedAt: new Date(createTime).toISOString(), + }, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `accessToken.create('${id}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return result.attributes as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update connector_token for id "${id}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Get connector token + */ + public async get({ + connectorId, + tokenType, + }: { + connectorId: string; + tokenType?: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: ConnectorToken | null; + }> { + const connectorTokensResult = []; + const tokenTypeFilter = tokenType + ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` + : ''; + + try { + connectorTokensResult.push( + ...( + await this.unsecuredSavedObjectsClient.find({ + perPage: MAX_TOKENS_RETURNED, + type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, + sortField: 'updated_at', + sortOrder: 'desc', + }) + ).saved_objects + ); + } catch (err) { + this.logger.error( + `Failed to fetch connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + + if (connectorTokensResult.length === 0) { + return { hasErrors: false, connectorToken: null }; + } + + let accessToken: string; + try { + const { + attributes: { token }, + } = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + connectorTokensResult[0].id + ); + + accessToken = token; + } catch (err) { + this.logger.error( + `Failed to decrypt connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + + if ( + connectorTokensResult[0].attributes.expiresAt && + isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt)) + ) { + this.logger.error( + `Failed to get connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"` + ); + return { hasErrors: true, connectorToken: null }; + } + + return { + hasErrors: false, + connectorToken: { + id: connectorTokensResult[0].id, + ...connectorTokensResult[0].attributes, + token: accessToken, + }, + }; + } + + /** + * Delete all connector tokens + */ + public async deleteConnectorTokens({ + connectorId, + tokenType, + }: { + connectorId: string; + tokenType?: string; + }) { + const tokenTypeFilter = tokenType + ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` + : ''; + try { + const result = await this.unsecuredSavedObjectsClient.find({ + type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, + }); + return Promise.all( + result.saved_objects.map( + async (obj) => + await this.unsecuredSavedObjectsClient.delete(CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, obj.id) + ) + ); + } catch (err) { + this.logger.error( + `Failed to delete connector_token records for connectorId "${connectorId}". Error: ${err.message}` + ); + throw err; + } + } + + public async updateOrReplace({ + connectorId, + token, + newToken, + expiresInSec, + tokenRequestDate, + deleteExisting, + }: UpdateOrReplaceOptions) { + expiresInSec = expiresInSec ?? 3600; + tokenRequestDate = tokenRequestDate ?? Date.now(); + if (token === null) { + if (deleteExisting) { + await this.deleteConnectorTokens({ + connectorId, + tokenType: 'access_token', + }); + } + + await this.create({ + connectorId, + token: newToken, + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), + tokenType: 'access_token', + }); + } else { + await this.update({ + id: token.id!, + token: newToken, + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), + tokenType: 'access_token', + }); + } + } + + /** + * Create new token with refresh token support + */ + public async createWithRefreshToken({ + connectorId, + accessToken, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + }: { + connectorId: string; + accessToken: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + }): Promise { + const id = SavedObjectsUtils.generateId(); + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + try { + const result = await this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + omitBy( + { + connectorId, + token: accessToken, + refreshToken, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: refreshTokenExpiresInMillis, + tokenType: tokenType ?? 'access_token', + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + }, + isUndefined + ), + { id } + ); + + return result.attributes as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create connector_token with refresh token for connectorId "${connectorId}". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update token with refresh token + */ + public async updateWithRefreshToken({ + id, + token, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + }: { + id: string; + token: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + }): Promise { + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + id + ); + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + try { + const updateOperation = () => { + // Exclude id from attributes since it's saved object metadata, not document data + const { id: _id, ...attributesWithoutId } = attributes; + return this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + omitBy( + { + ...attributesWithoutId, + token, + refreshToken: refreshToken ?? attributes.refreshToken, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: + refreshTokenExpiresInMillis ?? attributes.refreshTokenExpiresAt, + tokenType: tokenType ?? 'access_token', + updatedAt: new Date(now).toISOString(), + }, + isUndefined + ) as ConnectorToken, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `accessToken.updateWithRefreshToken('${id}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return result.attributes as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update connector_token with refresh token for id "${id}". Error: ${err.message}` + ); + throw err; + } + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts new file mode 100644 index 0000000000000..df2e019545790 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts @@ -0,0 +1,587 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import sinon from 'sinon'; +import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { UserConnectorTokenClient } from './user_connector_token_client'; +import type { Logger } from '@kbn/core/server'; +import type { UserConnectorToken } from '../types'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; +jest.mock('@kbn/core-saved-objects-utils-server', () => { + const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); + return { + ...actual, + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, + }; +}); + +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); + +let userClient: UserConnectorTokenClient; +let clock: sinon.SinonFakeTimers; + +beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); +}); +beforeEach(() => { + clock.reset(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + userClient = new UserConnectorTokenClient({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }); +}); +afterAll(() => clock.restore()); + +describe('UserConnectorTokenClient', () => { + describe('create()', () => { + test('creates user_connector_token with profileUid and credentials', async () => { + const expiresAt = new Date().toISOString(); + const savedObjectCreateResult = { + id: 'mock-saved-object-id', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + expiresAt, + createdAt: '2021-01-01T12:00:00.000Z', + updatedAt: '2021-01-01T12:00:00.000Z', + }, + references: [], + }; + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + const result = await userClient.create({ + profileUid: 'user-profile-123', + connectorId: '123', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + expiresAtMillis: expiresAt, + credentialType: 'oauth', + }); + + expect(result).toMatchObject({ + id: 'personal:mock-saved-object-id', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + expiresAt, + }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'user_connector_token', + expect.objectContaining({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }), + { id: 'mock-saved-object-id' } + ); + }); + + test('throws error if credentials are empty', async () => { + await expect( + userClient.create({ + profileUid: 'user-profile-123', + connectorId: '123', + credentials: {}, + }) + ).rejects.toThrow('Personal credentials are required to create a user connector token'); + }); + }); + + describe('get()', () => { + test('retrieves personal token by profileUid and connectorId', async () => { + const expiresAt = new Date().toISOString(); + const createdAt = new Date().toISOString(); + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + score: 1, + references: [], + }, + ], + }; + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + references: [], + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + }); + + const result = await userClient.get({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }); + + expect(result).toEqual({ + hasErrors: false, + connectorToken: { + id: 'personal:token-id-1', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + }); + }); + + test('returns null if no tokens found', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 0, + per_page: 10, + page: 1, + saved_objects: [], + }); + + const result = await userClient.get({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }); + + expect(result).toEqual({ connectorToken: null, hasErrors: false }); + }); + }); + + describe('getOAuthPersonalToken()', () => { + test('retrieves and parses OAuth credentials', async () => { + const expiresAt = new Date().toISOString(); + const createdAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + score: 1, + references: [], + }, + ], + }); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + references: [], + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + }, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + }); + + const result = await userClient.getOAuthPersonalToken({ + profileUid: 'user-profile-123', + connectorId: '123', + }); + + expect(result).toEqual({ + hasErrors: false, + connectorToken: { + id: 'personal:token-id-1', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'test-access-token', + refreshToken: 'test-refresh-token', + }, + createdAt, + expiresAt, + updatedAt: createdAt, + }, + }); + }); + + test('returns error if credentials are invalid', async () => { + const createdAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt, + updatedAt: createdAt, + }, + score: 1, + references: [], + }, + ], + }); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + references: [], + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + invalid: 'shape', + }, + createdAt, + updatedAt: createdAt, + }, + }); + + const result = await userClient.getOAuthPersonalToken({ + profileUid: 'user-profile-123', + connectorId: '123', + }); + + expect(result).toEqual({ + hasErrors: true, + connectorToken: null, + }); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid OAuth credentials shape') + ); + }); + }); + + describe('deleteConnectorTokens()', () => { + test('deletes personal tokens for profileUid and connectorId', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValue(expectedResult); + + const findResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + ], + }; + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); + await userClient.deleteConnectorTokens({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }); + + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith( + 'user_connector_token', + 'token1' + ); + }); + }); + + describe('createWithRefreshToken()', () => { + test('creates personal token with refresh token', async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'mock-saved-object-id', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + expiresAt, + createdAt: '2021-01-01T12:00:00.000Z', + updatedAt: '2021-01-01T12:00:00.000Z', + }, + references: [], + }); + + const result = await userClient.createWithRefreshToken({ + profileUid: 'user-profile-123', + connectorId: '123', + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + expiresIn: 3600, + }); + + expect(result).toMatchObject({ + id: 'personal:mock-saved-object-id', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'user_connector_token', + expect.objectContaining({ + profileUid: 'user-profile-123', + connectorId: '123', + credentials: { + accessToken: 'Bearer testtokenvalue', + refreshToken: 'testrefreshtoken', + }, + }), + { id: 'mock-saved-object-id' } + ); + }); + }); + + describe('update()', () => { + test('updates personal token with personal: prefix in id', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'oldtoken', + refreshToken: 'oldrefresh', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [], + }); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'newtoken', + }, + expiresAt, + createdAt: new Date().toISOString(), + updatedAt: '2021-01-01T12:00:00.000Z', + }, + references: [], + }); + + const result = await userClient.update({ + id: 'personal:token-id-1', + token: 'newtoken', + expiresAtMillis: expiresAt, + }); + + expect(result).toMatchObject({ + id: 'personal:token-id-1', + profileUid: 'user-profile-123', + connectorId: '123', + credentials: { + accessToken: 'newtoken', + }, + }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith( + 'user_connector_token', + 'token-id-1' + ); + }); + }); + + describe('updateWithRefreshToken()', () => { + test('updates personal token with new refresh token', async () => { + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'oldtoken', + refreshToken: 'oldrefresh', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [], + }); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'token-id-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { + accessToken: 'newtoken', + refreshToken: 'newrefresh', + }, + expiresAt, + createdAt: new Date().toISOString(), + updatedAt: '2021-01-01T12:00:00.000Z', + }, + references: [], + }); + + const result = await userClient.updateWithRefreshToken({ + id: 'personal:token-id-1', + token: 'newtoken', + refreshToken: 'newrefresh', + expiresIn: 3600, + }); + + expect(result).toMatchObject({ + id: 'personal:token-id-1', + credentials: { + accessToken: 'newtoken', + refreshToken: 'newrefresh', + }, + }); + }); + }); + + describe('deleteConnectorTokens()', () => { + test('deletes all personal tokens for profileUid and connectorId', async () => { + const expectedResult = Symbol(); + unsecuredSavedObjectsClient.delete.mockResolvedValue(expectedResult); + + const findResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: 'token1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + score: 1, + references: [], + }, + ], + }; + + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); + await userClient.deleteConnectorTokens({ + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + }); + + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith( + 'user_connector_token', + 'token1' + ); + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'user_connector_token', + filter: expect.stringContaining('profileUid: "user-profile-123"'), + }) + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts new file mode 100644 index 0000000000000..1b3195b4a012a --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts @@ -0,0 +1,655 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omitBy, isUndefined } from 'lodash'; +import { z } from '@kbn/zod/v4'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { Logger, SavedObjectsClientContract, SavedObjectAttributes } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; +import { retryIfConflicts } from './retry_if_conflicts'; +import type { + UserConnectorToken, + OAuthPersonalCredentials, + UserConnectorOAuthToken, +} from '../types'; +import { USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; + +export const MAX_TOKENS_RETURNED = 1; +const MAX_RETRY_ATTEMPTS = 3; + +interface ConstructorOptions { + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +interface CreateOptions { + profileUid: string; + connectorId: string; + token?: string; + credentials?: SavedObjectAttributes; + expiresAtMillis?: string; + tokenType?: string; + credentialType?: string; +} + +export interface UpdateOptions { + id: string; + token?: string; + credentials?: SavedObjectAttributes; + expiresAtMillis?: string; + tokenType?: string; + credentialType?: string; +} + +interface UpdateOrReplaceOptions { + profileUid: string; + connectorId: string; + token: UserConnectorToken | null; + newToken: string; + expiresInSec?: number; + tokenRequestDate: number; + deleteExisting: boolean; +} + +interface PersonalTokenAttributes { + connectorId: string; + profileUid: string; + credentialType: string; + credentials: SavedObjectAttributes; + expiresAt?: string; + refreshTokenExpiresAt?: string; + createdAt: string; + updatedAt: string; +} + +export class UserConnectorTokenClient { + private readonly logger: Logger; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + + constructor({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }: ConstructorOptions) { + this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.logger = logger; + } + + private parseTokenId(id: string): { scope: 'personal' | 'shared'; actualId: string } { + if (id.startsWith('personal:')) { + return { scope: 'personal', actualId: id.substring(9) }; + } + if (id.startsWith('shared:')) { + return { scope: 'shared', actualId: id.substring(7) }; + } + return { scope: 'shared', actualId: id }; + } + + private formatTokenId(rawId: string): string { + return `personal:${rawId}`; + } + + private getContextString( + profileUid?: string, + connectorId?: string, + credentialType?: string + ): string { + const parts = []; + if (profileUid) parts.push(`profileUid "${profileUid}"`); + if (connectorId) parts.push(`connectorId "${connectorId}"`); + if (credentialType) parts.push(`credentialType: "${credentialType}"`); + return parts.join(', '); + } + + private parseOAuthPersonalCredentials(credentials: unknown): OAuthPersonalCredentials | null { + const schema = z.object({ + accessToken: z.string(), + refreshToken: z.string().optional(), + }); + + const parsed = schema.safeParse(credentials); + return parsed.success ? parsed.data : null; + } + + /** + * Create new personal token for connector + */ + public async create({ + profileUid, + connectorId, + token, + credentials, + expiresAtMillis, + tokenType, + credentialType, + }: CreateOptions): Promise { + const rawId = SavedObjectsUtils.generateId(); + const createTime = Date.now(); + const resolvedCredentialType = credentialType ?? tokenType ?? 'oauth'; + + const resolvedCredentials = + credentials ?? (token ? { accessToken: token } : ({} as SavedObjectAttributes)); + + if (Object.keys(resolvedCredentials).length === 0) { + throw new Error('Personal credentials are required to create a user connector token'); + } + + const context = this.getContextString(profileUid, connectorId, resolvedCredentialType); + + const attributes: PersonalTokenAttributes = { + connectorId, + profileUid, + credentialType: resolvedCredentialType, + credentials: resolvedCredentials, + expiresAt: expiresAtMillis, + createdAt: new Date(createTime).toISOString(), + updatedAt: new Date(createTime).toISOString(), + }; + + try { + const result = await this.unsecuredSavedObjectsClient.create( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + attributes, + { id: rawId } + ); + + return { + ...result.attributes, + id: this.formatTokenId(rawId), + } as UserConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create user_connector_token for ${context}. Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update personal connector token + */ + public async update({ + id, + token, + credentials, + expiresAtMillis, + tokenType, + credentialType, + }: UpdateOptions): Promise { + const { actualId } = this.parseTokenId(id); + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + actualId + ); + const createTime = Date.now(); + + const existingAttrs = attributes as PersonalTokenAttributes; + const profileUid = existingAttrs.profileUid; + const context = this.getContextString( + profileUid, + existingAttrs.connectorId, + credentialType ?? existingAttrs.credentialType + ); + + try { + const updateOperation = () => { + const { id: _id, ...attributesWithoutId } = attributes; + const resolvedCredentialType = + credentialType ?? (attributesWithoutId as PersonalTokenAttributes).credentialType; + const resolvedCredentials = + credentials ?? + (token + ? { accessToken: token } + : (attributesWithoutId as PersonalTokenAttributes).credentials); + + if (Object.keys(resolvedCredentials).length === 0) { + throw new Error('Personal credentials are required to update a user connector token'); + } + + const updatedAttributes: PersonalTokenAttributes = { + ...(attributesWithoutId as PersonalTokenAttributes), + credentialType: resolvedCredentialType, + credentials: resolvedCredentials, + expiresAt: expiresAtMillis, + updatedAt: new Date(createTime).toISOString(), + }; + + return this.unsecuredSavedObjectsClient.create( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + updatedAttributes as UserConnectorToken, + omitBy( + { + id: actualId, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `userConnectorToken.update('${id}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return { ...result.attributes, id: this.formatTokenId(actualId) } as UserConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update user_connector_token for id "${id}" with ${context}. Error: ${err.message}` + ); + throw err; + } + } + + /** + * Get personal connector token + */ + public async get({ + profileUid, + connectorId, + tokenType, + credentialType, + }: { + profileUid: string; + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: UserConnectorToken | null; + }> { + const contextCredentialType = credentialType ?? 'oauth'; + const context = this.getContextString(profileUid, connectorId, contextCredentialType); + + const credentialTypeFilter = credentialType + ? ` AND ${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.credentialType: "${credentialType}"` + : ''; + + const profileUidFilter = `${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.profileUid: "${profileUid}" AND `; + + const connectorTokensResult = []; + try { + connectorTokensResult.push( + ...( + await this.unsecuredSavedObjectsClient.find({ + perPage: MAX_TOKENS_RETURNED, + type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${profileUidFilter}${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${credentialTypeFilter}`, + sortField: 'updated_at', + sortOrder: 'desc', + }) + ).saved_objects + ); + } catch (err) { + this.logger.error( + `Failed to fetch user_connector_token for ${context}. Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + + if (connectorTokensResult.length === 0) { + return { hasErrors: false, connectorToken: null }; + } + + if ( + connectorTokensResult[0].attributes.expiresAt && + isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt)) + ) { + this.logger.error( + `Failed to get user_connector_token for ${context}. Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"` + ); + return { hasErrors: true, connectorToken: null }; + } + + try { + const decrypted = + await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + connectorTokensResult[0].id + ); + + const personalToken = decrypted.attributes as UserConnectorToken; + + this.logger.debug( + `Retrieved personal credentials for ${context}, credentialKeys: ${Object.keys( + personalToken.credentials as Record + ).join(', ')}` + ); + + return { + hasErrors: false, + connectorToken: { + id: this.formatTokenId(connectorTokensResult[0].id), + ...personalToken, + }, + }; + } catch (err) { + this.logger.error( + `Failed to decrypt user_connector_token for ${context}. Error: ${err.message}` + ); + return { hasErrors: true, connectorToken: null }; + } + } + + /** + * Get OAuth personal token with parsed credentials + */ + public async getOAuthPersonalToken({ + profileUid, + connectorId, + }: { + profileUid: string; + connectorId: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: UserConnectorOAuthToken | null; + }> { + const { connectorToken, hasErrors } = await this.get({ + profileUid, + connectorId, + credentialType: 'oauth', + }); + + if (hasErrors || !connectorToken) { + return { hasErrors, connectorToken: null }; + } + + if (!('credentials' in connectorToken)) { + this.logger.error( + `Expected personal credentials for connectorId "${connectorId}", profileUid "${profileUid}".` + ); + return { hasErrors: true, connectorToken: null }; + } + + const parsedCredentials = this.parseOAuthPersonalCredentials(connectorToken.credentials); + if (!parsedCredentials) { + this.logger.error( + `Invalid OAuth credentials shape for connectorId "${connectorId}", profileUid "${profileUid}".` + ); + return { hasErrors: true, connectorToken: null }; + } + + return { + hasErrors: false, + connectorToken: { + ...connectorToken, + credentialType: 'oauth', + credentials: parsedCredentials, + }, + }; + } + + /** + * Delete all personal connector tokens + */ + public async deleteConnectorTokens({ + profileUid, + connectorId, + tokenType, + credentialType, + }: { + profileUid: string; + connectorId: string; + tokenType?: string; + credentialType?: string; + }) { + const context = this.getContextString(profileUid, connectorId); + + const credentialTypeFilter = credentialType + ? ` AND ${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.credentialType: "${credentialType}"` + : ''; + + const profileUidFilter = `${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.profileUid: "${profileUid}" AND `; + + try { + const result = await this.unsecuredSavedObjectsClient.find({ + type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + filter: `${profileUidFilter}${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${credentialTypeFilter}`, + }); + return Promise.all( + result.saved_objects.map( + async (obj) => + await this.unsecuredSavedObjectsClient.delete( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + obj.id + ) + ) + ); + } catch (err) { + this.logger.error( + `Failed to delete user_connector_token records for ${context}. Error: ${err.message}` + ); + throw err; + } + } + + public async updateOrReplace({ + profileUid, + connectorId, + token, + newToken, + expiresInSec, + tokenRequestDate, + deleteExisting, + }: UpdateOrReplaceOptions) { + expiresInSec = expiresInSec ?? 3600; + tokenRequestDate = tokenRequestDate ?? Date.now(); + if (token === null) { + if (deleteExisting) { + await this.deleteConnectorTokens({ + profileUid, + connectorId, + credentialType: 'oauth', + }); + } + + await this.create({ + profileUid, + connectorId, + token: newToken, + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), + credentialType: 'oauth', + }); + } else { + await this.update({ + id: token.id!, + token: newToken, + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), + credentialType: 'oauth', + }); + } + } + + /** + * Create new personal token with refresh token support + */ + public async createWithRefreshToken({ + profileUid, + connectorId, + accessToken, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + credentialType, + }: { + profileUid: string; + connectorId: string; + accessToken: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + credentialType?: string; + }): Promise { + const rawId = SavedObjectsUtils.generateId(); + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + const resolvedCredentialType = credentialType ?? 'oauth'; + const context = this.getContextString(profileUid, connectorId); + + const credentials: Record = { + accessToken, + }; + if (refreshToken) { + credentials.refreshToken = refreshToken; + } + + this.logger.debug( + `Creating personal token with credentials blob for profileUid: ${profileUid}, connectorId: ${connectorId}, credentialKeys: ${Object.keys( + credentials + ).join(', ')}` + ); + + const attributes: PersonalTokenAttributes = { + connectorId, + profileUid, + credentialType: resolvedCredentialType, + credentials, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: refreshTokenExpiresInMillis, + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + }; + + try { + const result = await this.unsecuredSavedObjectsClient.create( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + attributes, + { id: rawId } + ); + + this.logger.debug( + `Successfully created user_connector_token with refresh token for ${context}, id: ${this.formatTokenId( + rawId + )}` + ); + + return { ...result.attributes, id: this.formatTokenId(rawId) } as UserConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create user_connector_token with refresh token for ${context}. Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update personal token with refresh token + */ + public async updateWithRefreshToken({ + id, + token, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + credentialType, + }: { + id: string; + token: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + credentialType?: string; + }): Promise { + const { actualId } = this.parseTokenId(id); + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + actualId + ); + + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + const profileUid = + 'profileUid' in attributes && typeof attributes.profileUid === 'string' + ? attributes.profileUid + : undefined; + const context = this.getContextString(profileUid, attributes.connectorId); + + try { + const updateOperation = () => { + const { id: _id, ...attributesWithoutId } = attributes; + const existingCreds = + ((attributesWithoutId as PersonalTokenAttributes).credentials as Record< + string, + string | undefined + >) || {}; + const existingAttrs = attributesWithoutId as PersonalTokenAttributes; + + const credentials: Record = { + accessToken: token, + }; + if (refreshToken ?? existingCreds.refreshToken) { + credentials.refreshToken = refreshToken ?? existingCreds.refreshToken!; + } + + this.logger.debug( + `Updating personal token with refresh token for id: ${id}, credentialKeys: ${Object.keys( + credentials + ).join(', ')}` + ); + + const updatedAttributes: PersonalTokenAttributes = { + ...attributesWithoutId, + credentialType: + credentialType ?? (attributesWithoutId as PersonalTokenAttributes).credentialType, + credentials, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: refreshTokenExpiresInMillis ?? existingAttrs.refreshTokenExpiresAt, + updatedAt: new Date(now).toISOString(), + }; + + return this.unsecuredSavedObjectsClient.create( + USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + updatedAttributes as UserConnectorToken, + omitBy( + { + id: actualId, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `userConnectorToken.updateWithRefreshToken('${id}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return { ...result.attributes, id: this.formatTokenId(actualId) } as UserConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update user_connector_token with refresh token for id "${id}" and ${context}. Error: ${err.message}` + ); + throw err; + } + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts index 2413e2cda293b..49d11e0b61215 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts @@ -384,10 +384,10 @@ export const oauthCallbackRoute = ( // Store tokens - first delete any existing tokens for this connector then create a new token record const connectorTokenClient = new ConnectorTokenClient({ encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ - includedHiddenTypes: ['connector_token'], + includedHiddenTypes: ['connector_token', 'user_connector_token'], }), unsecuredSavedObjectsClient: core.savedObjects.getClient({ - includedHiddenTypes: ['connector_token'], + includedHiddenTypes: ['connector_token', 'user_connector_token'], }), logger: routeLogger, }); diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index 615495e4782ff..ecea60fb296c4 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -308,6 +308,28 @@ export interface ConnectorToken extends SavedObjectAttributes { refreshTokenExpiresAt?: string; } +export interface UserConnectorToken extends SavedObjectAttributes { + id?: string; + profileUid: string; + connectorId: string; + credentialType: string; + credentials: SavedObjectAttributes; + expiresAt?: string; + refreshTokenExpiresAt?: string; + createdAt: string; + updatedAt: string; +} + +export type OAuthPersonalCredentials = SavedObjectAttributes & { + accessToken: string; + refreshToken?: string; +}; + +export interface UserConnectorOAuthToken extends UserConnectorToken { + credentialType: 'oauth'; + credentials: OAuthPersonalCredentials; +} + // This unallowlist should only contain connector types that require a request or API key for // execution. export const UNALLOWED_FOR_UNSECURE_EXECUTION_CONNECTOR_TYPE_IDS = ['.index']; From 819dab069bc1f486c49185e535dcc2c242e66c4e Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:06:58 +0100 Subject: [PATCH 03/16] refactor --- .../server/lib/connector_token_client.test.ts | 3 +- .../lib/shared_connector_token_client.test.ts | 46 +++++++++++++++++- .../lib/shared_connector_token_client.ts | 14 ++++-- .../lib/user_connector_token_client.test.ts | 47 +++++++++++++++++++ .../server/lib/user_connector_token_client.ts | 19 +++++--- 5 files changed, 115 insertions(+), 14 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts index 013430e191626..4696cd7a26f18 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts @@ -412,8 +412,7 @@ describe('delete()', () => { ], }; unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); - const result = await connectorTokenClient.deleteConnectorTokens({ connectorId: '1' }); - expect(JSON.stringify(result)).toEqual(JSON.stringify([Symbol(), Symbol()])); + await connectorTokenClient.deleteConnectorTokens({ connectorId: '1' }); expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(2); expect(unsecuredSavedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` Array [ diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts index 8e37d3cc087d3..0a107b983ac67 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts @@ -223,8 +223,7 @@ describe('SharedConnectorTokenClient', () => { ], }; unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); - const result = await sharedClient.deleteConnectorTokens({ connectorId: '1' }); - expect(JSON.stringify(result)).toEqual(JSON.stringify([Symbol(), Symbol()])); + await sharedClient.deleteConnectorTokens({ connectorId: '1' }); expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(2); }); }); @@ -305,4 +304,47 @@ describe('SharedConnectorTokenClient', () => { }); }); }); + + describe('updateOrReplace()', () => { + test('throws when existing token has no id', async () => { + const tokenWithoutId = { + connectorId: '123', + tokenType: 'access_token', + token: 'old', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect( + sharedClient.updateOrReplace({ + connectorId: '123', + token: tokenWithoutId as ConnectorToken, + newToken: 'newtoken', + tokenRequestDate: Date.now(), + deleteExisting: false, + }) + ).rejects.toThrow('token id is missing'); + }); + + test('throws when existing token has empty string id', async () => { + const tokenWithEmptyId = { + id: '', + connectorId: '123', + tokenType: 'access_token', + token: 'old', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect( + sharedClient.updateOrReplace({ + connectorId: '123', + token: tokenWithEmptyId as ConnectorToken, + newToken: 'newtoken', + tokenRequestDate: Date.now(), + deleteExisting: false, + }) + ).rejects.toThrow('token id is missing'); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts index e2af0a0da8704..3258846d13e5f 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts @@ -248,7 +248,7 @@ export class SharedConnectorTokenClient { }: { connectorId: string; tokenType?: string; - }) { + }): Promise { const tokenTypeFilter = tokenType ? ` AND ${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.tokenType: "${tokenType}"` : ''; @@ -257,7 +257,7 @@ export class SharedConnectorTokenClient { type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, filter: `${CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${tokenTypeFilter}`, }); - return Promise.all( + await Promise.all( result.saved_objects.map( async (obj) => await this.unsecuredSavedObjectsClient.delete(CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, obj.id) @@ -278,7 +278,7 @@ export class SharedConnectorTokenClient { expiresInSec, tokenRequestDate, deleteExisting, - }: UpdateOrReplaceOptions) { + }: UpdateOrReplaceOptions): Promise { expiresInSec = expiresInSec ?? 3600; tokenRequestDate = tokenRequestDate ?? Date.now(); if (token === null) { @@ -296,8 +296,14 @@ export class SharedConnectorTokenClient { tokenType: 'access_token', }); } else { + const tokenId = token.id; + if (tokenId == null || tokenId === '') { + throw new Error( + `Cannot update connector token for connectorId "${connectorId}": token id is missing` + ); + } await this.update({ - id: token.id!, + id: tokenId, token: newToken, expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), tokenType: 'access_token', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts index df2e019545790..89b155d44e46b 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts @@ -584,4 +584,51 @@ describe('UserConnectorTokenClient', () => { ); }); }); + + describe('updateOrReplace()', () => { + test('throws when existing token has no id', async () => { + const tokenWithoutId = { + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { accessToken: 'old' }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect( + userClient.updateOrReplace({ + profileUid: 'user-profile-123', + connectorId: '123', + token: tokenWithoutId as UserConnectorToken, + newToken: 'newtoken', + tokenRequestDate: Date.now(), + deleteExisting: false, + }) + ).rejects.toThrow('token id is missing'); + }); + + test('throws when existing token has empty string id', async () => { + const tokenWithEmptyId = { + id: '', + profileUid: 'user-profile-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { accessToken: 'old' }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await expect( + userClient.updateOrReplace({ + profileUid: 'user-profile-123', + connectorId: '123', + token: tokenWithEmptyId as UserConnectorToken, + newToken: 'newtoken', + tokenRequestDate: Date.now(), + deleteExisting: false, + }) + ).rejects.toThrow('token id is missing'); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts index 1b3195b4a012a..aafed8e840476 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts @@ -404,7 +404,7 @@ export class UserConnectorTokenClient { connectorId: string; tokenType?: string; credentialType?: string; - }) { + }): Promise { const context = this.getContextString(profileUid, connectorId); const credentialTypeFilter = credentialType @@ -418,7 +418,7 @@ export class UserConnectorTokenClient { type: USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, filter: `${profileUidFilter}${USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE}.attributes.connectorId: "${connectorId}"${credentialTypeFilter}`, }); - return Promise.all( + await Promise.all( result.saved_objects.map( async (obj) => await this.unsecuredSavedObjectsClient.delete( @@ -443,7 +443,7 @@ export class UserConnectorTokenClient { expiresInSec, tokenRequestDate, deleteExisting, - }: UpdateOrReplaceOptions) { + }: UpdateOrReplaceOptions): Promise { expiresInSec = expiresInSec ?? 3600; tokenRequestDate = tokenRequestDate ?? Date.now(); if (token === null) { @@ -463,8 +463,14 @@ export class UserConnectorTokenClient { credentialType: 'oauth', }); } else { + const tokenId = token.id; + if (tokenId == null || tokenId === '') { + throw new Error( + `Cannot update user connector token for connectorId "${connectorId}", profileUid "${profileUid}": token id is missing` + ); + } await this.update({ - id: token.id!, + id: tokenId, token: newToken, expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), credentialType: 'oauth', @@ -602,8 +608,9 @@ export class UserConnectorTokenClient { const credentials: Record = { accessToken: token, }; - if (refreshToken ?? existingCreds.refreshToken) { - credentials.refreshToken = refreshToken ?? existingCreds.refreshToken!; + const resolvedRefreshToken = refreshToken ?? existingCreds.refreshToken; + if (resolvedRefreshToken) { + credentials.refreshToken = resolvedRefreshToken; } this.logger.debug( From 8f01e764a657058dd2bf46b896fb8eefbf7dc877 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:02:04 +0100 Subject: [PATCH 04/16] add logger --- .../server/lib/connector_token_client.test.ts | 6 ++- .../server/lib/connector_token_client.ts | 42 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts index 4696cd7a26f18..a68fe4dcf7c10 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts @@ -13,7 +13,11 @@ import type { Logger } from '@kbn/core/server'; import type { ConnectorToken } from '../types'; import * as allRetry from './retry_if_conflicts'; -const logger = loggingSystemMock.create().get() as jest.Mocked; +const rootLogger = loggingSystemMock.create().get() as jest.Mocked; +const logger = { + ...rootLogger, + get: () => rootLogger, +} as unknown as Logger; jest.mock('@kbn/core-saved-objects-utils-server', () => { const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); return { diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index 7069a644e6c85..40f716c056d88 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -49,10 +49,12 @@ interface UpdateOrReplaceOptions { } export class ConnectorTokenClient { + private readonly logger: Logger; private readonly sharedClient: SharedConnectorTokenClient; private readonly userClient: UserConnectorTokenClient; constructor(options: ConstructorOptions) { + this.logger = options.logger.get('connector_token_client'); this.sharedClient = new SharedConnectorTokenClient(options); this.userClient = new UserConnectorTokenClient(options); } @@ -67,10 +69,27 @@ export class ConnectorTokenClient { return { scope: 'shared', actualId: id }; } + private log({ + method, + scope, + fields, + }: { + method: string; + scope: 'personal' | 'shared'; + fields: Record; + }): void { + const parts = Object.entries(fields).map(([key, value]) => + typeof value === 'string' ? `${key}="${value}"` : `${key}=${value}` + ); + this.logger.debug(`Delegating to ${scope} client: method=${method}, ${parts.join(', ')}`); + } + /** * Create new token for connector (delegates to shared or user client) */ public async create(options: CreateOptions): Promise { + const scope = options.profileUid ? 'personal' : 'shared'; + this.log({ method: 'create', scope, fields: { connectorId: options.connectorId } }); if (options.profileUid) { return this.userClient.create(options as Parameters[0]); } @@ -82,6 +101,7 @@ export class ConnectorTokenClient { */ public async update(options: UpdateOptions): Promise { const { scope, actualId } = this.parseTokenId(options.id); + this.log({ method: 'update', scope, fields: { id: actualId } }); if (scope === 'personal') { return this.userClient.update({ ...options, id: actualId }); } @@ -108,6 +128,8 @@ export class ConnectorTokenClient { hasErrors: boolean; connectorToken: ConnectorToken | UserConnectorToken | null; }> { + const scope = options.profileUid ? 'personal' : 'shared'; + this.log({ method: 'get', scope, fields: { connectorId: options.connectorId } }); if (options.profileUid) { return this.userClient.get(options as Parameters[0]); } @@ -124,6 +146,11 @@ export class ConnectorTokenClient { hasErrors: boolean; connectorToken: UserConnectorOAuthToken | null; }> { + this.log({ + method: 'getOAuthPersonalToken', + scope: 'personal', + fields: { connectorId: options.connectorId }, + }); return this.userClient.getOAuthPersonalToken(options); } @@ -136,6 +163,12 @@ export class ConnectorTokenClient { tokenType?: string; credentialType?: string; }) { + const scope = options.profileUid ? 'personal' : 'shared'; + this.log({ + method: 'deleteConnectorTokens', + scope, + fields: { connectorId: options.connectorId }, + }); if (options.profileUid) { return this.userClient.deleteConnectorTokens( options as Parameters[0] @@ -147,6 +180,8 @@ export class ConnectorTokenClient { } public async updateOrReplace(options: UpdateOrReplaceOptions) { + const scope = options.profileUid ? 'personal' : 'shared'; + this.log({ method: 'updateOrReplace', scope, fields: { connectorId: options.connectorId } }); if (options.profileUid) { return this.userClient.updateOrReplace( options as Parameters[0] @@ -170,6 +205,12 @@ export class ConnectorTokenClient { tokenType?: string; credentialType?: string; }): Promise { + const scope = options.profileUid ? 'personal' : 'shared'; + this.log({ + method: 'createWithRefreshToken', + scope, + fields: { connectorId: options.connectorId }, + }); if (options.profileUid) { return this.userClient.createWithRefreshToken( options as Parameters[0] @@ -193,6 +234,7 @@ export class ConnectorTokenClient { credentialType?: string; }): Promise { const { scope, actualId } = this.parseTokenId(options.id); + this.log({ method: 'updateWithRefreshToken', scope, fields: { id: actualId } }); if (scope === 'personal') { return this.userClient.updateWithRefreshToken({ ...options, id: actualId }); } From d73d4f1c75e1c42ffb19e74abf57a731f7e0e73f Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:05:27 +0000 Subject: [PATCH 05/16] Changes from node scripts/check_mappings_update --fix --- .../current_fields.json | 5 +++++ .../current_mappings.json | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index bd9578de252c3..39269b3bfee25 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -1369,6 +1369,11 @@ "usage-counters": [ "domainId" ], + "user_connector_token": [ + "connectorId", + "credentialType", + "profileUid" + ], "visualization": [ "description", "kibanaSavedObjectMeta", diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index c194a1e1dcb58..a8158db316c6d 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -4506,6 +4506,20 @@ } } }, + "user_connector_token": { + "dynamic": false, + "properties": { + "connectorId": { + "type": "keyword" + }, + "credentialType": { + "type": "keyword" + }, + "profileUid": { + "type": "keyword" + } + } + }, "visualization": { "dynamic": false, "properties": { From 4bb57b7c163a1f50d000807b8a51404f93d7b03d Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:17:19 +0100 Subject: [PATCH 06/16] fix types + linter issues --- .../actions_client/actions_client.test.ts | 10 ++-- .../server/lib/connector_token_client.mock.ts | 41 ++++++++++++--- .../server/lib/connector_token_client.test.ts | 8 +-- .../server/lib/connector_token_client.ts | 52 +++++++++++++++++-- ...t_oauth_authorization_code_access_token.ts | 25 ++++----- .../lib/shared_connector_token_client.ts | 5 +- .../lib/user_connector_token_client.test.ts | 46 +--------------- .../plugins/shared/actions/server/mocks.ts | 6 +-- .../plugins/shared/actions/server/plugin.ts | 10 ++-- .../actions/server/routes/oauth_callback.ts | 4 +- .../plugins/shared/actions/server/types.ts | 8 +-- .../crowdstrike/token_manager.test.ts | 5 +- .../microsoft_defender_endpoint/mocks.ts | 7 +-- .../o_auth_token_manager.test.ts | 6 +-- 14 files changed, 134 insertions(+), 99 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts index ff1e1aa442259..0461a92b24c6d 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts @@ -38,7 +38,7 @@ import { actionExecutorMock } from '../lib/action_executor.mock'; import { v4 as uuidv4 } from 'uuid'; import type { ActionsAuthorization } from '../authorization/actions_authorization'; import { actionsAuthorizationMock } from '../authorization/actions_authorization.mock'; -import { ConnectorTokenClient } from '../lib/connector_token_client'; +import { ConnectorTokenClientFacade } from '../lib/connector_token_client'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import type { SavedObject } from '@kbn/core/server'; import { connectorTokenClientMock } from '../lib/connector_token_client.mock'; @@ -3019,7 +3019,7 @@ describe('isPreconfigured()', () => { isSystemAction: true, }), ], - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, @@ -3064,7 +3064,7 @@ describe('isPreconfigured()', () => { isSystemAction: true, }), ], - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, @@ -3111,7 +3111,7 @@ describe('isSystemAction()', () => { isSystemAction: true, }), ], - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, @@ -3156,7 +3156,7 @@ describe('isSystemAction()', () => { isSystemAction: true, }), ], - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts index 0a3dd9be6017a..46b6b5293ff66 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts @@ -5,20 +5,47 @@ * 2.0. */ -import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { ConnectorTokenClient } from './connector_token_client'; +import type { ConnectorTokenClientContract } from '../types'; +import type { SharedConnectorTokenClient } from './shared_connector_token_client'; +import type { UserConnectorTokenClient } from './user_connector_token_client'; -const createConnectorTokenClientMock = () => { - const mocked: jest.Mocked> = { - create: jest.fn(), - get: jest.fn(), +const sharedCredentialsClientMock = { + create: jest.fn() as jest.MockedFunction, + get: jest.fn() as jest.MockedFunction, + update: jest.fn(), + deleteConnectorTokens: jest.fn(), + updateOrReplace: jest.fn(), + createWithRefreshToken: jest.fn(), + updateWithRefreshToken: jest.fn(), +} as unknown as jest.Mocked; + +const userCredentialsClientMock = { + create: jest.fn() as jest.MockedFunction, + get: jest.fn() as jest.MockedFunction, + getOAuthPersonalToken: jest.fn() as jest.MockedFunction< + UserConnectorTokenClient['getOAuthPersonalToken'] + >, + update: jest.fn(), + deleteConnectorTokens: jest.fn(), + updateOrReplace: jest.fn(), + createWithRefreshToken: jest.fn(), + updateWithRefreshToken: jest.fn(), +} as unknown as jest.Mocked; + +const createConnectorTokenClientMock = (): jest.Mocked => { + const mocked = { + create: jest.fn() as jest.MockedFunction, + get: jest.fn() as jest.MockedFunction, getOAuthPersonalToken: jest.fn(), update: jest.fn(), deleteConnectorTokens: jest.fn(), updateOrReplace: jest.fn(), createWithRefreshToken: jest.fn(), updateWithRefreshToken: jest.fn(), - }; + getSharedCredentialsClient: jest.fn(() => sharedCredentialsClientMock), + getUserCredentialsClient: jest.fn(() => userCredentialsClientMock), + } satisfies jest.Mocked; + return mocked; }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts index a68fe4dcf7c10..6deefd55e9f33 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts @@ -8,7 +8,7 @@ import sinon from 'sinon'; import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; -import { ConnectorTokenClient } from './connector_token_client'; +import { ConnectorTokenClientFacade } from './connector_token_client'; import type { Logger } from '@kbn/core/server'; import type { ConnectorToken } from '../types'; import * as allRetry from './retry_if_conflicts'; @@ -17,7 +17,7 @@ const rootLogger = loggingSystemMock.create().get() as jest.Mocked; const logger = { ...rootLogger, get: () => rootLogger, -} as unknown as Logger; +} as unknown as jest.Mocked; jest.mock('@kbn/core-saved-objects-utils-server', () => { const actual = jest.requireActual('@kbn/core-saved-objects-utils-server'); return { @@ -31,7 +31,7 @@ jest.mock('@kbn/core-saved-objects-utils-server', () => { const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); -let connectorTokenClient: ConnectorTokenClient; +let connectorTokenClient: ConnectorTokenClientFacade; let clock: sinon.SinonFakeTimers; @@ -42,7 +42,7 @@ beforeEach(() => { clock.reset(); jest.resetAllMocks(); jest.restoreAllMocks(); - connectorTokenClient = new ConnectorTokenClient({ + connectorTokenClient = new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient, encryptedSavedObjectsClient, logger, diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index 40f716c056d88..ccca38579bfdb 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -7,8 +7,8 @@ import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { Logger, SavedObjectsClientContract, SavedObjectAttributes } from '@kbn/core/server'; +import { ConnectorTokenClient as SharedConnectorTokenClient } from './shared_connector_token_client'; import type { ConnectorToken, UserConnectorToken, UserConnectorOAuthToken } from '../types'; -import { SharedConnectorTokenClient } from './shared_connector_token_client'; import { UserConnectorTokenClient } from './user_connector_token_client'; export const MAX_TOKENS_RETURNED = 1; @@ -48,7 +48,7 @@ interface UpdateOrReplaceOptions { deleteExisting: boolean; } -export class ConnectorTokenClient { +export class ConnectorTokenClientFacade { private readonly logger: Logger; private readonly sharedClient: SharedConnectorTokenClient; private readonly userClient: UserConnectorTokenClient; @@ -87,6 +87,21 @@ export class ConnectorTokenClient { /** * Create new token for connector (delegates to shared or user client) */ + public async create(options: { + connectorId: string; + token: string; + expiresAtMillis?: string; + tokenType?: string; + }): Promise; + public async create(options: { + profileUid: string; + connectorId: string; + token?: string; + credentials?: SavedObjectAttributes; + expiresAtMillis?: string; + tokenType?: string; + credentialType?: string; + }): Promise; public async create(options: CreateOptions): Promise { const scope = options.profileUid ? 'personal' : 'shared'; this.log({ method: 'create', scope, fields: { connectorId: options.connectorId } }); @@ -119,6 +134,26 @@ export class ConnectorTokenClient { /** * Get connector token (delegates to shared or user client) */ + public async get(options: { + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise<{ hasErrors: boolean; connectorToken: ConnectorToken | null }>; + public async get(options: { + profileUid: string; + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise<{ hasErrors: boolean; connectorToken: UserConnectorToken | null }>; + public async get(options: { + profileUid?: string; + connectorId: string; + tokenType?: string; + credentialType?: string; + }): Promise<{ + hasErrors: boolean; + connectorToken: ConnectorToken | UserConnectorToken | null; + }>; public async get(options: { profileUid?: string; connectorId: string; @@ -162,7 +197,7 @@ export class ConnectorTokenClient { connectorId: string; tokenType?: string; credentialType?: string; - }) { + }): Promise { const scope = options.profileUid ? 'personal' : 'shared'; this.log({ method: 'deleteConnectorTokens', @@ -240,4 +275,15 @@ export class ConnectorTokenClient { } return this.sharedClient.updateWithRefreshToken({ ...options, id: actualId }); } + + public getSharedCredentialsClient(): SharedConnectorTokenClient { + return this.sharedClient; + } + + public getUserCredentialsClient(): UserConnectorTokenClient { + return this.userClient; + } } + +// Compatibility alias for existing code expecting ConnectorTokenClient name +export { ConnectorTokenClientFacade as ConnectorTokenClient }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts index ff2b8d3a7eb41..ee7b012770273 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts @@ -8,7 +8,7 @@ import pLimit from 'p-limit'; import type { Logger } from '@kbn/core/server'; import type { ActionsConfigurationUtilities } from '../actions_config'; -import type { ConnectorTokenClientContract } from '../types'; +import type { ConnectorToken, ConnectorTokenClientContract } from '../types'; import { requestOAuthRefreshToken } from './request_oauth_refresh_token'; // Per-connector locks to prevent concurrent token refreshes for the same connector @@ -98,18 +98,20 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ return null; } + const token = connectorToken as ConnectorToken; + // Check if access token is still valid (may have been refreshed by another request) const now = Date.now(); - const expiresAt = connectorToken.expiresAt ? Date.parse(connectorToken.expiresAt) : Infinity; + const expiresAt = token.expiresAt ? Date.parse(token.expiresAt) : Infinity; if (!forceRefresh && expiresAt > now) { // Token still valid logger.debug(`Using stored access token for connectorId: ${connectorId}`); - return connectorToken.token; + return token.token; } - // Access token expired - attempt refresh - if (!connectorToken.refreshToken) { + const refreshToken = typeof token.refreshToken === 'string' ? token.refreshToken : undefined; + if (!refreshToken) { logger.warn( `Access token expired and no refresh token available for connectorId: ${connectorId}. User must re-authorize.` ); @@ -117,10 +119,7 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ } // Check if the refresh token is expired - if ( - connectorToken.refreshTokenExpiresAt && - Date.parse(connectorToken.refreshTokenExpiresAt) <= now - ) { + if (token.refreshTokenExpiresAt && Date.parse(token.refreshTokenExpiresAt) <= now) { logger.warn(`Refresh token expired for connectorId: ${connectorId}. User must re-authorize.`); return null; } @@ -132,7 +131,7 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ tokenUrl, logger, { - refreshToken: connectorToken.refreshToken, + refreshToken, clientId, clientSecret, scope, @@ -144,11 +143,13 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ const newAccessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + const updatedRefreshToken: string | undefined = tokenResult.refreshToken ?? refreshToken; + // Update stored token await connectorTokenClient.updateWithRefreshToken({ - id: connectorToken.id!, + id: token.id!, token: newAccessToken, - refreshToken: tokenResult.refreshToken || connectorToken.refreshToken, + refreshToken: updatedRefreshToken, expiresIn: tokenResult.expiresIn, refreshTokenExpiresIn: tokenResult.refreshTokenExpiresIn, tokenType: 'access_token', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts index 3258846d13e5f..da6aeb3a31ec3 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts @@ -45,7 +45,7 @@ interface UpdateOrReplaceOptions { deleteExisting: boolean; } -export class SharedConnectorTokenClient { +export class ConnectorTokenClient { private readonly logger: Logger; private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; @@ -440,3 +440,6 @@ export class SharedConnectorTokenClient { } } } + +// Compatibility alias for existing imports +export { ConnectorTokenClient as SharedConnectorTokenClient }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts index 89b155d44e46b..057fd31e62b22 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts @@ -325,47 +325,6 @@ describe('UserConnectorTokenClient', () => { }); }); - describe('deleteConnectorTokens()', () => { - test('deletes personal tokens for profileUid and connectorId', async () => { - const expectedResult = Symbol(); - unsecuredSavedObjectsClient.delete.mockResolvedValue(expectedResult); - - const findResult = { - total: 1, - per_page: 10, - page: 1, - saved_objects: [ - { - id: 'token1', - type: 'user_connector_token', - attributes: { - profileUid: 'user-profile-123', - connectorId: '123', - credentialType: 'oauth', - credentials: {}, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - score: 1, - references: [], - }, - ], - }; - - unsecuredSavedObjectsClient.find.mockResolvedValueOnce(findResult); - await userClient.deleteConnectorTokens({ - profileUid: 'user-profile-123', - connectorId: '123', - credentialType: 'oauth', - }); - - expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith( - 'user_connector_token', - 'token1' - ); - }); - }); - describe('createWithRefreshToken()', () => { test('creates personal token with refresh token', async () => { const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); @@ -539,9 +498,8 @@ describe('UserConnectorTokenClient', () => { }); describe('deleteConnectorTokens()', () => { - test('deletes all personal tokens for profileUid and connectorId', async () => { - const expectedResult = Symbol(); - unsecuredSavedObjectsClient.delete.mockResolvedValue(expectedResult); + test('deletes personal tokens for profileUid and connectorId', async () => { + unsecuredSavedObjectsClient.delete.mockResolvedValue({}); const findResult = { total: 1, diff --git a/x-pack/platform/plugins/shared/actions/server/mocks.ts b/x-pack/platform/plugins/shared/actions/server/mocks.ts index f208d0f4fef9c..47a5d645fc651 100644 --- a/x-pack/platform/plugins/shared/actions/server/mocks.ts +++ b/x-pack/platform/plugins/shared/actions/server/mocks.ts @@ -20,7 +20,7 @@ import type { PluginSetupContract, PluginStartContract } from './plugin'; import { renderActionParameterTemplates } from './plugin'; import type { Services, UnsecuredServices } from './types'; import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; -import { ConnectorTokenClient } from './lib/connector_token_client'; +import { ConnectorTokenClientFacade } from './lib/connector_token_client'; import { unsecuredActionsClientMock } from './unsecured_actions_client/unsecured_actions_client.mock'; export { actionsAuthorizationMock }; export { actionsClientMock }; @@ -92,7 +92,7 @@ const createServicesMock = () => { > = lazyObject({ savedObjectsClient: savedObjectsClientMock.create(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, @@ -109,7 +109,7 @@ const createUnsecuredServicesMock = () => { > = lazyObject({ savedObjectsClient: savedObjectsRepositoryMock.create(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient: savedObjectsRepositoryMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.ts b/x-pack/platform/plugins/shared/actions/server/plugin.ts index d873af23a5760..06d70c7f00f8d 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.ts @@ -91,7 +91,7 @@ import { getAlertHistoryEsIndex } from './preconfigured_connectors/alert_history import { createAlertHistoryIndexTemplate } from './preconfigured_connectors/alert_history_es_index/create_alert_history_index_template'; import { ACTIONS_FEATURE_ID, AlertHistoryEsIndexConnectorId } from '../common'; import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER } from './constants/event_log'; -import { ConnectorTokenClient } from './lib/connector_token_client'; +import { ConnectorTokenClientFacade } from './lib/connector_token_client'; import { InMemoryMetrics, registerClusterCollector, registerNodeCollector } from './monitoring'; import type { ConnectorWithOptionalDeprecation } from './application/connector/lib'; import { isConnectorDeprecated } from './application/connector/lib'; @@ -531,7 +531,7 @@ export class ActionsPlugin }), auditLogger: this.security?.audit.asScoped(request), usageCounter: this.usageCounter, - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient, encryptedSavedObjectsClient, logger, @@ -755,7 +755,7 @@ export class ActionsPlugin return { savedObjectsClient: getScopedClient(request), scopedClusterClient: elasticsearch.client.asScoped(request).asCurrentUser, - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient: unsecuredSavedObjectsClient(request), encryptedSavedObjectsClient, logger: this.logger, @@ -774,7 +774,7 @@ export class ActionsPlugin return { savedObjectsClient: getSavedObjectRepository(), scopedClusterClient: elasticsearch.client.asInternalUser, - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient: unsecuredSavedObjectsRepository(), encryptedSavedObjectsClient, logger: this.logger, @@ -859,7 +859,7 @@ export class ActionsPlugin }), auditLogger: security?.audit.asScoped(request), usageCounter, - connectorTokenClient: new ConnectorTokenClient({ + connectorTokenClient: new ConnectorTokenClientFacade({ unsecuredSavedObjectsClient, encryptedSavedObjectsClient, logger, diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts index 49d11e0b61215..0f1edc9c29cfc 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts @@ -17,7 +17,7 @@ import { DEFAULT_ACTION_ROUTE_SECURITY } from './constants'; import { verifyAccessAndContext } from './verify_access_and_context'; import { OAuthStateClient } from '../lib/oauth_state_client'; import { requestOAuthAuthorizationCodeToken } from '../lib/request_oauth_authorization_code_token'; -import { ConnectorTokenClient } from '../lib/connector_token_client'; +import { ConnectorTokenClientFacade } from '../lib/connector_token_client'; import type { OAuthRateLimiter } from '../lib/oauth_rate_limiter'; const querySchema = schema.object({ @@ -382,7 +382,7 @@ export const oauthCallbackRoute = ( ); // Store tokens - first delete any existing tokens for this connector then create a new token record - const connectorTokenClient = new ConnectorTokenClient({ + const connectorTokenClient = new ConnectorTokenClientFacade({ encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ includedHiddenTypes: ['connector_token', 'user_connector_token'], }), diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index ecea60fb296c4..15052714f9c7c 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -20,12 +20,12 @@ import type { LicenseType } from '@kbn/licensing-types'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type * as z3 from '@kbn/zod'; import type * as z4 from '@kbn/zod/v4'; +import type { ConnectorTokenClientFacade } from './lib/connector_token_client'; import type { ActionTypeExecutorResult, SubFeature, ActionTypeSource } from '../common'; import type { ActionTypeRegistry } from './action_type_registry'; import type { ActionsClient } from './actions_client'; import type { ActionsConfigurationUtilities } from './actions_config'; import type { TaskInfo } from './lib/action_executor'; -import type { ConnectorTokenClient } from './lib/connector_token_client'; import type { PluginSetupContract, PluginStartContract } from './plugin'; import type { SubActionConnector } from './sub_action_framework/sub_action_connector'; import type { ServiceParams } from './sub_action_framework/types'; @@ -41,7 +41,7 @@ export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefine export type ActionTypeConfig = Record; export type ActionTypeSecrets = Record; export type ActionTypeParams = Record; -export type ConnectorTokenClientContract = PublicMethodsOf; +export type ConnectorTokenClientContract = PublicMethodsOf; import type { Connector, ConnectorWithExtraFindData } from './application/connector/types'; import type { ActionExecutionSource, ActionExecutionSourceType } from './lib'; @@ -50,13 +50,13 @@ import type { ConnectorUsageCollector } from './usage'; export interface Services { savedObjectsClient: SavedObjectsClientContract; scopedClusterClient: ElasticsearchClient; - connectorTokenClient: ConnectorTokenClient; + connectorTokenClient: ConnectorTokenClientFacade; } export interface UnsecuredServices { savedObjectsClient: ISavedObjectsRepository; scopedClusterClient: ElasticsearchClient; - connectorTokenClient: ConnectorTokenClient; + connectorTokenClient: ConnectorTokenClientFacade; } export interface HookServices { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts index 725f9a4b4c993..4094d6be69f18 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts @@ -12,8 +12,8 @@ import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.moc import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import type { ServiceParams } from '@kbn/actions-plugin/server'; import type { CrowdstrikeConfig, CrowdstrikeSecrets } from '@kbn/connector-schemas/crowdstrike'; -import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/connector_token_client'; import type { ConnectorToken } from '@kbn/actions-plugin/server/types'; +import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; describe('CrowdStrikeTokenManager', () => { let csTokenManager: CrowdStrikeTokenManager; @@ -41,7 +41,7 @@ describe('CrowdStrikeTokenManager', () => { mockRequest = jest.fn(); const mockServices = actionsMock.createServices(); connectorTokenClientMock = - mockServices.connectorTokenClient as jest.Mocked; + mockServices.connectorTokenClient as unknown as jest.Mocked; // Apply connector token client mock behavior let cachedTokenMock: ConnectorToken | null = null; @@ -95,7 +95,6 @@ describe('CrowdStrikeTokenManager', () => { jest.spyOn(connectorTokenClientMock, 'deleteConnectorTokens').mockImplementation(async () => { cachedTokenMock = null; - return []; }); const serviceParams: ServiceParams & { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts index 6419a5f298fd6..836729e4294ac 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts @@ -11,7 +11,6 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/usage'; import type { ConnectorToken } from '@kbn/actions-plugin/server/types'; -import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/connector_token_client'; import type { MicrosoftDefenderEndpointConfig, MicrosoftDefenderEndpointMachine, @@ -19,6 +18,7 @@ import type { MicrosoftDefenderEndpointSecrets, } from '@kbn/connector-schemas/microsoft_defender_endpoint'; import { CONNECTOR_ID } from '@kbn/connector-schemas/microsoft_defender_endpoint'; +import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; import { MicrosoftDefenderEndpointConnector } from './microsoft_defender_endpoint'; import type { ConnectorInstanceMock } from '../lib/mocks'; import { createAxiosResponseMock, createConnectorInstanceMock } from '../lib/mocks'; @@ -88,7 +88,6 @@ const applyConnectorTokenClientInstanceMock = ( }); jest.spyOn(connectorTokenClient, 'deleteConnectorTokens').mockImplementation(async () => { cachedTokenMock = null; - return []; }); }; @@ -177,7 +176,9 @@ const createMicrosoftDefenderConnectorMock = (): CreateMicrosoftDefenderConnecto } ); - applyConnectorTokenClientInstanceMock(options.services.connectorTokenClient); + applyConnectorTokenClientInstanceMock( + options.services.connectorTokenClient.getSharedCredentialsClient() + ); return { options, diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts index ae6eec99c5dca..af64c45efe80f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts @@ -8,17 +8,17 @@ import type { CreateMicrosoftDefenderConnectorMockResponse } from './mocks'; import { microsoftDefenderEndpointConnectorMocks } from './mocks'; import { OAuthTokenManager } from './o_auth_token_manager'; -import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/connector_token_client'; +import type { ConnectorTokenClientFacade } from '@kbn/actions-plugin/server/lib/connector_token_client'; describe('Microsoft Defender for Endpoint oAuth token manager', () => { let testMock: CreateMicrosoftDefenderConnectorMockResponse; let msOAuthManagerMock: OAuthTokenManager; - let connectorTokenManagerClientMock: jest.Mocked; + let connectorTokenManagerClientMock: jest.Mocked; beforeEach(() => { testMock = microsoftDefenderEndpointConnectorMocks.create(); connectorTokenManagerClientMock = testMock.options.services - .connectorTokenClient as jest.Mocked; + .connectorTokenClient as jest.Mocked; msOAuthManagerMock = new OAuthTokenManager({ ...testMock.options, apiRequest: async (...args) => testMock.instanceMock.request(...args), From 9e7f42b3edc40c7721280935ee93f92d65baed66 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:27:23 +0100 Subject: [PATCH 07/16] rename token clients --- .../server/actions_client/actions_client.test.ts | 10 +++++----- .../actions/server/lib/connector_token_client.test.ts | 6 +++--- .../actions/server/lib/connector_token_client.ts | 7 ++----- .../server/lib/shared_connector_token_client.ts | 5 +---- x-pack/platform/plugins/shared/actions/server/mocks.ts | 6 +++--- .../platform/plugins/shared/actions/server/plugin.ts | 10 +++++----- .../shared/actions/server/routes/oauth_callback.ts | 4 ++-- x-pack/platform/plugins/shared/actions/server/types.ts | 8 ++++---- .../connector_types/crowdstrike/token_manager.test.ts | 6 +++--- .../microsoft_defender_endpoint/mocks.ts | 4 ++-- .../o_auth_token_manager.test.ts | 8 ++++---- 11 files changed, 34 insertions(+), 40 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts index 0461a92b24c6d..ff1e1aa442259 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.test.ts @@ -38,7 +38,7 @@ import { actionExecutorMock } from '../lib/action_executor.mock'; import { v4 as uuidv4 } from 'uuid'; import type { ActionsAuthorization } from '../authorization/actions_authorization'; import { actionsAuthorizationMock } from '../authorization/actions_authorization.mock'; -import { ConnectorTokenClientFacade } from '../lib/connector_token_client'; +import { ConnectorTokenClient } from '../lib/connector_token_client'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import type { SavedObject } from '@kbn/core/server'; import { connectorTokenClientMock } from '../lib/connector_token_client.mock'; @@ -3019,7 +3019,7 @@ describe('isPreconfigured()', () => { isSystemAction: true, }), ], - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, @@ -3064,7 +3064,7 @@ describe('isPreconfigured()', () => { isSystemAction: true, }), ], - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, @@ -3111,7 +3111,7 @@ describe('isSystemAction()', () => { isSystemAction: true, }), ], - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, @@ -3156,7 +3156,7 @@ describe('isSystemAction()', () => { isSystemAction: true, }), ], - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts index 6deefd55e9f33..ed49ded553d2a 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts @@ -8,7 +8,7 @@ import sinon from 'sinon'; import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; -import { ConnectorTokenClientFacade } from './connector_token_client'; +import { ConnectorTokenClient } from './connector_token_client'; import type { Logger } from '@kbn/core/server'; import type { ConnectorToken } from '../types'; import * as allRetry from './retry_if_conflicts'; @@ -31,7 +31,7 @@ jest.mock('@kbn/core-saved-objects-utils-server', () => { const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); -let connectorTokenClient: ConnectorTokenClientFacade; +let connectorTokenClient: ConnectorTokenClient; let clock: sinon.SinonFakeTimers; @@ -42,7 +42,7 @@ beforeEach(() => { clock.reset(); jest.resetAllMocks(); jest.restoreAllMocks(); - connectorTokenClient = new ConnectorTokenClientFacade({ + connectorTokenClient = new ConnectorTokenClient({ unsecuredSavedObjectsClient, encryptedSavedObjectsClient, logger, diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index ccca38579bfdb..60c334b6c3887 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -7,7 +7,7 @@ import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { Logger, SavedObjectsClientContract, SavedObjectAttributes } from '@kbn/core/server'; -import { ConnectorTokenClient as SharedConnectorTokenClient } from './shared_connector_token_client'; +import { SharedConnectorTokenClient } from './shared_connector_token_client'; import type { ConnectorToken, UserConnectorToken, UserConnectorOAuthToken } from '../types'; import { UserConnectorTokenClient } from './user_connector_token_client'; @@ -48,7 +48,7 @@ interface UpdateOrReplaceOptions { deleteExisting: boolean; } -export class ConnectorTokenClientFacade { +export class ConnectorTokenClient { private readonly logger: Logger; private readonly sharedClient: SharedConnectorTokenClient; private readonly userClient: UserConnectorTokenClient; @@ -284,6 +284,3 @@ export class ConnectorTokenClientFacade { return this.userClient; } } - -// Compatibility alias for existing code expecting ConnectorTokenClient name -export { ConnectorTokenClientFacade as ConnectorTokenClient }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts index da6aeb3a31ec3..3258846d13e5f 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts @@ -45,7 +45,7 @@ interface UpdateOrReplaceOptions { deleteExisting: boolean; } -export class ConnectorTokenClient { +export class SharedConnectorTokenClient { private readonly logger: Logger; private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; @@ -440,6 +440,3 @@ export class ConnectorTokenClient { } } } - -// Compatibility alias for existing imports -export { ConnectorTokenClient as SharedConnectorTokenClient }; diff --git a/x-pack/platform/plugins/shared/actions/server/mocks.ts b/x-pack/platform/plugins/shared/actions/server/mocks.ts index 47a5d645fc651..f208d0f4fef9c 100644 --- a/x-pack/platform/plugins/shared/actions/server/mocks.ts +++ b/x-pack/platform/plugins/shared/actions/server/mocks.ts @@ -20,7 +20,7 @@ import type { PluginSetupContract, PluginStartContract } from './plugin'; import { renderActionParameterTemplates } from './plugin'; import type { Services, UnsecuredServices } from './types'; import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; -import { ConnectorTokenClientFacade } from './lib/connector_token_client'; +import { ConnectorTokenClient } from './lib/connector_token_client'; import { unsecuredActionsClientMock } from './unsecured_actions_client/unsecured_actions_client.mock'; export { actionsAuthorizationMock }; export { actionsClientMock }; @@ -92,7 +92,7 @@ const createServicesMock = () => { > = lazyObject({ savedObjectsClient: savedObjectsClientMock.create(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient: savedObjectsClientMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, @@ -109,7 +109,7 @@ const createUnsecuredServicesMock = () => { > = lazyObject({ savedObjectsClient: savedObjectsRepositoryMock.create(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient().asCurrentUser, - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient: savedObjectsRepositoryMock.create(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), logger, diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.ts b/x-pack/platform/plugins/shared/actions/server/plugin.ts index 06d70c7f00f8d..d873af23a5760 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.ts @@ -91,7 +91,7 @@ import { getAlertHistoryEsIndex } from './preconfigured_connectors/alert_history import { createAlertHistoryIndexTemplate } from './preconfigured_connectors/alert_history_es_index/create_alert_history_index_template'; import { ACTIONS_FEATURE_ID, AlertHistoryEsIndexConnectorId } from '../common'; import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER } from './constants/event_log'; -import { ConnectorTokenClientFacade } from './lib/connector_token_client'; +import { ConnectorTokenClient } from './lib/connector_token_client'; import { InMemoryMetrics, registerClusterCollector, registerNodeCollector } from './monitoring'; import type { ConnectorWithOptionalDeprecation } from './application/connector/lib'; import { isConnectorDeprecated } from './application/connector/lib'; @@ -531,7 +531,7 @@ export class ActionsPlugin }), auditLogger: this.security?.audit.asScoped(request), usageCounter: this.usageCounter, - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient, encryptedSavedObjectsClient, logger, @@ -755,7 +755,7 @@ export class ActionsPlugin return { savedObjectsClient: getScopedClient(request), scopedClusterClient: elasticsearch.client.asScoped(request).asCurrentUser, - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient: unsecuredSavedObjectsClient(request), encryptedSavedObjectsClient, logger: this.logger, @@ -774,7 +774,7 @@ export class ActionsPlugin return { savedObjectsClient: getSavedObjectRepository(), scopedClusterClient: elasticsearch.client.asInternalUser, - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient: unsecuredSavedObjectsRepository(), encryptedSavedObjectsClient, logger: this.logger, @@ -859,7 +859,7 @@ export class ActionsPlugin }), auditLogger: security?.audit.asScoped(request), usageCounter, - connectorTokenClient: new ConnectorTokenClientFacade({ + connectorTokenClient: new ConnectorTokenClient({ unsecuredSavedObjectsClient, encryptedSavedObjectsClient, logger, diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts index 0f1edc9c29cfc..49d11e0b61215 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts @@ -17,7 +17,7 @@ import { DEFAULT_ACTION_ROUTE_SECURITY } from './constants'; import { verifyAccessAndContext } from './verify_access_and_context'; import { OAuthStateClient } from '../lib/oauth_state_client'; import { requestOAuthAuthorizationCodeToken } from '../lib/request_oauth_authorization_code_token'; -import { ConnectorTokenClientFacade } from '../lib/connector_token_client'; +import { ConnectorTokenClient } from '../lib/connector_token_client'; import type { OAuthRateLimiter } from '../lib/oauth_rate_limiter'; const querySchema = schema.object({ @@ -382,7 +382,7 @@ export const oauthCallbackRoute = ( ); // Store tokens - first delete any existing tokens for this connector then create a new token record - const connectorTokenClient = new ConnectorTokenClientFacade({ + const connectorTokenClient = new ConnectorTokenClient({ encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ includedHiddenTypes: ['connector_token', 'user_connector_token'], }), diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index 15052714f9c7c..a6e1b9dad59cc 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -20,7 +20,7 @@ import type { LicenseType } from '@kbn/licensing-types'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type * as z3 from '@kbn/zod'; import type * as z4 from '@kbn/zod/v4'; -import type { ConnectorTokenClientFacade } from './lib/connector_token_client'; +import type { ConnectorTokenClient } from './lib/connector_token_client'; import type { ActionTypeExecutorResult, SubFeature, ActionTypeSource } from '../common'; import type { ActionTypeRegistry } from './action_type_registry'; import type { ActionsClient } from './actions_client'; @@ -41,7 +41,7 @@ export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefine export type ActionTypeConfig = Record; export type ActionTypeSecrets = Record; export type ActionTypeParams = Record; -export type ConnectorTokenClientContract = PublicMethodsOf; +export type ConnectorTokenClientContract = PublicMethodsOf; import type { Connector, ConnectorWithExtraFindData } from './application/connector/types'; import type { ActionExecutionSource, ActionExecutionSourceType } from './lib'; @@ -50,13 +50,13 @@ import type { ConnectorUsageCollector } from './usage'; export interface Services { savedObjectsClient: SavedObjectsClientContract; scopedClusterClient: ElasticsearchClient; - connectorTokenClient: ConnectorTokenClientFacade; + connectorTokenClient: ConnectorTokenClient; } export interface UnsecuredServices { savedObjectsClient: ISavedObjectsRepository; scopedClusterClient: ElasticsearchClient; - connectorTokenClient: ConnectorTokenClientFacade; + connectorTokenClient: ConnectorTokenClient; } export interface HookServices { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts index 4094d6be69f18..64ab76463bf0b 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts @@ -13,11 +13,11 @@ import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; import type { ServiceParams } from '@kbn/actions-plugin/server'; import type { CrowdstrikeConfig, CrowdstrikeSecrets } from '@kbn/connector-schemas/crowdstrike'; import type { ConnectorToken } from '@kbn/actions-plugin/server/types'; -import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; +import type { SharedConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; describe('CrowdStrikeTokenManager', () => { let csTokenManager: CrowdStrikeTokenManager; - let connectorTokenClientMock: jest.Mocked; + let connectorTokenClientMock: jest.Mocked; let usageCollector: ConnectorUsageCollector; let mockRequest: jest.Mock; @@ -41,7 +41,7 @@ describe('CrowdStrikeTokenManager', () => { mockRequest = jest.fn(); const mockServices = actionsMock.createServices(); connectorTokenClientMock = - mockServices.connectorTokenClient as unknown as jest.Mocked; + mockServices.connectorTokenClient as unknown as jest.Mocked; // Apply connector token client mock behavior let cachedTokenMock: ConnectorToken | null = null; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts index 836729e4294ac..47a37d989ecff 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts @@ -18,7 +18,7 @@ import type { MicrosoftDefenderEndpointSecrets, } from '@kbn/connector-schemas/microsoft_defender_endpoint'; import { CONNECTOR_ID } from '@kbn/connector-schemas/microsoft_defender_endpoint'; -import type { ConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; +import type { SharedConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; import { MicrosoftDefenderEndpointConnector } from './microsoft_defender_endpoint'; import type { ConnectorInstanceMock } from '../lib/mocks'; import { createAxiosResponseMock, createConnectorInstanceMock } from '../lib/mocks'; @@ -48,7 +48,7 @@ const createConnectorTokenMock = (overrides: Partial = {}): Conn }; const applyConnectorTokenClientInstanceMock = ( - connectorTokenClient: ConnectorTokenClient + connectorTokenClient: SharedConnectorTokenClient ): void => { // Make connector token client a mocked class instance let cachedTokenMock: ConnectorToken | null = null; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts index af64c45efe80f..61620e32b7039 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts @@ -8,17 +8,17 @@ import type { CreateMicrosoftDefenderConnectorMockResponse } from './mocks'; import { microsoftDefenderEndpointConnectorMocks } from './mocks'; import { OAuthTokenManager } from './o_auth_token_manager'; -import type { ConnectorTokenClientFacade } from '@kbn/actions-plugin/server/lib/connector_token_client'; +import type { SharedConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; describe('Microsoft Defender for Endpoint oAuth token manager', () => { let testMock: CreateMicrosoftDefenderConnectorMockResponse; let msOAuthManagerMock: OAuthTokenManager; - let connectorTokenManagerClientMock: jest.Mocked; + let connectorTokenManagerClientMock: jest.Mocked; beforeEach(() => { testMock = microsoftDefenderEndpointConnectorMocks.create(); - connectorTokenManagerClientMock = testMock.options.services - .connectorTokenClient as jest.Mocked; + connectorTokenManagerClientMock = testMock.options.services.connectorTokenClient + .getSharedCredentialsClient() as jest.Mocked; msOAuthManagerMock = new OAuthTokenManager({ ...testMock.options, apiRequest: async (...args) => testMock.instanceMock.request(...args), From 8ff5004771ba95f1d4efad82ab2e16a7b16a4199 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:56:20 +0000 Subject: [PATCH 08/16] Changes from node scripts/eslint_all_files --no-cache --fix --- .../microsoft_defender_endpoint/o_auth_token_manager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts index 61620e32b7039..b3fdc5d86cec9 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts @@ -17,8 +17,8 @@ describe('Microsoft Defender for Endpoint oAuth token manager', () => { beforeEach(() => { testMock = microsoftDefenderEndpointConnectorMocks.create(); - connectorTokenManagerClientMock = testMock.options.services.connectorTokenClient - .getSharedCredentialsClient() as jest.Mocked; + connectorTokenManagerClientMock = + testMock.options.services.connectorTokenClient.getSharedCredentialsClient() as jest.Mocked; msOAuthManagerMock = new OAuthTokenManager({ ...testMock.options, apiRequest: async (...args) => testMock.instanceMock.request(...args), From a16fcd6d267f0e68d0354cf2ed97700a0910cf79 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:45:48 +0100 Subject: [PATCH 09/16] fix type issues --- .../shared/actions/server/lib/connector_token_client.mock.ts | 4 ++-- .../shared/actions/server/lib/connector_token_client.ts | 4 ++-- .../server/connector_types/crowdstrike/token_manager.test.ts | 5 +++-- .../connector_types/microsoft_defender_endpoint/mocks.ts | 2 +- .../microsoft_defender_endpoint/o_auth_token_manager.test.ts | 4 ++-- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts index 46b6b5293ff66..4c8ada0ce84c7 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts @@ -42,8 +42,8 @@ const createConnectorTokenClientMock = (): jest.Mocked sharedCredentialsClientMock), - getUserCredentialsClient: jest.fn(() => userCredentialsClientMock), + getSharedConnectorTokenClient: jest.fn(() => sharedCredentialsClientMock), + getUserConnectorTokenClient: jest.fn(() => userCredentialsClientMock), } satisfies jest.Mocked; return mocked; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index 60c334b6c3887..fbcef616463ac 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -276,11 +276,11 @@ export class ConnectorTokenClient { return this.sharedClient.updateWithRefreshToken({ ...options, id: actualId }); } - public getSharedCredentialsClient(): SharedConnectorTokenClient { + public getSharedConnectorTokenClient(): SharedConnectorTokenClient { return this.sharedClient; } - public getUserCredentialsClient(): UserConnectorTokenClient { + public getUserConnectorTokenClient(): UserConnectorTokenClient { return this.userClient; } } diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts index 64ab76463bf0b..87aad2a681ae7 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts @@ -40,8 +40,9 @@ describe('CrowdStrikeTokenManager', () => { beforeEach(() => { mockRequest = jest.fn(); const mockServices = actionsMock.createServices(); - connectorTokenClientMock = - mockServices.connectorTokenClient as unknown as jest.Mocked; + connectorTokenClientMock = jest.mocked( + mockServices.connectorTokenClient.getSharedConnectorTokenClient() + ); // Apply connector token client mock behavior let cachedTokenMock: ConnectorToken | null = null; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts index 47a37d989ecff..ab6cd90155a61 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts @@ -177,7 +177,7 @@ const createMicrosoftDefenderConnectorMock = (): CreateMicrosoftDefenderConnecto ); applyConnectorTokenClientInstanceMock( - options.services.connectorTokenClient.getSharedCredentialsClient() + options.services.connectorTokenClient.getSharedConnectorTokenClient() ); return { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts index 61620e32b7039..f3335455cae6f 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts @@ -17,8 +17,8 @@ describe('Microsoft Defender for Endpoint oAuth token manager', () => { beforeEach(() => { testMock = microsoftDefenderEndpointConnectorMocks.create(); - connectorTokenManagerClientMock = testMock.options.services.connectorTokenClient - .getSharedCredentialsClient() as jest.Mocked; + connectorTokenManagerClientMock = + testMock.options.services.connectorTokenClient.getSharedConnectorTokenClient() as jest.Mocked; msOAuthManagerMock = new OAuthTokenManager({ ...testMock.options, apiRequest: async (...args) => testMock.instanceMock.request(...args), From 9de17288fec89757a43c426eae306c2e8eb5b8e3 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:10:53 +0100 Subject: [PATCH 10/16] use per-user instead of personal --- .../server/lib/connector_token_client.mock.ts | 28 ------ .../server/lib/connector_token_client.test.ts | 91 ++++++++++++++++++- .../server/lib/connector_token_client.ts | 51 +++-------- .../lib/shared_connector_token_client.test.ts | 9 +- .../lib/shared_connector_token_client.ts | 45 +++++++-- .../lib/user_connector_token_client.test.ts | 28 +++--- .../server/lib/user_connector_token_client.ts | 57 +++++++----- .../crowdstrike/token_manager.test.ts | 54 +++++++---- .../microsoft_defender_endpoint/mocks.ts | 62 +++++++++---- .../o_auth_token_manager.test.ts | 7 +- 10 files changed, 269 insertions(+), 163 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts index 4c8ada0ce84c7..8145a7006243a 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts @@ -6,44 +6,16 @@ */ import type { ConnectorTokenClientContract } from '../types'; -import type { SharedConnectorTokenClient } from './shared_connector_token_client'; -import type { UserConnectorTokenClient } from './user_connector_token_client'; - -const sharedCredentialsClientMock = { - create: jest.fn() as jest.MockedFunction, - get: jest.fn() as jest.MockedFunction, - update: jest.fn(), - deleteConnectorTokens: jest.fn(), - updateOrReplace: jest.fn(), - createWithRefreshToken: jest.fn(), - updateWithRefreshToken: jest.fn(), -} as unknown as jest.Mocked; - -const userCredentialsClientMock = { - create: jest.fn() as jest.MockedFunction, - get: jest.fn() as jest.MockedFunction, - getOAuthPersonalToken: jest.fn() as jest.MockedFunction< - UserConnectorTokenClient['getOAuthPersonalToken'] - >, - update: jest.fn(), - deleteConnectorTokens: jest.fn(), - updateOrReplace: jest.fn(), - createWithRefreshToken: jest.fn(), - updateWithRefreshToken: jest.fn(), -} as unknown as jest.Mocked; const createConnectorTokenClientMock = (): jest.Mocked => { const mocked = { create: jest.fn() as jest.MockedFunction, get: jest.fn() as jest.MockedFunction, - getOAuthPersonalToken: jest.fn(), update: jest.fn(), deleteConnectorTokens: jest.fn(), updateOrReplace: jest.fn(), createWithRefreshToken: jest.fn(), updateWithRefreshToken: jest.fn(), - getSharedConnectorTokenClient: jest.fn(() => sharedCredentialsClientMock), - getUserConnectorTokenClient: jest.fn(() => userCredentialsClientMock), } satisfies jest.Mocked; return mocked; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts index ed49ded553d2a..8ffb3fb5272b0 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts @@ -72,6 +72,7 @@ describe('create()', () => { token: 'testtokenvalue', }); expect(result).toEqual({ + id: 'shared:mock-saved-object-id', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -123,7 +124,7 @@ describe('get()', () => { expect(result).toEqual({ hasErrors: false, connectorToken: { - id: '1', + id: 'shared:1', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -269,12 +270,13 @@ describe('update()', () => { errors: [], }); const result = await connectorTokenClient.update({ - id: '1', + id: 'shared:1', tokenType: 'access_token', token: 'testtokenvalue', expiresAtMillis: expiresAt, }); expect(result).toEqual({ + id: 'shared:1', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -293,6 +295,89 @@ describe('update()', () => { `); }); + test('accepts unprefixed token ID and defaults to shared', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'newtokenvalue', + expiresAt, + }, + references: [], + }); + + const result = await connectorTokenClient.update({ + id: '1', + tokenType: 'access_token', + token: 'newtokenvalue', + expiresAtMillis: expiresAt, + }); + + // Should treat unprefixed ID as shared and return with shared: prefix + expect(result).toEqual({ + id: 'shared:1', + connectorId: '123', + tokenType: 'access_token', + token: 'newtokenvalue', + expiresAt, + }); + }); + + test('correctly routes per-user: prefixed ID to user client', async () => { + const expiresAt = new Date().toISOString(); + + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'user-token-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { accessToken: 'testtokenvalue' }, + createdAt: new Date().toISOString(), + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'user-token-1', + type: 'user_connector_token', + attributes: { + profileUid: 'user-123', + connectorId: '123', + credentialType: 'oauth', + credentials: { accessToken: 'newtokenvalue' }, + expiresAt, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [], + }); + + const result = await connectorTokenClient.update({ + id: 'per-user:user-token-1', + credentials: { accessToken: 'newtokenvalue' }, + expiresAtMillis: expiresAt, + }); + + // Should return with per-user: prefix intact + expect(result?.id).toBe('per-user:user-token-1'); + }); + test('should log error, when failed to update the connector token if there are a conflict errors', async () => { const expiresAt = new Date().toISOString(); @@ -597,7 +682,7 @@ describe('updateOrReplace()', () => { await connectorTokenClient.updateOrReplace({ connectorId: '1', token: { - id: '3', + id: 'shared:3', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index fbcef616463ac..18aa6bd80f415 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -8,7 +8,7 @@ import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; import type { Logger, SavedObjectsClientContract, SavedObjectAttributes } from '@kbn/core/server'; import { SharedConnectorTokenClient } from './shared_connector_token_client'; -import type { ConnectorToken, UserConnectorToken, UserConnectorOAuthToken } from '../types'; +import type { ConnectorToken, UserConnectorToken } from '../types'; import { UserConnectorTokenClient } from './user_connector_token_client'; export const MAX_TOKENS_RETURNED = 1; @@ -59,13 +59,14 @@ export class ConnectorTokenClient { this.userClient = new UserConnectorTokenClient(options); } - private parseTokenId(id: string): { scope: 'personal' | 'shared'; actualId: string } { - if (id.startsWith('personal:')) { - return { scope: 'personal', actualId: id.substring(9) }; + private parseTokenId(id: string): { scope: 'per-user' | 'shared'; actualId: string } { + if (id.startsWith('per-user:')) { + return { scope: 'per-user', actualId: id.substring(9) }; } if (id.startsWith('shared:')) { return { scope: 'shared', actualId: id.substring(7) }; } + // Default unprefixed IDs to shared for backward compatibility return { scope: 'shared', actualId: id }; } @@ -75,7 +76,7 @@ export class ConnectorTokenClient { fields, }: { method: string; - scope: 'personal' | 'shared'; + scope: 'per-user' | 'shared'; fields: Record; }): void { const parts = Object.entries(fields).map(([key, value]) => @@ -103,7 +104,7 @@ export class ConnectorTokenClient { credentialType?: string; }): Promise; public async create(options: CreateOptions): Promise { - const scope = options.profileUid ? 'personal' : 'shared'; + const scope = options.profileUid ? 'per-user' : 'shared'; this.log({ method: 'create', scope, fields: { connectorId: options.connectorId } }); if (options.profileUid) { return this.userClient.create(options as Parameters[0]); @@ -117,7 +118,7 @@ export class ConnectorTokenClient { public async update(options: UpdateOptions): Promise { const { scope, actualId } = this.parseTokenId(options.id); this.log({ method: 'update', scope, fields: { id: actualId } }); - if (scope === 'personal') { + if (scope === 'per-user') { return this.userClient.update({ ...options, id: actualId }); } if (options.token) { @@ -163,7 +164,7 @@ export class ConnectorTokenClient { hasErrors: boolean; connectorToken: ConnectorToken | UserConnectorToken | null; }> { - const scope = options.profileUid ? 'personal' : 'shared'; + const scope = options.profileUid ? 'per-user' : 'shared'; this.log({ method: 'get', scope, fields: { connectorId: options.connectorId } }); if (options.profileUid) { return this.userClient.get(options as Parameters[0]); @@ -171,24 +172,6 @@ export class ConnectorTokenClient { return this.sharedClient.get(options as Parameters[0]); } - /** - * Get OAuth personal token with parsed credentials - */ - public async getOAuthPersonalToken(options: { - profileUid: string; - connectorId: string; - }): Promise<{ - hasErrors: boolean; - connectorToken: UserConnectorOAuthToken | null; - }> { - this.log({ - method: 'getOAuthPersonalToken', - scope: 'personal', - fields: { connectorId: options.connectorId }, - }); - return this.userClient.getOAuthPersonalToken(options); - } - /** * Delete all connector tokens (delegates to shared or user client) */ @@ -198,7 +181,7 @@ export class ConnectorTokenClient { tokenType?: string; credentialType?: string; }): Promise { - const scope = options.profileUid ? 'personal' : 'shared'; + const scope = options.profileUid ? 'per-user' : 'shared'; this.log({ method: 'deleteConnectorTokens', scope, @@ -215,7 +198,7 @@ export class ConnectorTokenClient { } public async updateOrReplace(options: UpdateOrReplaceOptions) { - const scope = options.profileUid ? 'personal' : 'shared'; + const scope = options.profileUid ? 'per-user' : 'shared'; this.log({ method: 'updateOrReplace', scope, fields: { connectorId: options.connectorId } }); if (options.profileUid) { return this.userClient.updateOrReplace( @@ -240,7 +223,7 @@ export class ConnectorTokenClient { tokenType?: string; credentialType?: string; }): Promise { - const scope = options.profileUid ? 'personal' : 'shared'; + const scope = options.profileUid ? 'per-user' : 'shared'; this.log({ method: 'createWithRefreshToken', scope, @@ -270,17 +253,9 @@ export class ConnectorTokenClient { }): Promise { const { scope, actualId } = this.parseTokenId(options.id); this.log({ method: 'updateWithRefreshToken', scope, fields: { id: actualId } }); - if (scope === 'personal') { + if (scope === 'per-user') { return this.userClient.updateWithRefreshToken({ ...options, id: actualId }); } return this.sharedClient.updateWithRefreshToken({ ...options, id: actualId }); } - - public getSharedConnectorTokenClient(): SharedConnectorTokenClient { - return this.sharedClient; - } - - public getUserConnectorTokenClient(): UserConnectorTokenClient { - return this.userClient; - } } diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts index 0a107b983ac67..e9c6136723759 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.test.ts @@ -67,6 +67,7 @@ describe('SharedConnectorTokenClient', () => { token: 'testtokenvalue', }); expect(result).toEqual({ + id: 'shared:mock-saved-object-id', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -118,7 +119,7 @@ describe('SharedConnectorTokenClient', () => { expect(result).toEqual({ hasErrors: false, connectorToken: { - id: '1', + id: 'shared:1', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -172,12 +173,13 @@ describe('SharedConnectorTokenClient', () => { }); const result = await sharedClient.update({ - id: '1', + id: 'shared:1', tokenType: 'access_token', token: 'testtokenvalue', expiresAtMillis: expiresAt, }); expect(result).toEqual({ + id: 'shared:1', connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', @@ -291,13 +293,14 @@ describe('SharedConnectorTokenClient', () => { }); const result = await sharedClient.updateWithRefreshToken({ - id: '1', + id: 'shared:1', token: 'newtoken', refreshToken: 'newrefresh', expiresIn: 3600, }); expect(result).toMatchObject({ + id: 'shared:1', connectorId: '123', token: 'newtoken', refreshToken: 'newrefresh', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts index 3258846d13e5f..fa27c56abbc21 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts @@ -60,6 +60,18 @@ export class SharedConnectorTokenClient { this.logger = logger; } + private formatTokenId(rawId: string): string { + return `shared:${rawId}`; + } + + private parseTokenId(id: string): string { + if (id.startsWith('shared:')) { + return id.substring(7); + } + // Accept unprefixed IDs for backward compatibility + return id; + } + /** * Create new token for connector */ @@ -85,7 +97,10 @@ export class SharedConnectorTokenClient { { id } ); - return result.attributes as ConnectorToken; + return { + ...result.attributes, + id: this.formatTokenId(id), + } as ConnectorToken; } catch (err) { this.logger.error( `Failed to create connector_token for connectorId "${connectorId}" and tokenType: "${ @@ -105,10 +120,11 @@ export class SharedConnectorTokenClient { expiresAtMillis, tokenType, }: UpdateOptions): Promise { + const actualId = this.parseTokenId(id); const { attributes, references, version } = await this.unsecuredSavedObjectsClient.get( CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - id + actualId ); const createTime = Date.now(); @@ -139,12 +155,15 @@ export class SharedConnectorTokenClient { const result = await retryIfConflicts( this.logger, - `accessToken.create('${id}')`, + `accessToken.create('${actualId}')`, updateOperation, MAX_RETRY_ATTEMPTS ); - return result.attributes as ConnectorToken; + return { + ...result.attributes, + id: this.formatTokenId(actualId), + } as ConnectorToken; } catch (err) { this.logger.error( `Failed to update connector_token for id "${id}" and tokenType: "${ @@ -232,7 +251,7 @@ export class SharedConnectorTokenClient { return { hasErrors: false, connectorToken: { - id: connectorTokensResult[0].id, + id: this.formatTokenId(connectorTokensResult[0].id), ...connectorTokensResult[0].attributes, token: accessToken, }, @@ -302,6 +321,7 @@ export class SharedConnectorTokenClient { `Cannot update connector token for connectorId "${connectorId}": token id is missing` ); } + // Token ID should already have shared: prefix from get() await this.update({ id: tokenId, token: newToken, @@ -355,7 +375,10 @@ export class SharedConnectorTokenClient { { id } ); - return result.attributes as ConnectorToken; + return { + ...result.attributes, + id: this.formatTokenId(id), + } as ConnectorToken; } catch (err) { this.logger.error( `Failed to create connector_token with refresh token for connectorId "${connectorId}". Error: ${err.message}` @@ -382,10 +405,11 @@ export class SharedConnectorTokenClient { refreshTokenExpiresIn?: number; tokenType?: string; }): Promise { + const actualId = this.parseTokenId(id); const { attributes, references, version } = await this.unsecuredSavedObjectsClient.get( CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - id + actualId ); const now = Date.now(); const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; @@ -426,12 +450,15 @@ export class SharedConnectorTokenClient { const result = await retryIfConflicts( this.logger, - `accessToken.updateWithRefreshToken('${id}')`, + `accessToken.updateWithRefreshToken('${actualId}')`, updateOperation, MAX_RETRY_ATTEMPTS ); - return result.attributes as ConnectorToken; + return { + ...result.attributes, + id: this.formatTokenId(actualId), + } as ConnectorToken; } catch (err) { this.logger.error( `Failed to update connector_token with refresh token for id "${id}". Error: ${err.message}` diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts index 057fd31e62b22..9c4c8b2ec30ca 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts @@ -79,7 +79,7 @@ describe('UserConnectorTokenClient', () => { }); expect(result).toMatchObject({ - id: 'personal:mock-saved-object-id', + id: 'per-user:mock-saved-object-id', profileUid: 'user-profile-123', connectorId: '123', credentialType: 'oauth', @@ -108,12 +108,12 @@ describe('UserConnectorTokenClient', () => { connectorId: '123', credentials: {}, }) - ).rejects.toThrow('Personal credentials are required to create a user connector token'); + ).rejects.toThrow('Per-user credentials are required to create a user connector token'); }); }); describe('get()', () => { - test('retrieves personal token by profileUid and connectorId', async () => { + test('retrieves per-user token by profileUid and connectorId', async () => { const expiresAt = new Date().toISOString(); const createdAt = new Date().toISOString(); const expectedResult = { @@ -167,7 +167,7 @@ describe('UserConnectorTokenClient', () => { expect(result).toEqual({ hasErrors: false, connectorToken: { - id: 'personal:token-id-1', + id: 'per-user:token-id-1', profileUid: 'user-profile-123', connectorId: '123', credentialType: 'oauth', @@ -254,7 +254,7 @@ describe('UserConnectorTokenClient', () => { expect(result).toEqual({ hasErrors: false, connectorToken: { - id: 'personal:token-id-1', + id: 'per-user:token-id-1', profileUid: 'user-profile-123', connectorId: '123', credentialType: 'oauth', @@ -326,7 +326,7 @@ describe('UserConnectorTokenClient', () => { }); describe('createWithRefreshToken()', () => { - test('creates personal token with refresh token', async () => { + test('creates per-user token with refresh token', async () => { const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ @@ -356,7 +356,7 @@ describe('UserConnectorTokenClient', () => { }); expect(result).toMatchObject({ - id: 'personal:mock-saved-object-id', + id: 'per-user:mock-saved-object-id', profileUid: 'user-profile-123', connectorId: '123', credentialType: 'oauth', @@ -382,7 +382,7 @@ describe('UserConnectorTokenClient', () => { }); describe('update()', () => { - test('updates personal token with personal: prefix in id', async () => { + test('updates per-user token with per-user: prefix in id', async () => { const expiresAt = new Date().toISOString(); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ @@ -420,13 +420,13 @@ describe('UserConnectorTokenClient', () => { }); const result = await userClient.update({ - id: 'personal:token-id-1', + id: 'per-user:token-id-1', token: 'newtoken', expiresAtMillis: expiresAt, }); expect(result).toMatchObject({ - id: 'personal:token-id-1', + id: 'per-user:token-id-1', profileUid: 'user-profile-123', connectorId: '123', credentials: { @@ -442,7 +442,7 @@ describe('UserConnectorTokenClient', () => { }); describe('updateWithRefreshToken()', () => { - test('updates personal token with new refresh token', async () => { + test('updates per-user token with new refresh token', async () => { const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ @@ -481,14 +481,14 @@ describe('UserConnectorTokenClient', () => { }); const result = await userClient.updateWithRefreshToken({ - id: 'personal:token-id-1', + id: 'per-user:token-id-1', token: 'newtoken', refreshToken: 'newrefresh', expiresIn: 3600, }); expect(result).toMatchObject({ - id: 'personal:token-id-1', + id: 'per-user:token-id-1', credentials: { accessToken: 'newtoken', refreshToken: 'newrefresh', @@ -498,7 +498,7 @@ describe('UserConnectorTokenClient', () => { }); describe('deleteConnectorTokens()', () => { - test('deletes personal tokens for profileUid and connectorId', async () => { + test('deletes per-user tokens for profileUid and connectorId', async () => { unsecuredSavedObjectsClient.delete.mockResolvedValue({}); const findResult = { diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts index aafed8e840476..6da980bbc07b7 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts @@ -82,18 +82,19 @@ export class UserConnectorTokenClient { this.logger = logger; } - private parseTokenId(id: string): { scope: 'personal' | 'shared'; actualId: string } { - if (id.startsWith('personal:')) { - return { scope: 'personal', actualId: id.substring(9) }; + private parseTokenId(id: string): { scope: 'per-user' | 'shared'; actualId: string } { + if (id.startsWith('per-user:')) { + return { scope: 'per-user', actualId: id.substring(9) }; } if (id.startsWith('shared:')) { return { scope: 'shared', actualId: id.substring(7) }; } - return { scope: 'shared', actualId: id }; + // Default unprefixed IDs to per-user when called on user client + return { scope: 'per-user', actualId: id }; } private formatTokenId(rawId: string): string { - return `personal:${rawId}`; + return `per-user:${rawId}`; } private getContextString( @@ -108,9 +109,9 @@ export class UserConnectorTokenClient { return parts.join(', '); } - private parseOAuthPersonalCredentials(credentials: unknown): OAuthPersonalCredentials | null { + private parseOAuthPerUserCredentials(credentials: unknown): OAuthPersonalCredentials | null { const schema = z.object({ - accessToken: z.string(), + accessToken: z.string().min(1), refreshToken: z.string().optional(), }); @@ -119,7 +120,7 @@ export class UserConnectorTokenClient { } /** - * Create new personal token for connector + * Create new per-user token for connector */ public async create({ profileUid, @@ -138,7 +139,7 @@ export class UserConnectorTokenClient { credentials ?? (token ? { accessToken: token } : ({} as SavedObjectAttributes)); if (Object.keys(resolvedCredentials).length === 0) { - throw new Error('Personal credentials are required to create a user connector token'); + throw new Error('Per-user credentials are required to create a user connector token'); } const context = this.getContextString(profileUid, connectorId, resolvedCredentialType); @@ -173,7 +174,7 @@ export class UserConnectorTokenClient { } /** - * Update personal connector token + * Update per-user connector token */ public async update({ id, @@ -211,7 +212,7 @@ export class UserConnectorTokenClient { : (attributesWithoutId as PersonalTokenAttributes).credentials); if (Object.keys(resolvedCredentials).length === 0) { - throw new Error('Personal credentials are required to update a user connector token'); + throw new Error('Per-user credentials are required to update a user connector token'); } const updatedAttributes: PersonalTokenAttributes = { @@ -254,7 +255,7 @@ export class UserConnectorTokenClient { } /** - * Get personal connector token + * Get per-user connector token */ public async get({ profileUid, @@ -320,11 +321,11 @@ export class UserConnectorTokenClient { connectorTokensResult[0].id ); - const personalToken = decrypted.attributes as UserConnectorToken; + const perUserToken = decrypted.attributes as UserConnectorToken; this.logger.debug( - `Retrieved personal credentials for ${context}, credentialKeys: ${Object.keys( - personalToken.credentials as Record + `Retrieved per-user credentials for ${context}, credentialKeys: ${Object.keys( + perUserToken.credentials as Record ).join(', ')}` ); @@ -332,7 +333,7 @@ export class UserConnectorTokenClient { hasErrors: false, connectorToken: { id: this.formatTokenId(connectorTokensResult[0].id), - ...personalToken, + ...perUserToken, }, }; } catch (err) { @@ -344,7 +345,7 @@ export class UserConnectorTokenClient { } /** - * Get OAuth personal token with parsed credentials + * Get OAuth per-user token with parsed credentials */ public async getOAuthPersonalToken({ profileUid, @@ -368,12 +369,20 @@ export class UserConnectorTokenClient { if (!('credentials' in connectorToken)) { this.logger.error( - `Expected personal credentials for connectorId "${connectorId}", profileUid "${profileUid}".` + `Expected per-user credentials for connectorId "${connectorId}", profileUid "${profileUid}".` ); return { hasErrors: true, connectorToken: null }; } - const parsedCredentials = this.parseOAuthPersonalCredentials(connectorToken.credentials); + // Verify credential type matches oauth before parsing + if (connectorToken.credentialType !== 'oauth') { + this.logger.error( + `Expected OAuth credential type but found "${connectorToken.credentialType}" for connectorId "${connectorId}", profileUid "${profileUid}".` + ); + return { hasErrors: true, connectorToken: null }; + } + + const parsedCredentials = this.parseOAuthPerUserCredentials(connectorToken.credentials); if (!parsedCredentials) { this.logger.error( `Invalid OAuth credentials shape for connectorId "${connectorId}", profileUid "${profileUid}".` @@ -392,7 +401,7 @@ export class UserConnectorTokenClient { } /** - * Delete all personal connector tokens + * Delete all per-user connector tokens */ public async deleteConnectorTokens({ profileUid, @@ -479,7 +488,7 @@ export class UserConnectorTokenClient { } /** - * Create new personal token with refresh token support + * Create new per-user token with refresh token support */ public async createWithRefreshToken({ profileUid, @@ -518,7 +527,7 @@ export class UserConnectorTokenClient { } this.logger.debug( - `Creating personal token with credentials blob for profileUid: ${profileUid}, connectorId: ${connectorId}, credentialKeys: ${Object.keys( + `Creating per-user token with credentials blob for profileUid: ${profileUid}, connectorId: ${connectorId}, credentialKeys: ${Object.keys( credentials ).join(', ')}` ); @@ -557,7 +566,7 @@ export class UserConnectorTokenClient { } /** - * Update personal token with refresh token + * Update per-user token with refresh token */ public async updateWithRefreshToken({ id, @@ -614,7 +623,7 @@ export class UserConnectorTokenClient { } this.logger.debug( - `Updating personal token with refresh token for id: ${id}, credentialKeys: ${Object.keys( + `Updating per-user token with refresh token for id: ${id}, credentialKeys: ${Object.keys( credentials ).join(', ')}` ); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts index 87aad2a681ae7..8c5aa341e7886 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts @@ -9,15 +9,17 @@ import { CrowdStrikeTokenManager } from './token_manager'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; -import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types'; +import { + ConnectorUsageCollector, + type ConnectorTokenClientContract, +} from '@kbn/actions-plugin/server/types'; import type { ServiceParams } from '@kbn/actions-plugin/server'; import type { CrowdstrikeConfig, CrowdstrikeSecrets } from '@kbn/connector-schemas/crowdstrike'; import type { ConnectorToken } from '@kbn/actions-plugin/server/types'; -import type { SharedConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; describe('CrowdStrikeTokenManager', () => { let csTokenManager: CrowdStrikeTokenManager; - let connectorTokenClientMock: jest.Mocked; + let connectorTokenClientMock: jest.Mocked; let usageCollector: ConnectorUsageCollector; let mockRequest: jest.Mock; @@ -40,35 +42,47 @@ describe('CrowdStrikeTokenManager', () => { beforeEach(() => { mockRequest = jest.fn(); const mockServices = actionsMock.createServices(); - connectorTokenClientMock = jest.mocked( - mockServices.connectorTokenClient.getSharedConnectorTokenClient() - ); + connectorTokenClientMock = jest.mocked(mockServices.connectorTokenClient); // Apply connector token client mock behavior let cachedTokenMock: ConnectorToken | null = null; jest - .spyOn(connectorTokenClientMock, 'create') - .mockImplementation( - async ({ connectorId, token, expiresAtMillis: expiresAt, tokenType = 'access_token' }) => { - cachedTokenMock = createConnectorTokenMock({ - connectorId, - token, - expiresAt, - tokenType, - }); - return cachedTokenMock; - } - ); + .spyOn( + connectorTokenClientMock as unknown as { create: ConnectorTokenClientContract['create'] }, + 'create' + ) + .mockImplementation((async ({ + connectorId, + token, + expiresAtMillis: expiresAt, + tokenType = 'access_token', + }: { + connectorId: string; + token?: string; + expiresAtMillis?: string; + tokenType?: string; + }) => { + cachedTokenMock = createConnectorTokenMock({ + connectorId, + token: token ?? '', + expiresAt, + tokenType, + }); + return cachedTokenMock; + }) as unknown as ConnectorTokenClientContract['create']); jest - .spyOn(connectorTokenClientMock, 'update') + .spyOn( + connectorTokenClientMock as unknown as { update: ConnectorTokenClientContract['update'] }, + 'update' + ) .mockImplementation( async ({ token, expiresAtMillis: expiresAt, tokenType = 'access_token' }) => { if (cachedTokenMock) { cachedTokenMock = { ...cachedTokenMock, - token, + token: token ?? cachedTokenMock.token, expiresAt, tokenType, }; diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts index ab6cd90155a61..7965e629b2199 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/mocks.ts @@ -18,7 +18,7 @@ import type { MicrosoftDefenderEndpointSecrets, } from '@kbn/connector-schemas/microsoft_defender_endpoint'; import { CONNECTOR_ID } from '@kbn/connector-schemas/microsoft_defender_endpoint'; -import type { SharedConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; +import type { ConnectorTokenClientContract } from '@kbn/actions-plugin/server/types'; import { MicrosoftDefenderEndpointConnector } from './microsoft_defender_endpoint'; import type { ConnectorInstanceMock } from '../lib/mocks'; import { createAxiosResponseMock, createConnectorInstanceMock } from '../lib/mocks'; @@ -48,33 +48,57 @@ const createConnectorTokenMock = (overrides: Partial = {}): Conn }; const applyConnectorTokenClientInstanceMock = ( - connectorTokenClient: SharedConnectorTokenClient + connectorTokenClient: ConnectorTokenClientContract ): void => { // Make connector token client a mocked class instance let cachedTokenMock: ConnectorToken | null = null; - jest.spyOn(connectorTokenClient, 'updateOrReplace'); + jest.spyOn(connectorTokenClient, 'updateOrReplace').mockImplementation(async (options) => { + const expiresAt = new Date( + options.tokenRequestDate + (options.expiresInSec ?? 0) * 1000 + ).toISOString(); + cachedTokenMock = createConnectorTokenMock({ + connectorId: options.connectorId, + token: options.newToken, + expiresAt, + tokenType: 'access_token', + }); + }); jest - .spyOn(connectorTokenClient, 'create') - .mockImplementation( - async ({ connectorId, token, expiresAtMillis: expiresAt, tokenType = 'access_token' }) => { - cachedTokenMock = createConnectorTokenMock({ - connectorId, - token, - expiresAt, - tokenType, - }); - return cachedTokenMock; - } - ); + .spyOn( + connectorTokenClient as unknown as { create: ConnectorTokenClientContract['create'] }, + 'create' + ) + .mockImplementation((async ({ + connectorId, + token, + expiresAtMillis: expiresAt, + tokenType = 'access_token', + }: { + connectorId: string; + token?: string; + expiresAtMillis?: string; + tokenType?: string; + }) => { + cachedTokenMock = createConnectorTokenMock({ + connectorId, + token: token ?? '', + expiresAt, + tokenType, + }); + return cachedTokenMock; + }) as unknown as ConnectorTokenClientContract['create']); jest - .spyOn(connectorTokenClient, 'update') + .spyOn( + connectorTokenClient as unknown as { update: ConnectorTokenClientContract['update'] }, + 'update' + ) .mockImplementation( async ({ token, expiresAtMillis: expiresAt, tokenType = 'access_token' }) => { if (cachedTokenMock) { cachedTokenMock = { ...cachedTokenMock, - token, + token: token ?? cachedTokenMock.token, expiresAt, tokenType, }; @@ -176,9 +200,7 @@ const createMicrosoftDefenderConnectorMock = (): CreateMicrosoftDefenderConnecto } ); - applyConnectorTokenClientInstanceMock( - options.services.connectorTokenClient.getSharedConnectorTokenClient() - ); + applyConnectorTokenClientInstanceMock(options.services.connectorTokenClient); return { options, diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts index f3335455cae6f..09fa33e09039d 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.test.ts @@ -8,17 +8,16 @@ import type { CreateMicrosoftDefenderConnectorMockResponse } from './mocks'; import { microsoftDefenderEndpointConnectorMocks } from './mocks'; import { OAuthTokenManager } from './o_auth_token_manager'; -import type { SharedConnectorTokenClient } from '@kbn/actions-plugin/server/lib/shared_connector_token_client'; +import type { ConnectorTokenClientContract } from '@kbn/actions-plugin/server/types'; describe('Microsoft Defender for Endpoint oAuth token manager', () => { let testMock: CreateMicrosoftDefenderConnectorMockResponse; let msOAuthManagerMock: OAuthTokenManager; - let connectorTokenManagerClientMock: jest.Mocked; + let connectorTokenManagerClientMock: jest.Mocked; beforeEach(() => { testMock = microsoftDefenderEndpointConnectorMocks.create(); - connectorTokenManagerClientMock = - testMock.options.services.connectorTokenClient.getSharedConnectorTokenClient() as jest.Mocked; + connectorTokenManagerClientMock = jest.mocked(testMock.options.services.connectorTokenClient); msOAuthManagerMock = new OAuthTokenManager({ ...testMock.options, apiRequest: async (...args) => testMock.instanceMock.request(...args), From 0569c067a1483a5927b240f473db0bb44435080d Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:29:35 +0100 Subject: [PATCH 11/16] connector token client does not change id --- .../actions/server/lib/connector_token_client.test.ts | 4 ++-- .../shared/actions/server/lib/connector_token_client.ts | 8 ++++---- .../actions/server/lib/shared_connector_token_client.ts | 4 ++-- .../actions/server/lib/user_connector_token_client.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts index 8ffb3fb5272b0..bf456378811ae 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.test.ts @@ -328,9 +328,9 @@ describe('update()', () => { expiresAtMillis: expiresAt, }); - // Should treat unprefixed ID as shared and return with shared: prefix + // Should preserve the unprefixed ID as-is expect(result).toEqual({ - id: 'shared:1', + id: '1', connectorId: '123', tokenType: 'access_token', token: 'newtokenvalue', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index 18aa6bd80f415..d09bd6ad38737 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -119,11 +119,11 @@ export class ConnectorTokenClient { const { scope, actualId } = this.parseTokenId(options.id); this.log({ method: 'update', scope, fields: { id: actualId } }); if (scope === 'per-user') { - return this.userClient.update({ ...options, id: actualId }); + return this.userClient.update(options); } if (options.token) { return this.sharedClient.update({ - id: actualId, + id: options.id, token: options.token, expiresAtMillis: options.expiresAtMillis, tokenType: options.tokenType, @@ -254,8 +254,8 @@ export class ConnectorTokenClient { const { scope, actualId } = this.parseTokenId(options.id); this.log({ method: 'updateWithRefreshToken', scope, fields: { id: actualId } }); if (scope === 'per-user') { - return this.userClient.updateWithRefreshToken({ ...options, id: actualId }); + return this.userClient.updateWithRefreshToken(options); } - return this.sharedClient.updateWithRefreshToken({ ...options, id: actualId }); + return this.sharedClient.updateWithRefreshToken(options); } } diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts index fa27c56abbc21..0116af4cf9ccc 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts @@ -162,7 +162,7 @@ export class SharedConnectorTokenClient { return { ...result.attributes, - id: this.formatTokenId(actualId), + id, } as ConnectorToken; } catch (err) { this.logger.error( @@ -457,7 +457,7 @@ export class SharedConnectorTokenClient { return { ...result.attributes, - id: this.formatTokenId(actualId), + id, } as ConnectorToken; } catch (err) { this.logger.error( diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts index 6da980bbc07b7..838182df5f4b4 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts @@ -245,7 +245,7 @@ export class UserConnectorTokenClient { MAX_RETRY_ATTEMPTS ); - return { ...result.attributes, id: this.formatTokenId(actualId) } as UserConnectorToken; + return { ...result.attributes, id } as UserConnectorToken; } catch (err) { this.logger.error( `Failed to update user_connector_token for id "${id}" with ${context}. Error: ${err.message}` @@ -660,7 +660,7 @@ export class UserConnectorTokenClient { MAX_RETRY_ATTEMPTS ); - return { ...result.attributes, id: this.formatTokenId(actualId) } as UserConnectorToken; + return { ...result.attributes, id } as UserConnectorToken; } catch (err) { this.logger.error( `Failed to update user_connector_token with refresh token for id "${id}" and ${context}. Error: ${err.message}` From 92dc41fd2252c604b2da6548958e285c9662d09e Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:56:32 +0100 Subject: [PATCH 12/16] copilot review --- .../server/lib/connector_token_client.ts | 2 +- .../lib/shared_connector_token_client.ts | 2 +- .../lib/user_connector_token_client.test.ts | 22 +++++++++++++++++++ .../server/lib/user_connector_token_client.ts | 14 +++++++----- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index d09bd6ad38737..bc800508176f4 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -180,7 +180,7 @@ export class ConnectorTokenClient { connectorId: string; tokenType?: string; credentialType?: string; - }): Promise { + }): Promise { const scope = options.profileUid ? 'per-user' : 'shared'; this.log({ method: 'deleteConnectorTokens', diff --git a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts index 0116af4cf9ccc..c3f827699731e 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/shared_connector_token_client.ts @@ -143,7 +143,7 @@ export class SharedConnectorTokenClient { }, omitBy( { - id, + id: actualId, overwrite: true, references, version, diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts index 9c4c8b2ec30ca..9cbea9edc54af 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.test.ts @@ -439,6 +439,17 @@ describe('UserConnectorTokenClient', () => { 'token-id-1' ); }); + + test('throws error when given shared: prefix', async () => { + await expect( + userClient.update({ + id: 'shared:token-id-1', + token: 'newtoken', + }) + ).rejects.toThrow( + 'UserConnectorTokenClient cannot handle shared-scope tokens. Use SharedConnectorTokenClient or ConnectorTokenClient instead.' + ); + }); }); describe('updateWithRefreshToken()', () => { @@ -495,6 +506,17 @@ describe('UserConnectorTokenClient', () => { }, }); }); + + test('throws error when given shared: prefix', async () => { + await expect( + userClient.updateWithRefreshToken({ + id: 'shared:token-id-1', + token: 'newtoken', + }) + ).rejects.toThrow( + 'UserConnectorTokenClient cannot handle shared-scope tokens. Use SharedConnectorTokenClient or ConnectorTokenClient instead.' + ); + }); }); describe('deleteConnectorTokens()', () => { diff --git a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts index 838182df5f4b4..806c596bf8b34 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/user_connector_token_client.ts @@ -82,15 +82,17 @@ export class UserConnectorTokenClient { this.logger = logger; } - private parseTokenId(id: string): { scope: 'per-user' | 'shared'; actualId: string } { + private parseTokenId(id: string): string { if (id.startsWith('per-user:')) { - return { scope: 'per-user', actualId: id.substring(9) }; + return id.substring(9); } if (id.startsWith('shared:')) { - return { scope: 'shared', actualId: id.substring(7) }; + throw new Error( + 'UserConnectorTokenClient cannot handle shared-scope tokens. Use SharedConnectorTokenClient or ConnectorTokenClient instead.' + ); } // Default unprefixed IDs to per-user when called on user client - return { scope: 'per-user', actualId: id }; + return id; } private formatTokenId(rawId: string): string { @@ -184,7 +186,7 @@ export class UserConnectorTokenClient { tokenType, credentialType, }: UpdateOptions): Promise { - const { actualId } = this.parseTokenId(id); + const actualId = this.parseTokenId(id); const { attributes, references, version } = await this.unsecuredSavedObjectsClient.get( USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, @@ -585,7 +587,7 @@ export class UserConnectorTokenClient { tokenType?: string; credentialType?: string; }): Promise { - const { actualId } = this.parseTokenId(id); + const actualId = this.parseTokenId(id); const { attributes, references, version } = await this.unsecuredSavedObjectsClient.get( USER_CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, From 1c7b72eb9cb43e2970ea5bb8d558f9734eee0991 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:41:54 +0100 Subject: [PATCH 13/16] registered SO update --- .../server-internal/src/object_types/index.ts | 2 +- .../saved_objects/check_registered_types.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts index ce0041b74ed52..478b2fc301fe9 100644 --- a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts +++ b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts @@ -11,4 +11,4 @@ export { registerCoreObjectTypes } from './registration'; // set minimum number of registered saved objects to ensure no object types are removed after 8.8 // declared in internal implementation explicitly to prevent unintended changes. -export const SAVED_OBJECT_TYPES_COUNT = 146 as const; +export const SAVED_OBJECT_TYPES_COUNT = 147 as const; diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 3fe37c955f84f..9a62f8a1a1ea6 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -201,6 +201,7 @@ describe('checking migration metadata changes on all registered SO types', () => "url": "d9c7d0ed307a191111efd9d1b2df787f4dcd64d66000c742dee108daa95e5932", "usage-counter": "c489ccf0d36fa107aa27c6b589ea4deb4834e8365282ba137ab9415d76c5511e", "usage-counters": "cdfa1fa5d0dd16bc612eed3762d801053a6fb606eb387fcad22d257963d54a26", + "user_connector_token": "524b43a59d7bbc5b0430b19332f8f0f69e323dec2e391a87c568bb4fb4b3690c", "visualization": "b7c299233eb6fc88faccdf5924d4cff9fc2f4c3fe8acf5376d7232c05c9a3cfd", "workplace_search_telemetry": "b17dd0963b685cea46246d00b7da598822668434659b7e698313da6c2212febb", } @@ -1285,6 +1286,11 @@ describe('checking migration metadata changes on all registered SO types', () => "usage-counters|mappings: 435214259b7e5a3aea91163c53afc13a223547e4", "usage-counters|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", "================================================================", + "user_connector_token|global: e61d81d8f7d9becf82d58b6fddac0f47d2a1e14f", + "user_connector_token|mappings: 44fe19c04086807ed4f8faffc2be1556b6cdd6bc", + "user_connector_token|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "user_connector_token|10.1.0: 2ed52373b74b81653c4f3cdd126f0dc5abf262b2f3a79a11379cb58f27dcf69b", + "=============================================================================================", "visualization|global: 8a88e742e29159f4351701a9d677a11685c2cac5", "visualization|mappings: 98f1ae04a5d2af527a40e93292dd90c0080b44be", "visualization|schemas: 8d6477e08dfdf20335752a69994646f9da90741f", @@ -1469,6 +1475,7 @@ describe('checking migration metadata changes on all registered SO types', () => "url": "10.0.0", "usage-counter": "10.0.0", "usage-counters": "10.0.0", + "user_connector_token": "10.1.0", "visualization": "10.0.0", "workplace_search_telemetry": "10.0.0", } @@ -1625,6 +1632,7 @@ describe('checking migration metadata changes on all registered SO types', () => "url": "0.0.0", "usage-counter": "0.0.0", "usage-counters": "0.0.0", + "user_connector_token": "10.1.0", "visualization": "8.5.0", "workplace_search_telemetry": "0.0.0", } From 6752aa7fb6821da703618cd1a361e24a529943af Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:46:13 +0100 Subject: [PATCH 14/16] SO config changes --- .../ci_checks/check_registered_types.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts index e34c922fd944c..fbd1f4cdcd58f 100644 --- a/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts +++ b/x-pack/platform/plugins/shared/encrypted_saved_objects/integration_tests/ci_checks/check_registered_types.test.ts @@ -20,7 +20,7 @@ import type { EncryptedSavedObjectsService } from '../../server/crypto'; import * as EncryptedSavedObjectsModule from '../../server/saved_objects'; // This will only change if new ESOs are introduced. This number should never get smaller. -export const ESO_TYPES_COUNT = 20 as const; +export const ESO_TYPES_COUNT = 21 as const; describe('checking changes on all registered encrypted SO types', () => { let esServer: TestElasticsearchUtils; @@ -77,13 +77,14 @@ describe('checking changes on all registered encrypted SO types', () => { "fleet-uninstall-tokens": "6e7d75921dcce46e566f175eab1b0e3825fe565f20cdb3c984e7037934d61e23", "ingest-download-sources": "23eb3cf789fe13b4899215c6f919705b8a44b89f8feba7181e1f5db3c7699d40", "ingest-outputs": "d66716d5333484a25c57f7917bead5ac2576ec57a4b9eb61701b573f35ab62ad", - "oauth_state": "a7f039d0d00612a4228213d3983c86245c09ef7683efed147ea0b3511cfa2f66", + "oauth_state": "192a6c868fd863d8d14c33cc3357a3c61de3acca48b4a8ccb3a5e29ba465f4d7", "privmon-api-key": "7d7b76b3bc5287a784518731ba66d4f761052177fc04b1a85e5605846ab9de42", "synthetics-monitor": "f1c060b7be3b30187c4adcb35d74f1fa8a4290bd7faf04fec869de2aa387e21b", "synthetics-monitor-multi-space": "39c4c6abd28c4173f77c1c89306e92b6b92492c0029274e10620a170be4d4a67", "synthetics-param": "747ba9d1b7addf5b131713abe7868bd767af6ce0cf8b6b0f335f4ef34b280c7e", "task": "2d8e9bf532f469805b82051f545b915785d99eabfa050cb1aefbc715c6096b97", "uptime-synthetics-api-key": "5ca81f180763e85397fa8c6508adcd60efd0f916e29bac6dcd5b4564f1db7375", + "user_connector_token": "b443b022b46b79c0ff9fa674aecc64176a5fcbd09c2db2d9f050a6a88435732e", } `); expect(Object.keys(hashMap).length).toEqual(ESO_TYPES_COUNT); @@ -110,6 +111,7 @@ describe('checking changes on all registered encrypted SO types', () => { expect(modelVersionMap).toMatchInlineSnapshot(` Array [ + "action|2", "action|1", "action_task_params|2", "action_task_params|1", @@ -150,6 +152,7 @@ describe('checking changes on all registered encrypted SO types', () => { "task|3", "task|2", "task|1", + "user_connector_token|1", ] `); }); From 6483ef24e933f8ed6b37402f4fe29e6d1088271b Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:45:46 +0100 Subject: [PATCH 15/16] Apply suggestions from code review Co-authored-by: Christos Nasikas --- x-pack/platform/plugins/shared/actions/server/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index a6e1b9dad59cc..e4994b3251dd9 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -308,12 +308,12 @@ export interface ConnectorToken extends SavedObjectAttributes { refreshTokenExpiresAt?: string; } -export interface UserConnectorToken extends SavedObjectAttributes { +export interface UserConnectorToken { id?: string; profileUid: string; connectorId: string; credentialType: string; - credentials: SavedObjectAttributes; + credentials: Record; expiresAt?: string; refreshTokenExpiresAt?: string; createdAt: string; From 7cd83dba345c515d859efacafc361251ca501745 Mon Sep 17 00:00:00 2001 From: Julian Gernun <17549662+jcger@users.noreply.github.com> Date: Tue, 17 Feb 2026 12:50:08 +0100 Subject: [PATCH 16/16] refactor --- .../server/lib/connector_token_client.ts | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index bc800508176f4..2832e51c7bd21 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -13,6 +13,12 @@ import { UserConnectorTokenClient } from './user_connector_token_client'; export const MAX_TOKENS_RETURNED = 1; +const PER_USER_TOKEN_PREFIX = 'per-user:'; +const SHARED_TOKEN_PREFIX = 'shared:'; + +const PER_USER_TOKEN_SCOPE = 'per-user' as const; +const SHARED_TOKEN_SCOPE = 'shared' as const; + interface ConstructorOptions { encryptedSavedObjectsClient: EncryptedSavedObjectsClient; unsecuredSavedObjectsClient: SavedObjectsClientContract; @@ -59,15 +65,22 @@ export class ConnectorTokenClient { this.userClient = new UserConnectorTokenClient(options); } - private parseTokenId(id: string): { scope: 'per-user' | 'shared'; actualId: string } { - if (id.startsWith('per-user:')) { - return { scope: 'per-user', actualId: id.substring(9) }; + private getScope(profileUid?: string): typeof PER_USER_TOKEN_SCOPE | typeof SHARED_TOKEN_SCOPE { + return profileUid ? PER_USER_TOKEN_SCOPE : SHARED_TOKEN_SCOPE; + } + + private parseTokenId(id: string): { + scope: typeof PER_USER_TOKEN_SCOPE | typeof SHARED_TOKEN_SCOPE; + actualId: string; + } { + if (id.startsWith(PER_USER_TOKEN_PREFIX)) { + return { scope: PER_USER_TOKEN_SCOPE, actualId: id.substring(PER_USER_TOKEN_PREFIX.length) }; } - if (id.startsWith('shared:')) { - return { scope: 'shared', actualId: id.substring(7) }; + if (id.startsWith(SHARED_TOKEN_PREFIX)) { + return { scope: SHARED_TOKEN_SCOPE, actualId: id.substring(SHARED_TOKEN_PREFIX.length) }; } // Default unprefixed IDs to shared for backward compatibility - return { scope: 'shared', actualId: id }; + return { scope: SHARED_TOKEN_SCOPE, actualId: id }; } private log({ @@ -76,7 +89,7 @@ export class ConnectorTokenClient { fields, }: { method: string; - scope: 'per-user' | 'shared'; + scope: typeof PER_USER_TOKEN_SCOPE | typeof SHARED_TOKEN_SCOPE; fields: Record; }): void { const parts = Object.entries(fields).map(([key, value]) => @@ -104,9 +117,9 @@ export class ConnectorTokenClient { credentialType?: string; }): Promise; public async create(options: CreateOptions): Promise { - const scope = options.profileUid ? 'per-user' : 'shared'; + const scope = this.getScope(options.profileUid); this.log({ method: 'create', scope, fields: { connectorId: options.connectorId } }); - if (options.profileUid) { + if (scope === PER_USER_TOKEN_SCOPE) { return this.userClient.create(options as Parameters[0]); } return this.sharedClient.create(options as Parameters[0]); @@ -118,7 +131,7 @@ export class ConnectorTokenClient { public async update(options: UpdateOptions): Promise { const { scope, actualId } = this.parseTokenId(options.id); this.log({ method: 'update', scope, fields: { id: actualId } }); - if (scope === 'per-user') { + if (scope === PER_USER_TOKEN_SCOPE) { return this.userClient.update(options); } if (options.token) { @@ -164,9 +177,9 @@ export class ConnectorTokenClient { hasErrors: boolean; connectorToken: ConnectorToken | UserConnectorToken | null; }> { - const scope = options.profileUid ? 'per-user' : 'shared'; + const scope = this.getScope(options.profileUid); this.log({ method: 'get', scope, fields: { connectorId: options.connectorId } }); - if (options.profileUid) { + if (scope === PER_USER_TOKEN_SCOPE) { return this.userClient.get(options as Parameters[0]); } return this.sharedClient.get(options as Parameters[0]); @@ -181,13 +194,13 @@ export class ConnectorTokenClient { tokenType?: string; credentialType?: string; }): Promise { - const scope = options.profileUid ? 'per-user' : 'shared'; + const scope = this.getScope(options.profileUid); this.log({ method: 'deleteConnectorTokens', scope, fields: { connectorId: options.connectorId }, }); - if (options.profileUid) { + if (scope === PER_USER_TOKEN_SCOPE) { return this.userClient.deleteConnectorTokens( options as Parameters[0] ); @@ -198,9 +211,9 @@ export class ConnectorTokenClient { } public async updateOrReplace(options: UpdateOrReplaceOptions) { - const scope = options.profileUid ? 'per-user' : 'shared'; + const scope = this.getScope(options.profileUid); this.log({ method: 'updateOrReplace', scope, fields: { connectorId: options.connectorId } }); - if (options.profileUid) { + if (scope === PER_USER_TOKEN_SCOPE) { return this.userClient.updateOrReplace( options as Parameters[0] ); @@ -223,13 +236,13 @@ export class ConnectorTokenClient { tokenType?: string; credentialType?: string; }): Promise { - const scope = options.profileUid ? 'per-user' : 'shared'; + const scope = this.getScope(options.profileUid); this.log({ method: 'createWithRefreshToken', scope, fields: { connectorId: options.connectorId }, }); - if (options.profileUid) { + if (scope === PER_USER_TOKEN_SCOPE) { return this.userClient.createWithRefreshToken( options as Parameters[0] ); @@ -253,7 +266,7 @@ export class ConnectorTokenClient { }): Promise { const { scope, actualId } = this.parseTokenId(options.id); this.log({ method: 'updateWithRefreshToken', scope, fields: { id: actualId } }); - if (scope === 'per-user') { + if (scope === PER_USER_TOKEN_SCOPE) { return this.userClient.updateWithRefreshToken(options); } return this.sharedClient.updateWithRefreshToken(options);