diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dfc7074919c3a..1589e94182913 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2402,6 +2402,12 @@ x-pack/platform/plugins/shared/inference_endpoint @elastic/search-kibana /x-pack/platform/test/fixtures/es_archives/alerting/8_2_0 @elastic/response-ops /x-pack/solutions/**/test/serverless/**/test_suites/rules/ @elastic/response-ops +# EARS +/x-pack/platform/plugins/shared/actions/server/lib/ears @elastic/workchat-eng +x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.ts @elastic/workchat-eng @elastic/response-ops +x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.test.ts @elastic/workchat-eng @elastic/response-ops +/src/platform/packages/shared/kbn-connector-specs/src/auth_types/ears.ts @elastic/workchat-eng + # Connector Specs src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts diff --git a/src/platform/packages/shared/kbn-connector-specs/index.ts b/src/platform/packages/shared/kbn-connector-specs/index.ts index 6b1dbc4f6f1a9..3285280c4ed98 100644 --- a/src/platform/packages/shared/kbn-connector-specs/index.ts +++ b/src/platform/packages/shared/kbn-connector-specs/index.ts @@ -11,6 +11,7 @@ export * as connectorsSpecs from './src/all_specs'; export type * from './src/connector_spec'; export * as authTypeSpecs from './src/all_auth_types'; +export { EARS_PROVIDERS } from './src/auth_types/ears'; export { getConnectorSpec } from './src/get_connector_spec'; export { getWorkflowTemplatesForConnector } from './src/get_workflow_templates'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/all_auth_types.ts b/src/platform/packages/shared/kbn-connector-specs/src/all_auth_types.ts index 73ecefd3accb4..151fbc5f8d4b7 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/all_auth_types.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/all_auth_types.ts @@ -14,6 +14,7 @@ export * from './auth_types/basic'; export * from './auth_types/none'; export * from './auth_types/oauth'; export * from './auth_types/oauth_authorization_code'; +export { Ears } from './auth_types/ears'; // Skipping PFX and CRT exports for now as they will require updates to // the formbuilder to support file upload fields. diff --git a/src/platform/packages/shared/kbn-connector-specs/src/auth_types/ears.ts b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/ears.ts new file mode 100644 index 0000000000000..02d5b49601b88 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/ears.ts @@ -0,0 +1,74 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import type { AxiosInstance } from 'axios'; +import type { AuthContext, AuthTypeSpec } from '../connector_spec'; +import * as i18n from './translations'; + +export const EARS_PROVIDERS = ['google', 'microsoft', 'slack'] as const; + +const authSchema = z + .object({ + provider: z.enum(EARS_PROVIDERS).meta({ hidden: true }), + scope: z.string().meta({ label: i18n.OAUTH_SCOPE_LABEL }).optional(), + }) + .meta({ label: i18n.EARS_LABEL }); + +type AuthSchemaType = z.infer; + +/** + * EARS (Elastic Authentication Redirect Service) OAuth Flow + * + * EARS is an OAuth proxy that manages client credentials (clientId/clientSecret) + * on behalf of the user. Instead of users creating their own OAuth apps for each + * 3rd party, they can rely on the Elastic-owned apps for simplicity. + * Therefore, connectors using EARS don't require users to input any + * client credentials — EARS already knows them. + * + * EARS Redirect Flow: + * 1. On `/_start_oauth_flow`, Kibana builds EARS authorize URL with callback_uri, state, scope, pkce_challenge, pkce_method, and redirects to it + * 2. User visits EARS authorize URL → EARS redirects to OAuth provider and shows auth screen to user, in order for them to enter their credentials and authorize scopes + * 3. OAuth provider redirects back to EARS with authz code & state + * 4. EARS redirects to callback_uri (Kibana's `/_oauth_callback`) with authz code & state + * 5. Kibana then exchanges code via EARS token endpoint: POST {earsTokenUrl} with code & pkce_verifier in the JSON body + * 6. Tokens are auto-refreshed when expired during connector execution + */ +export const Ears: AuthTypeSpec = { + id: 'ears', + schema: authSchema, + authMode: 'per-user', + configure: async ( + ctx: AuthContext, + axiosInstance: AxiosInstance, + secret: AuthSchemaType + ): Promise => { + let token; + try { + token = await ctx.getToken({ + authType: 'ears', + provider: secret.provider, + scope: secret.scope, + }); + } catch (error) { + throw new Error( + `Unable to retrieve/refresh the access token. User may need to re-authorize: ${error.message}` + ); + } + + if (!token) { + throw new Error(`No access token available. User must complete OAuth authorization flow.`); + } + + // set global defaults + axiosInstance.defaults.headers.common.Authorization = token; + + return axiosInstance; + }, +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth.ts b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth.ts index ca6d3dbad483f..e07dabb618798 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth.ts @@ -47,6 +47,7 @@ export const OAuth: AuthTypeSpec = { let token; try { token = await ctx.getToken({ + authType: 'oauth', tokenUrl: secret.tokenUrl, scope: secret.scope, clientId: secret.clientId, diff --git a/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth_authorization_code.ts b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth_authorization_code.ts index dc326ad1b8463..c669e924a0e30 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth_authorization_code.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth_authorization_code.ts @@ -94,6 +94,7 @@ export const OAuthAuthorizationCode: AuthTypeSpec = { let token; try { token = await ctx.getToken({ + authType: 'oauth', tokenUrl: secret.tokenUrl, scope: secret.scope, clientId: secret.clientId, diff --git a/src/platform/packages/shared/kbn-connector-specs/src/auth_types/translations.ts b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/translations.ts index 6d78d59f1ae6e..14629fdd74551 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/auth_types/translations.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/translations.ts @@ -219,3 +219,7 @@ export const AWS_SECRET_ACCESS_KEY_REQUIRED_MESSAGE = i18n.translate( defaultMessage: 'Secret Access Key is required', } ); + +export const EARS_LABEL = i18n.translate('connectorSpecs.ears.label', { + defaultMessage: 'OAuth 2.0 via Elastic-owned apps', +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts b/src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts index 16fa3ec92ac98..7e5f65d1fd4f3 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts @@ -78,7 +78,8 @@ export interface ConnectorMetadata { // OAuth2, SSL/mTLS, AWS SigV4 → Phase 2 (see connector_rfc.ts) // Auth schemas defined in ./auth_types -export interface GetTokenOpts { +export interface OAuthGetTokenOpts { + authType: 'oauth'; tokenUrl: string; scope?: string; clientId: string; @@ -87,6 +88,14 @@ export interface GetTokenOpts { tokenEndpointAuthMethod?: 'client_secret_post' | 'client_secret_basic'; } +export interface EarsGetTokenOpts { + authType: 'ears'; + provider: string; + scope?: string; +} + +export type GetTokenOpts = OAuthGetTokenOpts | EarsGetTokenOpts; + export interface AuthContext { getCustomHostSettings: (url: string) => CustomHostSettings | undefined; getToken: (opts: GetTokenOpts) => Promise; diff --git a/x-pack/platform/plugins/shared/actions/server/actions_config.mock.ts b/x-pack/platform/plugins/shared/actions/server/actions_config.mock.ts index 7640f71844653..49f60a10995c5 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_config.mock.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_config.mock.ts @@ -47,6 +47,7 @@ const createActionsConfigMock = () => { getAwsSesConfig: jest.fn().mockReturnValue(null), getEnabledEmailServices: jest.fn().mockReturnValue(['*']), getMaxEmailBodyLength: jest.fn().mockReturnValue(DEFAULT_EMAIL_BODY_LENGTH), + getEarsUrl: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts b/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts index bfcabdb020814..c4367d28bd49a 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_config.test.ts @@ -50,6 +50,7 @@ const defaultActionsConfig: ActionsConfig = { }, }, }, + ears: {}, }; describe('ensureUriAllowed', () => { @@ -770,6 +771,21 @@ describe('getAwsSesConfig()', () => { }); }); +describe('getEarsUrl()', () => { + test('returns undefined when ears.url is not set in config', () => { + const acu = getActionsConfigurationUtilities(defaultActionsConfig); + expect(acu.getEarsUrl()).toBeUndefined(); + }); + + test('returns the configured URL when ears.url is set in config', () => { + const acu = getActionsConfigurationUtilities({ + ...defaultActionsConfig, + ears: { url: 'https://ears.example.com' }, + }); + expect(acu.getEarsUrl()).toBe('https://ears.example.com'); + }); +}); + describe('getEnabledEmailServices()', () => { test('returns all services when no email config set', () => { const acu = getActionsConfigurationUtilities(defaultActionsConfig); diff --git a/x-pack/platform/plugins/shared/actions/server/actions_config.ts b/x-pack/platform/plugins/shared/actions/server/actions_config.ts index f1f5a6a3c70f3..fa0bb4237cbbd 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_config.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_config.ts @@ -75,6 +75,7 @@ export interface ActionsConfigurationUtilities { getAwsSesConfig: () => AwsSesConfig; getEnabledEmailServices: () => string[]; getMaxEmailBodyLength: () => number; + getEarsUrl(): string | undefined; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -283,5 +284,6 @@ export function getActionsConfigurationUtilities( const nonNegativeLength = Math.max(0, configuredLength); return Math.min(nonNegativeLength, MAX_EMAIL_BODY_LENGTH); }, + getEarsUrl: () => config.ears?.url, }; } diff --git a/x-pack/platform/plugins/shared/actions/server/config.ts b/x-pack/platform/plugins/shared/actions/server/config.ts index b53ff12a7cd72..b2c2bf2fbd223 100644 --- a/x-pack/platform/plugins/shared/actions/server/config.ts +++ b/x-pack/platform/plugins/shared/actions/server/config.ts @@ -217,6 +217,11 @@ export const configSchema = schema.object({ rate_limits: oauthAuthorizationCodeRateLimitsSchema, }), }), + ears: schema.maybe( + schema.object({ + url: schema.maybe(schema.uri({ scheme: ['https'] })), + }) + ), }); export type ActionsConfig = TypeOf; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/default_strategy.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/default_strategy.test.ts new file mode 100644 index 0000000000000..23bff2393642d --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/default_strategy.test.ts @@ -0,0 +1,138 @@ +/* + * 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. + */ + +jest.mock('../get_oauth_client_credentials_access_token'); +jest.mock('../delete_token_axios_interceptor'); + +import type { AxiosInstance } from 'axios'; +import type { GetTokenOpts, OAuthGetTokenOpts } from '@kbn/connector-specs'; +import { loggerMock } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from '../connector_token_client.mock'; +import { getOAuthClientCredentialsAccessToken } from '../get_oauth_client_credentials_access_token'; +import { getDeleteTokenAxiosInterceptor } from '../delete_token_axios_interceptor'; +import { DefaultStrategy } from './default_strategy'; +import type { AuthStrategyDeps } from './types'; + +const mockGetOAuthClientCredentialsAccessToken = + getOAuthClientCredentialsAccessToken as jest.MockedFunction< + typeof getOAuthClientCredentialsAccessToken + >; +const mockGetDeleteTokenAxiosInterceptor = getDeleteTokenAxiosInterceptor as jest.MockedFunction< + typeof getDeleteTokenAxiosInterceptor +>; + +const logger = loggerMock.create(); +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +const baseDeps: AuthStrategyDeps = { + connectorId: 'connector-1', + secrets: { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + tokenUrl: 'https://provider.example.com/token', + scope: 'openid', + }, + connectorTokenClient, + logger, + configurationUtilities, +}; + +const createMockAxiosInstance = () => + ({ + interceptors: { response: { use: jest.fn() } }, + } as unknown as AxiosInstance); + +describe('DefaultStrategy', () => { + let strategy: DefaultStrategy; + + const mockOnFulfilled = jest.fn(); + const mockOnRejected = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + strategy = new DefaultStrategy(); + mockGetDeleteTokenAxiosInterceptor.mockReturnValue({ + onFulfilled: mockOnFulfilled, + onRejected: mockOnRejected, + }); + }); + + describe('installResponseInterceptor', () => { + it('installs the delete-token cleanup interceptor', () => { + const instance = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + + expect(mockGetDeleteTokenAxiosInterceptor).toHaveBeenCalledWith({ + connectorTokenClient, + connectorId: 'connector-1', + }); + expect(instance.interceptors.response.use).toHaveBeenCalledWith( + mockOnFulfilled, + mockOnRejected + ); + }); + }); + + describe('getToken', () => { + it('throws when opts authType is not oauth', async () => { + const opts: GetTokenOpts = { authType: 'ears', provider: 'google' }; + await expect(strategy.getToken(opts, baseDeps)).rejects.toThrow( + 'DefaultStrategy received non-oauth token opts' + ); + }); + + it('delegates to getOAuthClientCredentialsAccessToken with correct args', async () => { + mockGetOAuthClientCredentialsAccessToken.mockResolvedValue('Bearer clientcred'); + + const opts: OAuthGetTokenOpts = { + authType: 'oauth', + tokenUrl: 'https://provider.example.com/token', + clientId: 'the-client-id', + clientSecret: 'the-client-secret', + scope: 'openid profile', + }; + const result = await strategy.getToken(opts, baseDeps); + + expect(result).toBe('Bearer clientcred'); + expect(mockGetOAuthClientCredentialsAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: 'connector-1', + tokenUrl: 'https://provider.example.com/token', + oAuthScope: 'openid profile', + credentials: { + config: { clientId: 'the-client-id' }, + secrets: { clientSecret: 'the-client-secret' }, + }, + connectorTokenClient, + }) + ); + }); + + it('includes additionalFields when present in opts', async () => { + mockGetOAuthClientCredentialsAccessToken.mockResolvedValue('Bearer token'); + + const opts: OAuthGetTokenOpts = { + authType: 'oauth', + tokenUrl: 'https://provider.example.com/token', + clientId: 'id', + clientSecret: 'secret', + additionalFields: { tenant: 'abc' }, + }; + await strategy.getToken(opts, baseDeps); + + expect(mockGetOAuthClientCredentialsAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: expect.objectContaining({ + config: expect.objectContaining({ additionalFields: { tenant: 'abc' } }), + }), + }) + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/default_strategy.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/default_strategy.ts new file mode 100644 index 0000000000000..890b723d16e7c --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/default_strategy.ts @@ -0,0 +1,51 @@ +/* + * 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 { AxiosInstance } from 'axios'; +import type { GetTokenOpts } from '@kbn/connector-specs'; +import { getOAuthClientCredentialsAccessToken } from '../get_oauth_client_credentials_access_token'; +import { getDeleteTokenAxiosInterceptor } from '../delete_token_axios_interceptor'; +import type { AxiosAuthStrategy, AuthStrategyDeps } from './types'; + +export class DefaultStrategy implements AxiosAuthStrategy { + installResponseInterceptor(axiosInstance: AxiosInstance, deps: AuthStrategyDeps): void { + const { connectorId, connectorTokenClient } = deps; + if (!connectorTokenClient) { + throw new Error('Failed to delete invalid tokens: missing required ConnectorTokenClient.'); + } + const { onFulfilled, onRejected } = getDeleteTokenAxiosInterceptor({ + connectorTokenClient, + connectorId, + }); + axiosInstance.interceptors.response.use(onFulfilled, onRejected); + } + + async getToken(opts: GetTokenOpts, deps: AuthStrategyDeps): Promise { + if (opts.authType !== 'oauth') { + throw new Error('DefaultStrategy received non-oauth token opts'); + } + + const { connectorId, connectorTokenClient, logger, configurationUtilities } = deps; + + return getOAuthClientCredentialsAccessToken({ + connectorId, + logger, + tokenUrl: opts.tokenUrl, + oAuthScope: opts.scope, + configurationUtilities, + credentials: { + config: { + clientId: opts.clientId, + ...(opts.additionalFields ? { additionalFields: opts.additionalFields } : {}), + }, + secrets: { clientSecret: opts.clientSecret }, + }, + connectorTokenClient, + tokenEndpointAuthMethod: opts.tokenEndpointAuthMethod, + }); + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.test.ts new file mode 100644 index 0000000000000..a8a22049d7e8f --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.test.ts @@ -0,0 +1,214 @@ +/* + * 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. + */ + +jest.mock('../ears/get_ears_access_token'); +jest.mock('../ears/url'); + +import type { AxiosInstance } from 'axios'; +import type { EarsGetTokenOpts, GetTokenOpts } from '@kbn/connector-specs'; +import { loggerMock } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from '../connector_token_client.mock'; +import { getEarsAccessToken } from '../ears/get_ears_access_token'; +import { resolveEarsUrl } from '../ears/url'; +import { EarsStrategy } from './ears_strategy'; +import type { AuthStrategyDeps } from './types'; + +const mockGetEarsAccessToken = getEarsAccessToken as jest.MockedFunction; +const mockResolveEarsUrl = resolveEarsUrl as jest.MockedFunction; + +const logger = loggerMock.create(); +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +const baseDeps: AuthStrategyDeps = { + connectorId: 'connector-1', + secrets: { provider: 'my-provider' }, + connectorTokenClient, + logger, + configurationUtilities, +}; + +const createMockAxiosInstance = () => { + const mockRequest = jest.fn(); + const instance = { + interceptors: { response: { use: jest.fn() } }, + request: mockRequest, + defaults: { headers: { common: {} as Record } }, + } as unknown as AxiosInstance; + return { instance, mockRequest }; +}; + +const getOnRejected = (instance: AxiosInstance) => { + const useMock = instance.interceptors.response.use as jest.Mock; + expect(useMock).toHaveBeenCalledTimes(1); + return useMock.mock.calls[0][1] as (error: unknown) => Promise; +}; + +describe('EarsStrategy', () => { + let strategy: EarsStrategy; + + beforeEach(() => { + jest.clearAllMocks(); + strategy = new EarsStrategy(); + mockResolveEarsUrl.mockImplementation((url) => `https://ears.example.com${url}`); + }); + + describe('installResponseInterceptor', () => { + it('throws synchronously when connectorTokenClient is absent', () => { + const { instance } = createMockAxiosInstance(); + const { connectorTokenClient: _ctc, ...depsWithoutClient } = baseDeps; + expect(() => strategy.installResponseInterceptor(instance, depsWithoutClient)).toThrow( + 'ConnectorTokenClient is required for EARS authorization code flow' + ); + }); + + it('passes non-401 errors through unchanged', async () => { + const { instance } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + const error = { + config: { _retry: false, headers: {} }, + response: { status: 500 }, + message: 'Server Error', + }; + await expect(onRejected(error)).rejects.toBe(error); + expect(mockGetEarsAccessToken).not.toHaveBeenCalled(); + }); + + it('does not retry when _retry is already set', async () => { + const { instance } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + const error = { + config: { _retry: true, headers: {} }, + response: { status: 401 }, + message: 'Unauthorized', + }; + await expect(onRejected(error)).rejects.toBe(error); + expect(mockGetEarsAccessToken).not.toHaveBeenCalled(); + }); + + it('rejects with message when provider is absent', async () => { + const { instance } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, { ...baseDeps, secrets: {} }); + const onRejected = getOnRejected(instance); + + const error = { + config: { _retry: false, headers: {} }, + response: { status: 401 }, + message: '', + }; + await expect(onRejected(error)).rejects.toBe(error); + expect((error as { message: string }).message).toContain( + 'Authentication failed: Missing required EARS provider.' + ); + expect(mockGetEarsAccessToken).not.toHaveBeenCalled(); + }); + + it('refreshes token on 401 and retries the request', async () => { + const { instance, mockRequest } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + mockGetEarsAccessToken.mockResolvedValue('Bearer newtoken'); + mockRequest.mockResolvedValue({ status: 200 }); + + const error = { + config: { _retry: false, headers: {} as Record }, + response: { status: 401 }, + message: 'Unauthorized', + }; + await onRejected(error); + + expect(mockGetEarsAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true, connectorId: 'connector-1' }) + ); + expect(error.config.headers.Authorization).toBe('Bearer newtoken'); + expect(mockRequest).toHaveBeenCalledWith(error.config); + }); + + it('rejects with message when token refresh returns null', async () => { + const { instance } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + mockGetEarsAccessToken.mockResolvedValue(null); + + const error = { + config: { _retry: false, headers: {} }, + response: { status: 401 }, + message: '', + }; + await expect(onRejected(error)).rejects.toBe(error); + expect((error as { message: string }).message).toContain( + 'Unable to refresh access token via EARS' + ); + }); + + it('calls getEarsAccessToken with the provider from secrets', async () => { + const { instance, mockRequest } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, { + ...baseDeps, + secrets: { provider: 'my-provider' }, + }); + const onRejected = getOnRejected(instance); + + mockGetEarsAccessToken.mockResolvedValue('Bearer token'); + mockRequest.mockResolvedValue({ status: 200 }); + + const error = { + config: { _retry: false, headers: {} as Record }, + response: { status: 401 }, + message: '', + }; + await onRejected(error); + + expect(mockGetEarsAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'my-provider', forceRefresh: true }) + ); + }); + }); + + describe('getToken', () => { + it('throws when opts authType is not ears', async () => { + const opts: GetTokenOpts = { + authType: 'oauth', + tokenUrl: 'https://example.com/token', + clientId: 'id', + clientSecret: 'secret', + }; + await expect(strategy.getToken(opts, baseDeps)).rejects.toThrow( + 'EarsStrategy received non-ears token opts' + ); + }); + + it('throws when connectorTokenClient is absent', async () => { + const { connectorTokenClient: _ctc, ...depsWithoutClient } = baseDeps; + await expect( + strategy.getToken({ authType: 'ears', provider: 'my-provider' }, depsWithoutClient) + ).rejects.toThrow('ConnectorTokenClient is required for EARS OAuth flow'); + }); + + it('delegates to getEarsAccessToken with provider', async () => { + mockGetEarsAccessToken.mockResolvedValue('Bearer token'); + + const opts: EarsGetTokenOpts = { authType: 'ears', provider: 'my-provider' }; + await strategy.getToken(opts, baseDeps); + + expect(mockGetEarsAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: 'connector-1', + provider: 'my-provider', + connectorTokenClient, + }) + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.ts new file mode 100644 index 0000000000000..b1a72d1508735 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/ears_strategy.ts @@ -0,0 +1,111 @@ +/* + * 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 { AxiosInstance } from 'axios'; +import type { GetTokenOpts } from '@kbn/connector-specs'; +import type { AxiosErrorWithRetry } from '../axios_utils'; +import { getEarsAccessToken } from '../ears'; +import type { AxiosAuthStrategy, AuthStrategyDeps } from './types'; + +interface EarsSecrets { + provider?: string; +} + +export class EarsStrategy implements AxiosAuthStrategy { + installResponseInterceptor(axiosInstance: AxiosInstance, deps: AuthStrategyDeps): void { + const { + connectorId, + secrets, + connectorTokenClient, + logger, + configurationUtilities, + authMode, + profileUid, + } = deps; + + if (!connectorTokenClient) { + throw new Error('ConnectorTokenClient is required for EARS authorization code flow'); + } + + axiosInstance.interceptors.response.use( + (response) => response, + async (error: AxiosErrorWithRetry) => { + if (error.response?.status !== 401) { + return Promise.reject(error); + } + + if (error.config._retry) { + return Promise.reject(error); + } + error.config._retry = true; + + logger.debug( + `Attempting EARS token refresh for connectorId ${connectorId} after 401 error` + ); + + const { provider } = secrets as EarsSecrets; + if (!provider) { + error.message = 'Authentication failed: Missing required EARS provider.'; + return Promise.reject(error); + } + + const newAccessToken = await getEarsAccessToken({ + connectorId, + logger, + configurationUtilities, + provider, + connectorTokenClient, + authMode, + profileUid, + forceRefresh: true, + }); + + if (!newAccessToken) { + error.message = + 'Authentication failed: Unable to refresh access token via EARS. Please re-authorize the connector.'; + return Promise.reject(error); + } + + logger.debug( + `EARS token refreshed successfully for connectorId ${connectorId}. Retrying request.` + ); + error.config.headers.Authorization = newAccessToken; + axiosInstance.defaults.headers.common.Authorization = newAccessToken; + return axiosInstance.request(error.config); + } + ); + } + + async getToken(opts: GetTokenOpts, deps: AuthStrategyDeps): Promise { + if (opts.authType !== 'ears') { + throw new Error('EarsStrategy received non-ears token opts'); + } + + const { + connectorId, + connectorTokenClient, + logger, + configurationUtilities, + authMode, + profileUid, + } = deps; + if (!connectorTokenClient) { + throw new Error('ConnectorTokenClient is required for EARS OAuth flow'); + } + + const { provider } = opts; + return getEarsAccessToken({ + connectorId, + logger, + configurationUtilities, + provider, + connectorTokenClient, + authMode, + profileUid, + }); + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/get_axios_auth_strategy.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/get_axios_auth_strategy.test.ts new file mode 100644 index 0000000000000..4ed2e2bbf3608 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/get_axios_auth_strategy.test.ts @@ -0,0 +1,33 @@ +/* + * 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 { getAxiosAuthStrategy } from './get_axios_auth_strategy'; +import { EarsStrategy } from './ears_strategy'; +import { OAuthAuthCodeStrategy } from './oauth_auth_code_strategy'; +import { DefaultStrategy } from './default_strategy'; + +describe('getAxiosAuthStrategy', () => { + it('returns EarsStrategy for "ears"', () => { + expect(getAxiosAuthStrategy('ears')).toBeInstanceOf(EarsStrategy); + }); + + it('returns OAuthAuthCodeStrategy for "oauth_authorization_code"', () => { + expect(getAxiosAuthStrategy('oauth_authorization_code')).toBeInstanceOf(OAuthAuthCodeStrategy); + }); + + it('returns DefaultStrategy for "oauth_client_credentials"', () => { + expect(getAxiosAuthStrategy('oauth_client_credentials')).toBeInstanceOf(DefaultStrategy); + }); + + it('returns DefaultStrategy for "none"', () => { + expect(getAxiosAuthStrategy('none')).toBeInstanceOf(DefaultStrategy); + }); + + it('returns DefaultStrategy for unknown auth types', () => { + expect(getAxiosAuthStrategy('some_future_type')).toBeInstanceOf(DefaultStrategy); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/get_axios_auth_strategy.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/get_axios_auth_strategy.ts new file mode 100644 index 0000000000000..afbfaf389ce3a --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/get_axios_auth_strategy.ts @@ -0,0 +1,27 @@ +/* + * 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 { AxiosAuthStrategy } from './types'; +import { EarsStrategy } from './ears_strategy'; +import { OAuthAuthCodeStrategy } from './oauth_auth_code_strategy'; +import { DefaultStrategy } from './default_strategy'; + +/** + * Returns the AxiosAuthStrategy for the given auth type. + * This is the single place where authTypeId is inspected for strategy selection, + * which includes 401 handling and token request. + */ +export const getAxiosAuthStrategy = (authTypeId: string): AxiosAuthStrategy => { + switch (authTypeId) { + case 'ears': + return new EarsStrategy(); + case 'oauth_authorization_code': + return new OAuthAuthCodeStrategy(); + default: + return new DefaultStrategy(); + } +}; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/index.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/index.ts new file mode 100644 index 0000000000000..301605f2c0edc --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { getAxiosAuthStrategy } from './get_axios_auth_strategy'; +export type { AxiosAuthStrategy, AuthStrategyDeps } from './types'; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/oauth_auth_code_strategy.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/oauth_auth_code_strategy.test.ts new file mode 100644 index 0000000000000..6e0c348e17f6d --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/oauth_auth_code_strategy.test.ts @@ -0,0 +1,258 @@ +/* + * 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. + */ + +jest.mock('../get_oauth_authorization_code_access_token'); + +import type { AxiosInstance } from 'axios'; +import type { GetTokenOpts, OAuthGetTokenOpts } from '@kbn/connector-specs'; +import { loggerMock } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from '../connector_token_client.mock'; +import { getOAuthAuthorizationCodeAccessToken } from '../get_oauth_authorization_code_access_token'; +import { OAuthAuthCodeStrategy } from './oauth_auth_code_strategy'; +import type { AuthStrategyDeps } from './types'; + +const mockGetOAuthAuthorizationCodeAccessToken = + getOAuthAuthorizationCodeAccessToken as jest.MockedFunction< + typeof getOAuthAuthorizationCodeAccessToken + >; + +const logger = loggerMock.create(); +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +const baseDeps: AuthStrategyDeps = { + connectorId: 'connector-1', + secrets: { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + tokenUrl: 'https://provider.example.com/token', + scope: 'openid', + useBasicAuth: true, + }, + connectorTokenClient, + logger, + configurationUtilities, +}; + +const createMockAxiosInstance = () => { + const mockRequest = jest.fn(); + const instance = { + interceptors: { response: { use: jest.fn() } }, + request: mockRequest, + defaults: { headers: { common: {} as Record } }, + } as unknown as AxiosInstance; + return { instance, mockRequest }; +}; + +const getOnRejected = (instance: AxiosInstance) => { + const useMock = instance.interceptors.response.use as jest.Mock; + expect(useMock).toHaveBeenCalledTimes(1); + return useMock.mock.calls[0][1] as (error: unknown) => Promise; +}; + +describe('OAuthAuthCodeStrategy', () => { + let strategy: OAuthAuthCodeStrategy; + + beforeEach(() => { + jest.clearAllMocks(); + strategy = new OAuthAuthCodeStrategy(); + }); + + describe('installResponseInterceptor', () => { + it('throws synchronously when connectorTokenClient is absent', () => { + const { instance } = createMockAxiosInstance(); + const { connectorTokenClient: _ctc, ...depsWithoutClient } = baseDeps; + expect(() => strategy.installResponseInterceptor(instance, depsWithoutClient)).toThrow( + 'ConnectorTokenClient is required for OAuth authorization code flow' + ); + }); + + it('passes non-401 errors through unchanged', async () => { + const { instance } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + const error = { + config: { _retry: false, headers: {} }, + response: { status: 403 }, + message: 'Forbidden', + }; + await expect(onRejected(error)).rejects.toBe(error); + expect(mockGetOAuthAuthorizationCodeAccessToken).not.toHaveBeenCalled(); + }); + + it('does not retry when _retry is already set', async () => { + const { instance } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + const error = { + config: { _retry: true, headers: {} }, + response: { status: 401 }, + message: 'Unauthorized', + }; + await expect(onRejected(error)).rejects.toBe(error); + expect(mockGetOAuthAuthorizationCodeAccessToken).not.toHaveBeenCalled(); + }); + + it('rejects with message when clientId, clientSecret or tokenUrl are absent', async () => { + const { instance } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, { ...baseDeps, secrets: { scope: 'openid' } }); + const onRejected = getOnRejected(instance); + + const error = { + config: { _retry: false, headers: {} }, + response: { status: 401 }, + message: '', + }; + await expect(onRejected(error)).rejects.toBe(error); + expect((error as { message: string }).message).toContain( + 'Missing required OAuth configuration' + ); + expect(mockGetOAuthAuthorizationCodeAccessToken).not.toHaveBeenCalled(); + }); + + it('refreshes token on 401 and retries the request', async () => { + const { instance, mockRequest } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + mockGetOAuthAuthorizationCodeAccessToken.mockResolvedValue('Bearer refreshed'); + mockRequest.mockResolvedValue({ status: 200 }); + + const error = { + config: { _retry: false, headers: {} as Record }, + response: { status: 401 }, + message: 'Unauthorized', + }; + await onRejected(error); + + expect(mockGetOAuthAuthorizationCodeAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ forceRefresh: true, connectorId: 'connector-1' }) + ); + expect(error.config.headers.Authorization).toBe('Bearer refreshed'); + expect(mockRequest).toHaveBeenCalledWith(error.config); + }); + + it('rejects with message when token refresh returns null', async () => { + const { instance } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + mockGetOAuthAuthorizationCodeAccessToken.mockResolvedValue(null); + + const error = { + config: { _retry: false, headers: {} }, + response: { status: 401 }, + message: '', + }; + await expect(onRejected(error)).rejects.toBe(error); + expect((error as { message: string }).message).toContain( + 'Unable to refresh access token. Please re-authorize' + ); + }); + + it('passes credentials to getOAuthAuthorizationCodeAccessToken with correct shape', async () => { + const { instance, mockRequest } = createMockAxiosInstance(); + strategy.installResponseInterceptor(instance, baseDeps); + const onRejected = getOnRejected(instance); + + mockGetOAuthAuthorizationCodeAccessToken.mockResolvedValue('Bearer token'); + mockRequest.mockResolvedValue({ status: 200 }); + + const error = { + config: { _retry: false, headers: {} }, + response: { status: 401 }, + message: '', + }; + await onRejected(error); + + expect(mockGetOAuthAuthorizationCodeAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: { + config: { + clientId: 'my-client-id', + tokenUrl: 'https://provider.example.com/token', + useBasicAuth: true, + }, + secrets: { clientSecret: 'my-client-secret' }, + }, + scope: 'openid', + }) + ); + }); + }); + + describe('getToken', () => { + it('throws when opts authType is not oauth', async () => { + const opts: GetTokenOpts = { authType: 'ears', provider: 'google' }; + await expect(strategy.getToken(opts, baseDeps)).rejects.toThrow( + 'OAuthAuthCodeStrategy received non-oauth token opts' + ); + }); + + it('throws when connectorTokenClient is absent', async () => { + const { connectorTokenClient: _ctc, ...depsWithoutClient } = baseDeps; + const opts: OAuthGetTokenOpts = { + authType: 'oauth', + tokenUrl: 'https://provider.example.com/token', + clientId: 'id', + clientSecret: 'secret', + }; + await expect(strategy.getToken(opts, depsWithoutClient)).rejects.toThrow( + 'ConnectorTokenClient is required for OAuth authorization code flow' + ); + }); + + it('delegates to getOAuthAuthorizationCodeAccessToken with correct credentials', async () => { + mockGetOAuthAuthorizationCodeAccessToken.mockResolvedValue('Bearer token'); + + const opts: OAuthGetTokenOpts = { + authType: 'oauth', + tokenUrl: 'https://provider.example.com/token', + clientId: 'the-client-id', + clientSecret: 'the-client-secret', + scope: 'openid profile', + }; + await strategy.getToken(opts, baseDeps); + + expect(mockGetOAuthAuthorizationCodeAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + connectorId: 'connector-1', + credentials: { + config: { clientId: 'the-client-id', tokenUrl: 'https://provider.example.com/token' }, + secrets: { clientSecret: 'the-client-secret' }, + }, + connectorTokenClient, + scope: 'openid profile', + }) + ); + }); + + it('includes additionalFields when present in opts', async () => { + mockGetOAuthAuthorizationCodeAccessToken.mockResolvedValue('Bearer token'); + + const opts: OAuthGetTokenOpts = { + authType: 'oauth', + tokenUrl: 'https://provider.example.com/token', + clientId: 'id', + clientSecret: 'secret', + additionalFields: { tenant: 'my-tenant' }, + }; + await strategy.getToken(opts, baseDeps); + + expect(mockGetOAuthAuthorizationCodeAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: expect.objectContaining({ + config: expect.objectContaining({ additionalFields: { tenant: 'my-tenant' } }), + }), + }) + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/oauth_auth_code_strategy.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/oauth_auth_code_strategy.ts new file mode 100644 index 0000000000000..3dfb63bdcf7ca --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/oauth_auth_code_strategy.ts @@ -0,0 +1,130 @@ +/* + * 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 { AxiosInstance } from 'axios'; +import type { GetTokenOpts } from '@kbn/connector-specs'; +import type { AxiosErrorWithRetry } from '../axios_utils'; +import { getOAuthAuthorizationCodeAccessToken } from '../get_oauth_authorization_code_access_token'; +import type { AxiosAuthStrategy, AuthStrategyDeps } from './types'; + +interface OAuthAuthCodeSecrets { + clientId?: string; + clientSecret?: string; + tokenUrl?: string; + scope?: string; + useBasicAuth?: boolean; +} + +export class OAuthAuthCodeStrategy implements AxiosAuthStrategy { + installResponseInterceptor(axiosInstance: AxiosInstance, deps: AuthStrategyDeps): void { + const { + connectorId, + secrets, + connectorTokenClient, + logger, + configurationUtilities, + authMode, + profileUid, + } = deps; + + if (!connectorTokenClient) { + throw new Error('ConnectorTokenClient is required for OAuth authorization code flow'); + } + + axiosInstance.interceptors.response.use( + (response) => response, + async (error: AxiosErrorWithRetry) => { + if (error.response?.status !== 401) { + return Promise.reject(error); + } + + if (error.config._retry) { + return Promise.reject(error); + } + error.config._retry = true; + + logger.debug(`Attempting token refresh for connectorId ${connectorId} after 401 error`); + + const { clientId, clientSecret, tokenUrl, scope, useBasicAuth } = + secrets as OAuthAuthCodeSecrets; + + if (!clientId || !clientSecret || !tokenUrl) { + error.message = + 'Authentication failed: Missing required OAuth configuration (clientId, clientSecret, tokenUrl).'; + return Promise.reject(error); + } + + const newAccessToken = await getOAuthAuthorizationCodeAccessToken({ + connectorId, + logger, + configurationUtilities, + credentials: { + config: { clientId, tokenUrl, useBasicAuth }, + secrets: { clientSecret }, + }, + connectorTokenClient, + scope, + authMode, + profileUid, + forceRefresh: true, + }); + + if (!newAccessToken) { + error.message = + 'Authentication failed: Unable to refresh access token. Please re-authorize the connector.'; + return Promise.reject(error); + } + + logger.debug( + `Token refreshed successfully for connectorId ${connectorId}. Retrying request.` + ); + error.config.headers.Authorization = newAccessToken; + axiosInstance.defaults.headers.common.Authorization = newAccessToken; + return axiosInstance.request(error.config); + } + ); + } + + async getToken(opts: GetTokenOpts, deps: AuthStrategyDeps): Promise { + if (opts.authType !== 'oauth') { + throw new Error('OAuthAuthCodeStrategy received non-oauth token opts'); + } + + const { + connectorId, + connectorTokenClient, + logger, + configurationUtilities, + authMode, + profileUid, + } = deps; + if (!connectorTokenClient) { + throw new Error('ConnectorTokenClient is required for OAuth authorization code flow'); + } + + return getOAuthAuthorizationCodeAccessToken({ + connectorId, + logger, + configurationUtilities, + credentials: { + config: { + clientId: opts.clientId, + tokenUrl: opts.tokenUrl, + ...(opts.tokenEndpointAuthMethod !== undefined + ? { useBasicAuth: opts.tokenEndpointAuthMethod !== 'client_secret_post' } + : {}), + ...(opts.additionalFields ? { additionalFields: opts.additionalFields } : {}), + }, + secrets: { clientSecret: opts.clientSecret }, + }, + connectorTokenClient, + scope: opts.scope, + authMode, + profileUid, + }); + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/types.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/types.ts new file mode 100644 index 0000000000000..32098080c6cfc --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_auth_strategies/types.ts @@ -0,0 +1,36 @@ +/* + * 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 { AxiosInstance } from 'axios'; +import type { AuthMode, GetTokenOpts } from '@kbn/connector-specs'; +import type { Logger } from '@kbn/core/server'; +import type { ActionsConfigurationUtilities } from '../../actions_config'; +import type { ConnectorTokenClientContract } from '../../types'; + +export interface AuthStrategyDeps { + connectorId: string; + secrets: Record; + connectorTokenClient?: ConnectorTokenClientContract; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + authMode?: AuthMode; + profileUid?: string; +} + +export interface AxiosAuthStrategy { + /** + * Attach whichever Axios response interceptor is appropriate for this auth type. + * Only called when the connectorTokenClient is present. + */ + installResponseInterceptor(axiosInstance: AxiosInstance, deps: AuthStrategyDeps): void; + + /** + * Return a bearer token string (or null) for the given GetTokenOpts. + * Called by configureCtx.getToken inside authType.configure(). + */ + getToken(opts: GetTokenOpts, deps: AuthStrategyDeps): Promise; +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.ts b/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.ts index 0ceb3e65da4d3..f07a30fa5b67e 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/axios_utils.ts @@ -5,14 +5,15 @@ * 2.0. */ -import { isObjectLike, isEmpty } from 'lodash'; +import { isEmpty, isObjectLike } from 'lodash'; import type { Agent } from 'agent-base'; import type { + AxiosHeaderValue, AxiosInstance, - Method, - AxiosResponse, AxiosRequestConfig, - AxiosHeaderValue, + AxiosResponse, + InternalAxiosRequestConfig, + Method, } from 'axios'; import { AxiosHeaders, isAxiosError } from 'axios'; import type { Logger } from '@kbn/core/server'; @@ -237,3 +238,9 @@ export const createAxiosResponse = (res: Partial): AxiosResponse }, ...res, }); + +export interface AxiosErrorWithRetry { + config: InternalAxiosRequestConfig & { _retry?: boolean }; + response?: { status: number }; + message: string; +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/ears/README.md b/x-pack/platform/plugins/shared/actions/server/lib/ears/README.md new file mode 100644 index 0000000000000..52497b92c1148 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/ears/README.md @@ -0,0 +1,72 @@ +# Elastic Authentication Redirect Service (EARS) + +EARS is an OAuth 2.0 proxy operated by Elastic. It owns the OAuth app credentials (client ID and secret) for each supported provider, so connector users never need to register their own OAuth app. +Kibana talks to EARS instead of talking directly to the OAuth provider. + +Supported providers (defined as `EARS_PROVIDERS` in `@kbn/connector-specs`): +* Google +* Microsoft +* Slack + +For more details on EARS, see the [source code](https://github.com/elastic/elastic-auth-redirect-service) & [internal documentation](https://docs.elastic.dev/search-team/teams/extract-and-transform/ears). + +Key differences from a standard OAuth authorization code grant flow: + +| Standard OAuth | EARS | +|---------------------------------------------------------------------|-------------------------------------------------------| +| Kibana sends `client_id` + `client_secret` to the token endpoint | No client credentials — EARS already knows them | +| Token endpoint accepts form-encoded `grant_type=authorization_code` | Token endpoint accepts JSON `{ code, pkce_verifier }` | +| Refresh endpoint accepts `grant_type=refresh_token` | Refresh endpoint accepts JSON `{ refresh_token }` | +| `redirect_uri` required in token exchange | No `redirect_uri` in token exchange | + +--- + +## Endpoint structure + +All EARS endpoints are derived from the configured `earsBaseUrl` +(`xpack.actions.ears.url` in `kibana.yml`) and the provider name: + +| Purpose | Path | +|----------------|-----------------------------------------------| +| Authorize | `{earsBaseUrl}/v1/{provider}/oauth/authorize` | +| Token exchange | `{earsBaseUrl}/v1/{provider}/oauth/token` | +| Token refresh | `{earsBaseUrl}/v1/{provider}/oauth/refresh` | + +`getEarsEndpointsForProvider(provider)` in `url.ts` builds the path segments; +`resolveEarsUrl(path, earsBaseUrl)` assembles the full URL. + +--- + +## Files + +| File | Purpose | +|---------------------------------|-----------------------------------------------------------------------------------| +| `url.ts` | Utilities for URL construction (`resolveEarsUrl`, `getEarsEndpointsForProvider`) | +| `request_ears_token.ts` | Exchanges an authorization code for tokens (called from `/_oauth_callback`) | +| `request_ears_refresh_token.ts` | Refreshes an expired access token using the stored refresh token | +| `get_ears_access_token.ts` | Entry point for connector execution: reads the stored token, refreshing if needed | +| `index.ts` | Public exports for this module | + +--- + +## Token storage + +Tokens are stored as `user_connector_token` saved objects, keyed by `(connectorId, profileUid, tokenType)`. +This is the `per-user` auth mode, where each Kibana user has their own independent token for each connector. + +During execution, `getEarsAccessToken` will: + +1. Acquire a per-connectorId lock to prevent concurrent refreshes for the same connector. +2. Re-read the token inside the lock (another in-flight request may have refreshed it already). +3. Return the stored token if still valid. +4. Call `requestEarsRefreshToken` if expired, which calls the `refresh` endpoint from EARS, then persists the new token. + +## Configuration + +```yaml +# kibana.yml +xpack.actions.ears.url: https://... +``` + +`configurationUtilities.getEarsUrl()` exposes this value at runtime. +If `ears.url` is not set, `resolveEarsUrl` throws, and the connector will fail with a clear error rather than silently misconfiguring the URL. \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/actions/server/lib/ears/get_ears_access_token.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/ears/get_ears_access_token.test.ts new file mode 100644 index 0000000000000..ac0a30fd9b790 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/ears/get_ears_access_token.test.ts @@ -0,0 +1,409 @@ +/* + * 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 type { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from '../connector_token_client.mock'; +import { getEarsAccessToken } from './get_ears_access_token'; +import { requestEarsRefreshToken } from './request_ears_refresh_token'; + +jest.mock('./request_ears_refresh_token', () => ({ + requestEarsRefreshToken: jest.fn(), +})); + +const NOW = new Date('2024-01-15T12:00:00.000Z'); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +const PROVIDER = 'my-provider'; + +// A valid stored token: access token expires 1h from now, refresh token expires in 7 days +const validToken = { + id: 'token-1', + connectorId: 'connector-1', + tokenType: 'access_token', + token: 'stored-access-token', + createdAt: new Date('2024-01-15T10:00:00.000Z').toISOString(), + expiresAt: new Date('2024-01-15T13:00:00.000Z').toISOString(), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresAt: new Date('2024-01-22T12:00:00.000Z').toISOString(), +}; + +// Same token but with an access token that expired 1h ago +const expiredToken = { + ...validToken, + expiresAt: new Date('2024-01-15T11:00:00.000Z').toISOString(), +}; + +// Per-user token stored under credentials.accessToken / credentials.refreshToken +const validPerUserToken = { + id: 'token-1', + profileUid: 'profile-1', + connectorId: 'connector-1', + credentialType: 'oauth', + credentials: { + accessToken: 'stored-per-user-access-token', + refreshToken: 'stored-per-user-refresh-token', + }, + createdAt: new Date('2024-01-15T10:00:00.000Z').toISOString(), + updatedAt: new Date('2024-01-15T10:00:00.000Z').toISOString(), + expiresAt: new Date('2024-01-15T13:00:00.000Z').toISOString(), + refreshTokenExpiresAt: new Date('2024-01-22T12:00:00.000Z').toISOString(), +}; + +const expiredPerUserToken = { + ...validPerUserToken, + expiresAt: new Date('2024-01-15T11:00:00.000Z').toISOString(), +}; + +const refreshResponse = { + tokenType: 'Bearer', + accessToken: 'new-access-token', + expiresIn: 3600, + refreshToken: 'new-refresh-token', + refreshTokenExpiresIn: 604800, +}; + +const baseOpts = { + connectorId: 'connector-1', + logger, + configurationUtilities, + provider: PROVIDER, + connectorTokenClient, +}; + +let clock: sinon.SinonFakeTimers; + +describe('getEarsAccessToken', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(NOW); + }); + beforeEach(() => { + clock.reset(); + jest.resetAllMocks(); + }); + afterAll(() => clock.restore()); + + describe('stored token retrieval', () => { + it('returns null and warns when the token fetch reports errors', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: true, connectorToken: null }); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Errors fetching connector token for connectorId: connector-1' + ); + }); + + it('returns null and warns when no token is stored (user has not authorized yet)', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: false, connectorToken: null }); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'No access token found for connectorId: connector-1. User must complete OAuth authorization flow.' + ); + }); + + it('returns the stored token without refreshing when it has not expired', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: validToken, + }); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBe('stored-access-token'); + expect(requestEarsRefreshToken).not.toHaveBeenCalled(); + }); + + it('treats a token with no expiresAt as never-expiring', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...validToken, expiresAt: undefined }, + }); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBe('stored-access-token'); + expect(requestEarsRefreshToken).not.toHaveBeenCalled(); + }); + }); + + describe('token refresh', () => { + it('returns null and warns when access token is expired but no refresh token is stored', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...expiredToken, refreshToken: undefined }, + }); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Access token expired and no refresh token available for connectorId: connector-1. User must re-authorize.' + ); + }); + + it('returns null and warns when the refresh token itself is expired', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + ...expiredToken, + refreshTokenExpiresAt: new Date('2024-01-15T11:00:00.000Z').toISOString(), + }, + }); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Refresh token expired for connectorId: connector-1. User must re-authorize.' + ); + }); + + it('returns the refreshed token formatted as "tokenType accessToken"', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestEarsRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBe('Bearer new-access-token'); + }); + + it('passes tokenUrl and refreshToken to requestEarsRefreshToken', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestEarsRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + await getEarsAccessToken(baseOpts); + + expect(requestEarsRefreshToken).toHaveBeenCalledWith( + PROVIDER, + logger, + { refreshToken: 'stored-refresh-token' }, + configurationUtilities + ); + }); + + it('persists the refreshed token and the new refresh token from the response', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestEarsRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + await getEarsAccessToken(baseOpts); + + expect(connectorTokenClient.updateWithRefreshToken).toHaveBeenCalledWith({ + id: 'token-1', + token: 'Bearer new-access-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 604800, + tokenType: 'access_token', + }); + }); + + it('falls back to the existing refresh token when the response omits one', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestEarsRefreshToken as jest.Mock).mockResolvedValueOnce({ + ...refreshResponse, + refreshToken: undefined, + }); + + await getEarsAccessToken(baseOpts); + + expect(connectorTokenClient.updateWithRefreshToken).toHaveBeenCalledWith( + expect.objectContaining({ refreshToken: 'stored-refresh-token' }) + ); + }); + }); + + describe('forceRefresh', () => { + it('bypasses the expiry check and refreshes a still-valid token', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: validToken, + }); + (requestEarsRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + const result = await getEarsAccessToken({ ...baseOpts, forceRefresh: true }); + + expect(result).toBe('Bearer new-access-token'); + expect(requestEarsRefreshToken).toHaveBeenCalledTimes(1); + }); + }); + + describe('error handling', () => { + it('returns null and logs an error when requestEarsRefreshToken throws', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestEarsRefreshToken as jest.Mock).mockRejectedValueOnce( + new Error('EARS endpoint unreachable') + ); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to refresh access token for connectorId: connector-1. Error: EARS endpoint unreachable' + ); + }); + + it('returns null and logs an error when persisting the refreshed token fails', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestEarsRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + connectorTokenClient.updateWithRefreshToken.mockRejectedValueOnce( + new Error('DB write failed') + ); + + const result = await getEarsAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to refresh access token for connectorId: connector-1. Error: DB write failed' + ); + }); + }); + + describe('per-user auth mode', () => { + it('returns null and warns when authMode is per-user but profileUid is missing', async () => { + const result = await getEarsAccessToken({ + ...baseOpts, + authMode: 'per-user', + }); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Per-user authMode requires a profileUid for connectorId: connector-1. Cannot retrieve token.' + ); + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + }); + + it('fetches the token using profileUid when authMode is per-user', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: validPerUserToken, + }); + + await getEarsAccessToken({ + ...baseOpts, + authMode: 'per-user', + profileUid: 'profile-1', + }); + + expect(connectorTokenClient.get).toHaveBeenCalledWith({ + profileUid: 'profile-1', + connectorId: 'connector-1', + tokenType: 'access_token', + }); + }); + + it('returns the stored access token from credentials.accessToken for a valid per-user token', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: validPerUserToken, + }); + + const result = await getEarsAccessToken({ + ...baseOpts, + authMode: 'per-user', + profileUid: 'profile-1', + }); + + expect(result).toBe('stored-per-user-access-token'); + expect(requestEarsRefreshToken).not.toHaveBeenCalled(); + }); + + it('refreshes using credentials.refreshToken for an expired per-user token', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredPerUserToken, + }); + (requestEarsRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + const result = await getEarsAccessToken({ + ...baseOpts, + authMode: 'per-user', + profileUid: 'profile-1', + }); + + expect(requestEarsRefreshToken).toHaveBeenCalledWith( + PROVIDER, + logger, + { refreshToken: 'stored-per-user-refresh-token' }, + configurationUtilities + ); + expect(result).toBe('Bearer new-access-token'); + }); + + it('warns and returns null when the per-user token exists but credentials.accessToken is absent', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...validPerUserToken, credentials: {} }, + }); + + const result = await getEarsAccessToken({ + ...baseOpts, + authMode: 'per-user', + profileUid: 'profile-1', + }); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Stored token has unexpected shape for connectorId: connector-1 (authMode: per-user)' + ) + ); + }); + }); + + describe('concurrency lock', () => { + it('queues concurrent calls for the same connector so only one refresh runs', async () => { + const lockedConnectorId = 'connector-lock-test'; + connectorTokenClient.get + .mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...expiredToken, connectorId: lockedConnectorId }, + }) + .mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...validToken, connectorId: lockedConnectorId }, + }); + (requestEarsRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + const [result1, result2] = await Promise.all([ + getEarsAccessToken({ ...baseOpts, connectorId: lockedConnectorId }), + getEarsAccessToken({ ...baseOpts, connectorId: lockedConnectorId }), + ]); + + expect(requestEarsRefreshToken).toHaveBeenCalledTimes(1); + expect(result1).toBe('Bearer new-access-token'); + expect(result2).toBe('stored-access-token'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/ears/get_ears_access_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/ears/get_ears_access_token.ts new file mode 100644 index 0000000000000..f6cffbb18d7e6 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/ears/get_ears_access_token.ts @@ -0,0 +1,69 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import type { AuthMode } from '@kbn/connector-specs'; +import type { ActionsConfigurationUtilities } from '../../actions_config'; +import type { ConnectorTokenClientContract } from '../../types'; +import { requestEarsRefreshToken } from './request_ears_refresh_token'; +import { getStoredTokenWithRefresh } from '../get_stored_oauth_token_with_refresh'; + +interface GetEarsAccessTokenOpts { + connectorId: string; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + provider: string; + connectorTokenClient: ConnectorTokenClientContract; + authMode?: AuthMode; + profileUid?: string; + /** + * When true, skip the expiration check and force a token refresh. + * Use this when you've received a 401 and know the token is invalid + * even if it hasn't "expired" according to the stored timestamp. + */ + forceRefresh?: boolean; +} + +/** + * Get an access token for EARS OAuth flow from storage. + * Automatically refreshes expired tokens using the EARS refresh endpoint. + * + * Unlike the standard OAuth authorization code flow, EARS does not require + * clientId/clientSecret — the refresh endpoint is derived from tokenUrl + * by replacing `/token` with `/refresh`, and the body is JSON `{ refresh_token }`. + */ +export const getEarsAccessToken = async ({ + connectorId, + logger, + configurationUtilities, + provider, + connectorTokenClient, + authMode, + profileUid, + forceRefresh = false, +}: GetEarsAccessTokenOpts): Promise => { + const isPerUser = authMode === 'per-user'; + + if (isPerUser && !profileUid) { + logger.warn( + `Per-user authMode requires a profileUid for connectorId: ${connectorId}. Cannot retrieve token.` + ); + return null; + } + + return getStoredTokenWithRefresh({ + connectorId, + logger, + connectorTokenClient, + forceRefresh, + isPerUser, + profileUid, + authMode, + refreshFn: (refreshToken) => + requestEarsRefreshToken(provider, logger, { refreshToken }, configurationUtilities), + }); +}; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/ears/index.ts b/x-pack/platform/plugins/shared/actions/server/lib/ears/index.ts new file mode 100644 index 0000000000000..73fd6573d4e6a --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/ears/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 { getEarsEndpointsForProvider, resolveEarsUrl, type EarsEndpoints } from './url'; +export { getEarsAccessToken } from './get_ears_access_token'; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/ears/request_ears_refresh_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/ears/request_ears_refresh_token.ts new file mode 100644 index 0000000000000..3ba3ede0241f7 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/ears/request_ears_refresh_token.ts @@ -0,0 +1,65 @@ +/* + * 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 axios from 'axios'; +import { stableStringify } from '@kbn/std'; +import type { Logger } from '@kbn/core/server'; +import { getEarsEndpointsForProvider, resolveEarsUrl } from './url'; +import { request } from '../axios_utils'; +import type { ActionsConfigurationUtilities } from '../../actions_config'; +import type { OAuthTokenResponse } from '../request_oauth_token'; + +export interface EarsRefreshTokenRequestParams { + refreshToken: string; +} + +/** + * Refresh an access token via the EARS refresh endpoint. + * + * EARS uses a JSON request body with `{ refresh_token }` — no grant_type, + * no client credentials. The refresh endpoint is derived from the token URL + * by replacing `/token` with `/refresh`. + */ +export async function requestEarsRefreshToken( + provider: string, + logger: Logger, + params: EarsRefreshTokenRequestParams, + configurationUtilities: ActionsConfigurationUtilities +): Promise { + const axiosInstance = axios.create(); + const { refreshEndpoint: earsRefreshPath } = getEarsEndpointsForProvider(provider); + const refreshUrl = resolveEarsUrl(earsRefreshPath, configurationUtilities.getEarsUrl()); + + const res = await request({ + axios: axiosInstance, + url: refreshUrl, + method: 'post', + logger, + data: { + refresh_token: params.refreshToken, + }, + headers: { + 'Content-Type': 'application/json', + }, + configurationUtilities, + validateStatus: () => true, + }); + + if (res.status === 200) { + return { + tokenType: res.data.token_type, + accessToken: res.data.access_token, + expiresIn: res.data.expires_in, + refreshToken: res.data.refresh_token, + refreshTokenExpiresIn: res.data.refresh_token_expires_in, + }; + } else { + const errString = stableStringify(res.data); + logger.debug(`error thrown refreshing the access token from EARS ${refreshUrl}: ${errString}`); + throw new Error('Failed to refresh token from auth redirect service'); + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/ears/request_ears_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/ears/request_ears_token.ts new file mode 100644 index 0000000000000..d8c363d3af35a --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/ears/request_ears_token.ts @@ -0,0 +1,66 @@ +/* + * 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 axios from 'axios'; +import { stableStringify } from '@kbn/std'; +import type { Logger } from '@kbn/core/server'; +import { getEarsEndpointsForProvider, resolveEarsUrl } from './url'; +import { request } from '../axios_utils'; +import type { ActionsConfigurationUtilities } from '../../actions_config'; +import type { OAuthTokenResponse } from '../request_oauth_token'; + +export interface EarsTokenRequestParams { + code: string; + pkceVerifier: string; +} + +/** + * Exchange an authorization code for tokens via the EARS token endpoint. + * + * EARS uses a JSON request body with `{ code, pkce_verifier }` — no grant_type, + * no client credentials, and no redirect_uri. + */ +export async function requestEarsToken( + provider: string, + logger: Logger, + params: EarsTokenRequestParams, + configurationUtilities: ActionsConfigurationUtilities +): Promise { + const axiosInstance = axios.create(); + const { tokenEndpoint: earsTokenPath } = getEarsEndpointsForProvider(provider); + const tokenUrl = resolveEarsUrl(earsTokenPath, configurationUtilities.getEarsUrl()); + + const res = await request({ + axios: axiosInstance, + url: tokenUrl, + method: 'post', + logger, + data: { + code: params.code, + pkce_verifier: params.pkceVerifier, + }, + headers: { + 'Content-Type': 'application/json', + }, + configurationUtilities, + validateStatus: () => true, + }); + + if (res.status === 200) { + return { + tokenType: res.data.token_type, + accessToken: res.data.access_token, + expiresIn: res.data.expires_in, + refreshToken: res.data.refresh_token, + refreshTokenExpiresIn: res.data.refresh_token_expires_in, + }; + } else { + const errString = stableStringify(res.data); + logger.debug(`error thrown getting the access token from EARS ${tokenUrl}: ${errString}`); + throw new Error('Failed to request access token from auth redirect service'); + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/ears/url.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/ears/url.test.ts new file mode 100644 index 0000000000000..ece6be21b3bdf --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/ears/url.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { resolveEarsUrl, getEarsEndpointsForProvider } from './url'; + +describe('resolveEarsUrl', () => { + it('combines base URL and path', () => { + expect(resolveEarsUrl('/github/oauth/token', 'https://ears.example.com')).toBe( + 'https://ears.example.com/github/oauth/token' + ); + }); + + it('strips trailing slash from base URL', () => { + expect(resolveEarsUrl('/github/oauth/token', 'https://ears.example.com/')).toBe( + 'https://ears.example.com/github/oauth/token' + ); + }); + + it('prepends leading slash to path when missing', () => { + expect(resolveEarsUrl('github/oauth/token', 'https://ears.example.com')).toBe( + 'https://ears.example.com/github/oauth/token' + ); + }); + + it('throws when earsBaseUrl is undefined', () => { + expect(() => resolveEarsUrl('/github/oauth/token', undefined)).toThrow( + 'EARS base URL is not configured' + ); + }); + + it('throws when earsBaseUrl is an empty string', () => { + expect(() => resolveEarsUrl('/github/oauth/token', '')).toThrow( + 'EARS base URL is not configured' + ); + }); +}); + +describe('getEarsEndpointsForProvider', () => { + it.each(['google', 'microsoft', 'slack'])( + 'returns authorize, token and refresh endpoints for supported provider "%s"', + (provider) => { + const { authorizeEndpoint, tokenEndpoint, refreshEndpoint } = + getEarsEndpointsForProvider(provider); + expect(authorizeEndpoint).toBe(`v1/${provider}/oauth/authorize`); + expect(tokenEndpoint).toBe(`v1/${provider}/oauth/token`); + expect(refreshEndpoint).toBe(`v1/${provider}/oauth/refresh`); + } + ); + + it('throws when provider is undefined', () => { + expect(() => getEarsEndpointsForProvider(undefined)).toThrow('Provider is not configured'); + }); + + it('throws for an unsupported provider', () => { + expect(() => getEarsEndpointsForProvider('okta')).toThrow('Unsupported provider: okta'); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/ears/url.ts b/x-pack/platform/plugins/shared/actions/server/lib/ears/url.ts new file mode 100644 index 0000000000000..244c417fee7a8 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/ears/url.ts @@ -0,0 +1,42 @@ +/* + * 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 { EARS_PROVIDERS } from '@kbn/connector-specs'; + +const SUPPORTED_EARS_PROVIDERS = new Set(EARS_PROVIDERS); +const EARS_API_VERSION = 'v1'; + +export function resolveEarsUrl(urlPath: string, earsBaseUrl: string | undefined): string { + if (!earsBaseUrl) { + throw new Error('EARS base URL is not configured'); + } + + const base = earsBaseUrl.replace(/\/$/, ''); // strip trailing slash if any present + const path = urlPath.startsWith('/') ? urlPath : `/${urlPath}`; + + return `${base}${path}`; +} + +export interface EarsEndpoints { + authorizeEndpoint: string; + tokenEndpoint: string; + refreshEndpoint: string; +} + +export function getEarsEndpointsForProvider(provider: string | undefined): EarsEndpoints { + if (!provider) { + throw new Error('Provider is not configured'); + } + if (!SUPPORTED_EARS_PROVIDERS.has(provider)) { + throw new Error(`Unsupported provider: ${provider}`); + } + + return { + authorizeEndpoint: `${EARS_API_VERSION}/${provider}/oauth/authorize`, + tokenEndpoint: `${EARS_API_VERSION}/${provider}/oauth/token`, + refreshEndpoint: `${EARS_API_VERSION}/${provider}/oauth/refresh`, + }; +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_axios_instance.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_axios_instance.ts index bc973cbd5a82f..6515fa102af08 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_axios_instance.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_axios_instance.ts @@ -15,9 +15,7 @@ import { getCustomAgents } from './get_custom_agents'; import type { ActionsConfigurationUtilities } from '../actions_config'; import type { ConnectorTokenClientContract } from '../types'; import { getBeforeRedirectFn } from './before_redirect'; -import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; -import { getOAuthAuthorizationCodeAccessToken } from './get_oauth_authorization_code_access_token'; -import { getDeleteTokenAxiosInterceptor } from './delete_token_axios_interceptor'; +import { getAxiosAuthStrategy } from './axios_auth_strategies'; export type ConnectorInfo = Omit; @@ -29,91 +27,6 @@ interface GetAxiosInstanceOpts { type ValidatedSecrets = Record; -interface AxiosErrorWithRetry { - config: InternalAxiosRequestConfig & { _retry?: boolean }; - response?: { status: number }; - message: string; -} - -interface OAuth2AuthCodeParams { - clientId?: string; - clientSecret?: string; - tokenUrl?: string; - scope?: string; - useBasicAuth?: boolean; -} - -async function handleOAuth401Error({ - error, - connectorId, - secrets, - connectorTokenClient, - logger, - configurationUtilities, - axiosInstance, - authMode, - profileUid, -}: { - error: AxiosErrorWithRetry; - connectorId: string; - secrets: OAuth2AuthCodeParams; - connectorTokenClient: ConnectorTokenClientContract; - logger: Logger; - configurationUtilities: ActionsConfigurationUtilities; - axiosInstance: AxiosInstance; - authMode?: AuthMode; - profileUid?: string; -}): Promise { - // Prevent retry loops - only attempt refresh once per request - if (error.config._retry) { - return Promise.reject(error); - } - - error.config._retry = true; - logger.debug(`Attempting token refresh for connectorId ${connectorId} after 401 error`); - - const { clientId, clientSecret, tokenUrl, scope, useBasicAuth } = secrets; - if (!clientId || !clientSecret || !tokenUrl) { - error.message = - 'Authentication failed: Missing required OAuth configuration (clientId, clientSecret, tokenUrl).'; - return Promise.reject(error); - } - - // Use the shared token refresh function with mutex protection - const newAccessToken = await getOAuthAuthorizationCodeAccessToken({ - connectorId, - logger, - configurationUtilities, - credentials: { - config: { - clientId, - tokenUrl, - useBasicAuth, - }, - secrets: { - clientSecret, - }, - }, - connectorTokenClient, - scope, - authMode, - profileUid, - forceRefresh: true, - }); - - if (!newAccessToken) { - error.message = - 'Authentication failed: Unable to refresh access token. Please re-authorize the connector.'; - return Promise.reject(error); - } - - logger.debug(`Token refreshed successfully for connectorId ${connectorId}. Retrying request.`); - - // Update request with the new token and retry - error.config.headers.Authorization = newAccessToken; - return axiosInstance.request(error.config); -} - export interface GetAxiosInstanceWithAuthFnOpts { additionalHeaders?: Record; connectorId: string; @@ -182,88 +95,24 @@ export const getAxiosInstanceWithAuth = ({ return config; }); + const strategy = getAxiosAuthStrategy(authTypeId); + const strategyDeps = { + connectorId, + secrets, + connectorTokenClient, + logger, + configurationUtilities, + authMode, + profileUid, + }; + if (connectorTokenClient) { - if (authTypeId === 'oauth_authorization_code') { - // Add a response interceptor to handle 401 errors for OAuth authz code grant connectors - axiosInstance.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - return handleOAuth401Error({ - error, - connectorId, - secrets: secrets as OAuth2AuthCodeParams, - connectorTokenClient, - logger, - configurationUtilities, - axiosInstance, - authMode, - profileUid, - }); - } - return Promise.reject(error); - } - ); - } else { - // add a response interceptor to clean up saved tokens if necessary - const { onFulfilled, onRejected } = getDeleteTokenAxiosInterceptor({ - connectorTokenClient, - connectorId, - }); - axiosInstance.interceptors.response.use(onFulfilled, onRejected); - } + strategy.installResponseInterceptor(axiosInstance, strategyDeps); } const configureCtx = { getCustomHostSettings: (url: string) => configurationUtilities.getCustomHostSettings(url), - getToken: async (opts: GetTokenOpts) => { - // Use different token retrieval method based on auth type - if (authTypeId === 'oauth_authorization_code') { - // For authorization code flow, retrieve stored tokens from callback - if (!connectorTokenClient) { - throw new Error('ConnectorTokenClient is required for OAuth authorization code flow'); - } - return await getOAuthAuthorizationCodeAccessToken({ - connectorId, - logger, - configurationUtilities, - credentials: { - config: { - clientId: opts.clientId, - tokenUrl: opts.tokenUrl, - ...(opts.additionalFields ? { additionalFields: opts.additionalFields } : {}), - }, - secrets: { - clientSecret: opts.clientSecret, - }, - }, - connectorTokenClient, - scope: opts.scope, - authMode, - profileUid, - }); - } - - // For client credentials flow, request new token each time - return await getOAuthClientCredentialsAccessToken({ - connectorId, - logger, - tokenUrl: opts.tokenUrl, - oAuthScope: opts.scope, - configurationUtilities, - credentials: { - config: { - clientId: opts.clientId, - ...(opts.additionalFields ? { additionalFields: opts.additionalFields } : {}), - }, - secrets: { - clientSecret: opts.clientSecret, - }, - }, - connectorTokenClient, - tokenEndpointAuthMethod: opts.tokenEndpointAuthMethod, - }); - }, + getToken: (opts: GetTokenOpts) => strategy.getToken(opts, strategyDeps), logger, proxySettings: configurationUtilities.getProxySettings(), sslSettings: configurationUtilities.getSSLSettings(), diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.test.ts index 78c8b0fc36156..5aa691abcf811 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.test.ts @@ -479,31 +479,4 @@ describe('getOAuthAuthorizationCodeAccessToken', () => { ); }); }); - - describe('concurrency lock', () => { - it('queues concurrent calls for the same connector so only one refresh runs', async () => { - const lockedConnectorId = 'connector-lock-test'; - // First call inside the lock sees an expired token and refreshes it. - // Second call (queued behind the first) re-fetches and sees the valid token. - connectorTokenClient.get - .mockResolvedValueOnce({ - hasErrors: false, - connectorToken: { ...expiredToken, connectorId: lockedConnectorId }, - }) - .mockResolvedValueOnce({ - hasErrors: false, - connectorToken: { ...validToken, connectorId: lockedConnectorId }, - }); - (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); - - const [result1, result2] = await Promise.all([ - getOAuthAuthorizationCodeAccessToken({ ...baseOpts, connectorId: lockedConnectorId }), - getOAuthAuthorizationCodeAccessToken({ ...baseOpts, connectorId: lockedConnectorId }), - ]); - - expect(requestOAuthRefreshToken).toHaveBeenCalledTimes(1); - expect(result1).toBe('Bearer new-access-token'); - expect(result2).toBe('stored-access-token'); - }); - }); }); 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 68f035b918ec5..2de69c386a91c 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 @@ -5,24 +5,12 @@ * 2.0. */ -import { get } from 'lodash'; -import pLimit from 'p-limit'; import type { Logger } from '@kbn/core/server'; import type { AuthMode } from '@kbn/connector-specs'; import type { ActionsConfigurationUtilities } from '../actions_config'; -import type { ConnectorToken, ConnectorTokenClientContract, UserConnectorToken } from '../types'; +import type { ConnectorTokenClientContract } from '../types'; import { requestOAuthRefreshToken } from './request_oauth_refresh_token'; - -// Per-connector locks to prevent concurrent token refreshes for the same connector -const tokenRefreshLocks = new Map>(); - -function getOrCreateLock(connectorId: string): ReturnType { - if (!tokenRefreshLocks.has(connectorId)) { - // Using p-limit with concurrency of 1 creates a mutex (only 1 operation at a time) - tokenRefreshLocks.set(connectorId, pLimit(1)); - } - return tokenRefreshLocks.get(connectorId)!; -} +import { getStoredTokenWithRefresh } from './get_stored_oauth_token_with_refresh'; export interface GetOAuthAuthorizationCodeConfig { clientId: string; @@ -55,35 +43,6 @@ interface GetOAuthAuthorizationCodeAccessTokenOpts { forceRefresh?: boolean; } -interface ExtractedStoredOAuthTokens { - accessToken: string | null; - refreshToken: string | undefined; -} - -const getStringField = (obj: unknown, path: string): string | undefined => { - const value = get(obj, path); - return typeof value === 'string' ? value : undefined; -}; - -const extractStoredOAuthTokens = ({ - connectorToken, - isPerUser, -}: { - connectorToken: ConnectorToken | UserConnectorToken; - isPerUser: boolean; -}): ExtractedStoredOAuthTokens => { - const accessToken = getStringField( - connectorToken, - isPerUser ? 'credentials.accessToken' : 'token' - ); - const refreshToken = getStringField( - connectorToken, - isPerUser ? 'credentials.refreshToken' : 'refreshToken' - ); - - return { accessToken: accessToken ?? null, refreshToken }; -}; - /** * Get an access token for OAuth2 Authorization Code flow * Automatically refreshes expired tokens using the refresh token @@ -119,113 +78,21 @@ export const getOAuthAuthorizationCodeAccessToken = async ({ // Default to true (OAuth 2.0 recommended practice) const shouldUseBasicAuth = useBasicAuth ?? true; - // Acquire lock for this connector to prevent concurrent token refreshes - const lock = getOrCreateLock(connectorId); - - return await lock(async () => { - // Re-fetch token inside lock - another request may have already refreshed it - const { connectorToken, hasErrors } = isPerUser - ? await connectorTokenClient.get({ - profileUid: profileUid!, - connectorId, - tokenType: 'access_token', - }) - : await connectorTokenClient.get({ - connectorId, - tokenType: 'access_token', - }); - - if (hasErrors) { - logger.warn(`Errors fetching connector token for connectorId: ${connectorId}`); - return null; - } - - // No token found - user must authorize first - if (!connectorToken) { - logger.warn( - `No access token found for connectorId: ${connectorId}. User must complete OAuth authorization flow.` - ); - return null; - } - - // 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 extractedTokens = extractStoredOAuthTokens({ - connectorToken: connectorToken as ConnectorToken | UserConnectorToken, - isPerUser, - }); - - const { accessToken: storedAccessToken, refreshToken: storedRefreshToken } = extractedTokens; - - if (!forceRefresh && expiresAt > now) { - // Token still valid - logger.debug(`Using stored access token for connectorId: ${connectorId}`); - if (storedAccessToken === null) { - logger.warn( - `Stored token has unexpected shape for connectorId: ${connectorId} (authMode: ${ - authMode ?? 'shared' - }). User must re-authorize.` - ); - } - return storedAccessToken; - } - - if (!storedRefreshToken) { - logger.warn( - `Access token expired and no refresh token available for connectorId: ${connectorId}. User must re-authorize.` - ); - return null; - } - - // Check if the refresh token is expired - if ( - connectorToken.refreshTokenExpiresAt && - Date.parse(connectorToken.refreshTokenExpiresAt) <= now - ) { - logger.warn(`Refresh token expired for connectorId: ${connectorId}. User must re-authorize.`); - return null; - } - - // Refresh the token - logger.debug(`Refreshing access token for connectorId: ${connectorId}`); - try { - const tokenResult = await requestOAuthRefreshToken( + return getStoredTokenWithRefresh({ + connectorId, + logger, + connectorTokenClient, + forceRefresh, + isPerUser, + profileUid, + authMode, + refreshFn: (refreshToken) => + requestOAuthRefreshToken( tokenUrl, logger, - { - refreshToken: storedRefreshToken, - clientId, - clientSecret, - scope, - ...additionalFields, - }, + { refreshToken, clientId, clientSecret, scope, ...additionalFields }, configurationUtilities, shouldUseBasicAuth - ); - - const newAccessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; - - const updatedRefreshToken: string | undefined = - tokenResult.refreshToken ?? storedRefreshToken; - - // Update stored token - await connectorTokenClient.updateWithRefreshToken({ - id: connectorToken.id!, - token: newAccessToken, - refreshToken: updatedRefreshToken, - expiresIn: tokenResult.expiresIn, - refreshTokenExpiresIn: tokenResult.refreshTokenExpiresIn, - tokenType: 'access_token', - }); - - return newAccessToken; - } catch (err) { - logger.error( - `Failed to refresh access token for connectorId: ${connectorId}. Error: ${err.message}` - ); - return null; - } + ), }); }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_stored_oauth_token_with_refresh.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_stored_oauth_token_with_refresh.test.ts new file mode 100644 index 0000000000000..acecc50fc0eda --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_stored_oauth_token_with_refresh.test.ts @@ -0,0 +1,404 @@ +/* + * 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 type { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { connectorTokenClientMock } from './connector_token_client.mock'; +import { getStoredTokenWithRefresh } from './get_stored_oauth_token_with_refresh'; + +const NOW = new Date('2024-01-15T12:00:00.000Z'); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const connectorTokenClient = connectorTokenClientMock.create(); + +// Access token expires 1h from now, refresh token expires in 7 days +const validToken = { + id: 'token-1', + connectorId: 'connector-1', + tokenType: 'access_token', + token: 'stored-access-token', + createdAt: new Date('2024-01-15T10:00:00.000Z').toISOString(), + expiresAt: new Date('2024-01-15T13:00:00.000Z').toISOString(), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresAt: new Date('2024-01-22T12:00:00.000Z').toISOString(), +}; + +// Same but access token expired 1h ago +const expiredToken = { + ...validToken, + expiresAt: new Date('2024-01-15T11:00:00.000Z').toISOString(), +}; + +// Per-user token: access/refresh stored under credentials.accessToken / credentials.refreshToken +const validPerUserToken = { + id: 'token-1', + profileUid: 'profile-1', + connectorId: 'connector-1', + credentialType: 'oauth', + credentials: { + accessToken: 'stored-per-user-access-token', + refreshToken: 'stored-per-user-refresh-token', + }, + createdAt: new Date('2024-01-15T10:00:00.000Z').toISOString(), + updatedAt: new Date('2024-01-15T10:00:00.000Z').toISOString(), + expiresAt: new Date('2024-01-15T13:00:00.000Z').toISOString(), + refreshTokenExpiresAt: new Date('2024-01-22T12:00:00.000Z').toISOString(), +}; + +const expiredPerUserToken = { + ...validPerUserToken, + expiresAt: new Date('2024-01-15T11:00:00.000Z').toISOString(), +}; + +const refreshResponse = { + tokenType: 'Bearer', + accessToken: 'new-access-token', + expiresIn: 3600, + refreshToken: 'new-refresh-token', + refreshTokenExpiresIn: 604800, +}; + +const refreshFn = jest.fn(); + +const baseOpts = { + connectorId: 'connector-1', + logger, + connectorTokenClient, + refreshFn, +}; + +let clock: sinon.SinonFakeTimers; + +describe('getStoredTokenWithRefresh', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(NOW); + }); + beforeEach(() => { + clock.reset(); + jest.resetAllMocks(); + }); + afterAll(() => clock.restore()); + + describe('stored token retrieval', () => { + it('returns null and warns when the token fetch reports errors', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: true, connectorToken: null }); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Errors fetching connector token for connectorId: connector-1' + ); + expect(refreshFn).not.toHaveBeenCalled(); + }); + + it('returns null and warns when no token is stored', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: false, connectorToken: null }); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'No access token found for connectorId: connector-1. User must complete OAuth authorization flow.' + ); + expect(refreshFn).not.toHaveBeenCalled(); + }); + + it('returns the stored token without calling refreshFn when it has not expired', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: validToken, + }); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBe('stored-access-token'); + expect(refreshFn).not.toHaveBeenCalled(); + }); + + it('treats a token with no expiresAt as never-expiring', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...validToken, expiresAt: undefined }, + }); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBe('stored-access-token'); + expect(refreshFn).not.toHaveBeenCalled(); + }); + }); + + describe('token refresh', () => { + it('returns null and warns when the access token is expired but no refresh token is stored', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...expiredToken, refreshToken: undefined }, + }); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Access token expired and no refresh token available for connectorId: connector-1. User must re-authorize.' + ); + }); + + it('returns null and warns when the refresh token itself is expired', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + ...expiredToken, + refreshTokenExpiresAt: new Date('2024-01-15T11:00:00.000Z').toISOString(), + }, + }); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Refresh token expired for connectorId: connector-1. User must re-authorize.' + ); + }); + + it('calls refreshFn with the stored refresh token when the access token is expired', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + refreshFn.mockResolvedValueOnce(refreshResponse); + + await getStoredTokenWithRefresh(baseOpts); + + expect(refreshFn).toHaveBeenCalledWith('stored-refresh-token'); + }); + + it('returns the refreshed token formatted as "tokenType accessToken"', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + refreshFn.mockResolvedValueOnce(refreshResponse); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBe('Bearer new-access-token'); + }); + + it('persists the refreshed token and the new refresh token from the response', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + refreshFn.mockResolvedValueOnce(refreshResponse); + + await getStoredTokenWithRefresh(baseOpts); + + expect(connectorTokenClient.updateWithRefreshToken).toHaveBeenCalledWith({ + id: 'token-1', + token: 'Bearer new-access-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 604800, + tokenType: 'access_token', + }); + }); + + it('falls back to the existing refresh token when the response omits one', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + refreshFn.mockResolvedValueOnce({ ...refreshResponse, refreshToken: undefined }); + + await getStoredTokenWithRefresh(baseOpts); + + expect(connectorTokenClient.updateWithRefreshToken).toHaveBeenCalledWith( + expect.objectContaining({ refreshToken: 'stored-refresh-token' }) + ); + }); + }); + + describe('forceRefresh', () => { + it('bypasses the expiry check and refreshes a still-valid token', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: validToken, + }); + refreshFn.mockResolvedValueOnce(refreshResponse); + + const result = await getStoredTokenWithRefresh({ ...baseOpts, forceRefresh: true }); + + expect(result).toBe('Bearer new-access-token'); + expect(refreshFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('error handling', () => { + it('returns null and logs an error when refreshFn throws', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + refreshFn.mockRejectedValueOnce(new Error('upstream auth failed')); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to refresh access token for connectorId: connector-1. Error: upstream auth failed' + ); + }); + + it('returns null and logs an error when persisting the refreshed token fails', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + refreshFn.mockResolvedValueOnce(refreshResponse); + connectorTokenClient.updateWithRefreshToken.mockRejectedValueOnce( + new Error('DB write failed') + ); + + const result = await getStoredTokenWithRefresh(baseOpts); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to refresh access token for connectorId: connector-1. Error: DB write failed' + ); + }); + }); + + describe('concurrency lock', () => { + it('queues concurrent calls for the same connector so only one refresh runs', async () => { + const lockedConnectorId = 'connector-lock-shared'; + // First call inside the lock sees an expired token and refreshes it. + // Second call (queued behind the first) re-fetches and sees the valid token. + connectorTokenClient.get + .mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...expiredToken, connectorId: lockedConnectorId }, + }) + .mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...validToken, connectorId: lockedConnectorId }, + }); + refreshFn.mockResolvedValueOnce(refreshResponse); + + const [result1, result2] = await Promise.all([ + getStoredTokenWithRefresh({ ...baseOpts, connectorId: lockedConnectorId }), + getStoredTokenWithRefresh({ ...baseOpts, connectorId: lockedConnectorId }), + ]); + + expect(refreshFn).toHaveBeenCalledTimes(1); + expect(result1).toBe('Bearer new-access-token'); + expect(result2).toBe('stored-access-token'); + }); + + it('queues concurrent per-user calls for the same connector+user so only one refresh runs', async () => { + const lockedConnectorId = 'connector-lock-per-user-same'; + // Same profileUid => same lock key => serialized. + // First call refreshes, second call re-fetches and sees the valid token. + connectorTokenClient.get + .mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...expiredPerUserToken, connectorId: lockedConnectorId }, + }) + .mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...validPerUserToken, connectorId: lockedConnectorId }, + }); + refreshFn.mockResolvedValueOnce(refreshResponse); + + const opts = { + ...baseOpts, + connectorId: lockedConnectorId, + isPerUser: true as const, + profileUid: 'profile-1', + }; + const [result1, result2] = await Promise.all([ + getStoredTokenWithRefresh(opts), + getStoredTokenWithRefresh(opts), + ]); + + expect(refreshFn).toHaveBeenCalledTimes(1); + expect(result1).toBe('Bearer new-access-token'); + expect(result2).toBe('stored-per-user-access-token'); + }); + + it.each([ + { + label: 'shared mode (simple lock key)', + extraOpts: {}, + connectorToken: validToken, + expectedLockKey: (connectorId: string) => connectorId, + }, + { + label: 'per-user mode (composite lock key)', + extraOpts: { isPerUser: true as const, profileUid: 'profile-cleanup-test' }, + connectorToken: validPerUserToken, + expectedLockKey: (connectorId: string) => `${connectorId}:profile-cleanup-test`, + }, + ])( + 'removes the lock entry after all queued calls complete to prevent memory leaks ($label)', + async ({ extraOpts, connectorToken, expectedLockKey }) => { + const deleteSpy = jest.spyOn(Map.prototype, 'delete'); + const connectorId = 'connector-cleanup-test'; + + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...connectorToken, connectorId }, + }); + + await getStoredTokenWithRefresh({ ...baseOpts, connectorId, ...extraOpts }); + + expect(deleteSpy).toHaveBeenCalledWith(expectedLockKey(connectorId)); + deleteSpy.mockRestore(); + } + ); + + it('allows concurrent per-user calls for different users on the same connector to refresh independently', async () => { + const lockedConnectorId = 'connector-lock-per-user-diff'; + // Different profileUids => different lock keys => independent execution. + // Both users see an expired token and each triggers its own refresh. + const expiredUser1Token = { ...expiredPerUserToken, connectorId: lockedConnectorId }; + const expiredUser2Token = { + ...expiredPerUserToken, + id: 'token-2', + profileUid: 'profile-2', + connectorId: lockedConnectorId, + }; + connectorTokenClient.get + .mockResolvedValueOnce({ hasErrors: false, connectorToken: expiredUser1Token }) + .mockResolvedValueOnce({ hasErrors: false, connectorToken: expiredUser2Token }); + refreshFn + .mockResolvedValueOnce(refreshResponse) + .mockResolvedValueOnce({ ...refreshResponse, accessToken: 'new-access-token-user2' }); + + const [result1, result2] = await Promise.all([ + getStoredTokenWithRefresh({ + ...baseOpts, + connectorId: lockedConnectorId, + isPerUser: true, + profileUid: 'profile-1', + }), + getStoredTokenWithRefresh({ + ...baseOpts, + connectorId: lockedConnectorId, + isPerUser: true, + profileUid: 'profile-2', + }), + ]); + + // Each user refreshed independently — no sharing of the lock + expect(refreshFn).toHaveBeenCalledTimes(2); + expect(result1).toBe('Bearer new-access-token'); + expect(result2).toBe('Bearer new-access-token-user2'); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_stored_oauth_token_with_refresh.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_stored_oauth_token_with_refresh.ts new file mode 100644 index 0000000000000..10838aac0f0ff --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_stored_oauth_token_with_refresh.ts @@ -0,0 +1,202 @@ +/* + * 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 { get } from 'lodash'; +import pLimit from 'p-limit'; +import type { Logger } from '@kbn/core/server'; +import type { OAuthTokenResponse } from './request_oauth_token'; +import type { ConnectorToken, ConnectorTokenClientContract, UserConnectorToken } from '../types'; + +// Per-connector (or per-connector-per-user) locks to prevent concurrent token refreshes +const tokenRefreshLocks = new Map>(); + +function getOrCreateLock(lockKey: string): ReturnType { + if (!tokenRefreshLocks.has(lockKey)) { + tokenRefreshLocks.set(lockKey, pLimit(1)); + } + return tokenRefreshLocks.get(lockKey)!; +} + +export interface GetStoredTokenWithRefreshOpts { + connectorId: string; + logger: Logger; + connectorTokenClient: ConnectorTokenClientContract; + /** + * When true, skip the expiration check and force a token refresh. + * Use this when you've received a 401 and know the token is invalid + * even if it hasn't "expired" according to the stored timestamp. + */ + forceRefresh?: boolean; + isPerUser?: boolean; + /** Required when `isPerUser` is true to look up the per-user stored token. */ + profileUid?: string; + /** Used in log messages to identify the auth mode (e.g. 'per-user'). */ + authMode?: string; + /** + * Called when a refresh is needed. Receives the stored refresh token + * and must return a new token response from the auth server. + */ + refreshFn: (refreshToken: string) => Promise; +} + +interface ExtractedStoredOAuthTokens { + accessToken: string | null; + refreshToken: string | undefined; +} + +const getStringField = (obj: unknown, path: string): string | undefined => { + const value = get(obj, path); + return typeof value === 'string' ? value : undefined; +}; + +const extractStoredOAuthTokens = ({ + connectorToken, + isPerUser, +}: { + connectorToken: ConnectorToken | UserConnectorToken; + isPerUser: boolean; +}): ExtractedStoredOAuthTokens => { + const accessToken = getStringField( + connectorToken, + isPerUser ? 'credentials.accessToken' : 'token' + ); + const refreshToken = getStringField( + connectorToken, + isPerUser ? 'credentials.refreshToken' : 'refreshToken' + ); + + return { accessToken: accessToken ?? null, refreshToken }; +}; + +/** + * Retrieves a stored OAuth access token, refreshing it automatically when expired. + * + * Handles the common lifecycle shared by all per-user OAuth flows: + * - Fetching the stored token from the connector token client + * - Validating expiry (access token and refresh token) + * - Calling the provided `doRefresh` to obtain a new token from the auth server + * - Persisting the refreshed token back to storage + * + * Concurrent refresh requests for the same connector are serialized via a + * per-connector mutex to avoid redundant token requests. + */ +export const getStoredTokenWithRefresh = async ({ + connectorId, + logger, + connectorTokenClient, + forceRefresh = false, + isPerUser = false, + profileUid, + authMode, + refreshFn, +}: GetStoredTokenWithRefreshOpts): Promise => { + // Acquire lock scoped to the connector (shared mode) or to the connector + user (per-user mode), + // so concurrent requests for different users don't block each other unnecessarily. + const lockKey = isPerUser ? `${connectorId}:${profileUid}` : connectorId; + const lock = getOrCreateLock(lockKey); + + const result = await lock(async () => { + // Re-fetch token inside lock - another request may have already refreshed it + const { connectorToken, hasErrors } = isPerUser + ? await connectorTokenClient.get({ + profileUid: profileUid!, + connectorId, + tokenType: 'access_token', + }) + : await connectorTokenClient.get({ + connectorId, + tokenType: 'access_token', + }); + + if (hasErrors) { + logger.warn(`Errors fetching connector token for connectorId: ${connectorId}`); + return null; + } + + // No token found - user must authorize first + if (!connectorToken) { + logger.warn( + `No access token found for connectorId: ${connectorId}. User must complete OAuth authorization flow.` + ); + return null; + } + + // 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 extractedTokens = extractStoredOAuthTokens({ + connectorToken: connectorToken as ConnectorToken | UserConnectorToken, + isPerUser, + }); + + const { accessToken: storedAccessToken, refreshToken: storedRefreshToken } = extractedTokens; + + if (!forceRefresh && expiresAt > now) { + // Token still valid + logger.debug(`Using stored access token for connectorId: ${connectorId}`); + if (storedAccessToken === null) { + logger.warn( + `Stored token has unexpected shape for connectorId: ${connectorId} (authMode: ${ + authMode ?? 'shared' + }). User must re-authorize.` + ); + } + return storedAccessToken; + } + + if (!storedRefreshToken) { + logger.warn( + `Access token expired and no refresh token available for connectorId: ${connectorId}. User must re-authorize.` + ); + return null; + } + + // Check if the refresh token is expired + if ( + connectorToken.refreshTokenExpiresAt && + Date.parse(connectorToken.refreshTokenExpiresAt) <= now + ) { + logger.warn(`Refresh token expired for connectorId: ${connectorId}. User must re-authorize.`); + return null; + } + + // Refresh the token + logger.debug(`Refreshing access token for connectorId: ${connectorId}`); + try { + const tokenResult = await refreshFn(storedRefreshToken); + + const newAccessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + + const updatedRefreshToken: string | undefined = + tokenResult.refreshToken ?? storedRefreshToken; + + // Update stored token + await connectorTokenClient.updateWithRefreshToken({ + id: connectorToken.id!, + token: newAccessToken, + refreshToken: updatedRefreshToken, + expiresIn: tokenResult.expiresIn, + refreshTokenExpiresIn: tokenResult.refreshTokenExpiresIn, + tokenType: 'access_token', + }); + + return newAccessToken; + } catch (err) { + logger.error( + `Failed to refresh access token for connectorId: ${connectorId}. Error: ${err.message}` + ); + return null; + } + }); + + if (lock.pendingCount === 0 && lock.activeCount === 0) { + tokenRefreshLocks.delete(lockKey); + } + + return result; +}; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.test.ts index 0f026e2683f86..5c2463518923a 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.test.ts @@ -48,6 +48,7 @@ describe('OAuthAuthorizationService', () => { const result = await service.getOAuthConfig('connector-1', undefined); expect(result).toEqual({ + authTypeId: 'oauth_authorization_code', authorizationUrl: 'https://provider.example.com/authorize', clientId: 'secret-client-id', scope: 'openid email', @@ -86,17 +87,18 @@ describe('OAuthAuthorizationService', () => { const result = await service.getOAuthConfig('connector-1', undefined); expect(result).toEqual({ + authTypeId: 'oauth_authorization_code', authorizationUrl: 'https://config-provider.example.com/authorize', clientId: 'config-client-id', scope: 'profile', }); }); - it('supports auth.type for OAuth validation', async () => { + it('passes namespace when provided', async () => { const service = createService(); const getResult = createMockConnector({ id: 'connector-1', - config: { auth: { type: 'oauth_authorization_code' } }, + config: { authType: 'oauth_authorization_code' }, }); mockActionsClient.get.mockResolvedValue(getResult); mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ @@ -109,20 +111,21 @@ describe('OAuthAuthorizationService', () => { }, }); - const result = await service.getOAuthConfig('connector-1', undefined); + await service.getOAuthConfig('connector-1', 'custom-namespace'); - expect(result).toEqual({ - authorizationUrl: 'https://provider.example.com/authorize', - clientId: 'client-id', - scope: undefined, - }); + expect(mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + 'action', + 'connector-1', + { namespace: 'custom-namespace' } + ); }); - it('passes namespace when provided', async () => { + it('validates via authMode per-user for API-created spec connectors', async () => { const service = createService(); const getResult = createMockConnector({ id: 'connector-1', - config: { authType: 'oauth_authorization_code' }, + config: {}, + authMode: 'per-user', }); mockActionsClient.get.mockResolvedValue(getResult); mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ @@ -135,26 +138,24 @@ describe('OAuthAuthorizationService', () => { }, }); - await service.getOAuthConfig('connector-1', 'custom-namespace'); + const result = await service.getOAuthConfig('connector-1', undefined); - expect(mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( - 'action', - 'connector-1', - { namespace: 'custom-namespace' } - ); + expect(result).toEqual({ + authTypeId: 'oauth_authorization_code', + authorizationUrl: 'https://provider.example.com/authorize', + clientId: 'client-id', + scope: undefined, + }); }); - it('validates via authMode per-user for API-created spec connectors', async () => { + it('resolves authType from secrets when config has none', async () => { const service = createService(); - const getResult = createMockConnector({ - id: 'connector-1', - config: {}, - authMode: 'per-user', - }); + const getResult = createMockConnector({ id: 'connector-1', config: {} }); mockActionsClient.get.mockResolvedValue(getResult); mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ attributes: { secrets: { + authType: 'oauth_authorization_code', authorizationUrl: 'https://provider.example.com/authorize', clientId: 'client-id', }, @@ -165,6 +166,7 @@ describe('OAuthAuthorizationService', () => { const result = await service.getOAuthConfig('connector-1', undefined); expect(result).toEqual({ + authTypeId: 'oauth_authorization_code', authorizationUrl: 'https://provider.example.com/authorize', clientId: 'client-id', scope: undefined, @@ -178,9 +180,12 @@ describe('OAuthAuthorizationService', () => { config: { authType: 'basic' }, }); mockActionsClient.get.mockResolvedValue(getResult); + mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + attributes: { secrets: {}, config: {} }, + }); await expect(service.getOAuthConfig('connector-1', undefined)).rejects.toThrow( - 'Connector does not use OAuth Authorization Code flow' + 'Connector does not use OAuth Authorization Code or EARS flow' ); }); @@ -192,9 +197,12 @@ describe('OAuthAuthorizationService', () => { authMode: 'shared', }); mockActionsClient.get.mockResolvedValue(getResult); + mockEncryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + attributes: { secrets: {}, config: {} }, + }); await expect(service.getOAuthConfig('connector-1', undefined)).rejects.toThrow( - 'Connector does not use OAuth Authorization Code flow' + 'Connector does not use OAuth Authorization Code or EARS flow' ); }); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.ts index 92245860faca3..6379f12ae5192 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.ts @@ -14,6 +14,8 @@ import { BASE_ACTION_API_PATH } from '../../common'; * OAuth connector secrets stored in encrypted saved objects */ interface OAuthConnectorSecrets { + authType?: string; + provider?: string; authorizationUrl?: string; clientId?: string; clientSecret?: string; @@ -26,9 +28,6 @@ interface OAuthConnectorSecrets { */ interface OAuthConnectorConfig { authType?: string; - auth?: { - type?: string; - }; authorizationUrl?: string; clientId?: string; tokenUrl?: string; @@ -36,14 +35,26 @@ interface OAuthConnectorConfig { } /** - * OAuth configuration required for authorization flow + * OAuth configuration for standard OAuth Authorization Code flow */ -export interface OAuthConfig { +export interface OAuthFlowConfig { + authTypeId: 'oauth_authorization_code'; authorizationUrl: string; clientId: string; scope?: string; } +/** + * OAuth configuration for EARS (Elastic Authentication Redirect Service) flow + */ +export interface EarsFlowConfig { + authTypeId: 'ears'; + provider: string; + scope?: string; +} + +export type OAuthConfig = OAuthFlowConfig | EarsFlowConfig; + /** * Parameters for building an OAuth authorization URL */ @@ -56,6 +67,17 @@ interface BuildAuthorizationUrlParams { codeChallenge: string; } +/** + * Parameters for building an EARS authorization URL + */ +interface BuildEarsAuthorizationUrlParams { + baseAuthorizationUrl: string; + scope?: string; + callbackUri: string; + state: string; + pkceChallenge: string; +} + interface ConnectorWithOAuth { actionTypeId: string; name: string; @@ -86,18 +108,25 @@ export class OAuthAuthorizationService { } /** - * Validates that a connector uses OAuth Authorization Code flow - * @throws Error if connector doesn't use oauth_authorization_code + * Validates that a connector uses OAuth Authorization Code or EARS flow + * @throws Error if connector doesn't use a supported OAuth flow */ - private validateOAuthConnector(config: OAuthConnectorConfig, authMode?: AuthMode): void { - const isOAuthAuthCode = - authMode === 'per-user' || - config?.authType === 'oauth_authorization_code' || - config?.auth?.type === 'oauth_authorization_code'; - - if (!isOAuthAuthCode) { - throw new Error('Connector does not use OAuth Authorization Code flow'); + private validateOAuthConnector( + config: OAuthConnectorConfig, + secrets: OAuthConnectorSecrets, + authMode?: AuthMode + ): 'oauth_authorization_code' | 'ears' { + const authType = secrets?.authType || config?.authType; + + if (authType === 'oauth_authorization_code' || authType === 'ears') { + return authType; } + + if (authMode === 'per-user') { + return 'oauth_authorization_code'; + } + + throw new Error('Connector does not use OAuth Authorization Code or EARS flow'); } /** @@ -113,9 +142,6 @@ export class OAuthAuthorizationService { const connector = await this.actionsClient.get({ id: connectorId }); const config = connector.config as OAuthConnectorConfig; - // Validate this is an OAuth connector - this.validateOAuthConnector(config, connector.authMode); - // Fetch connector with decrypted secrets const rawAction = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( @@ -123,15 +149,31 @@ export class OAuthAuthorizationService { connectorId, { namespace } ); - const secrets = rawAction.attributes.secrets; + // Validate this is an OAuth connector and get the resolved auth type + const authTypeId = this.validateOAuthConnector(config, secrets, connector.authMode); + + const scope = secrets.scope || config?.scope; + + if (authTypeId === 'ears') { + const provider = secrets.provider; + if (!provider) { + throw new Error('Connector missing required OAuth configuration (EARS provider)'); + } + return { + authTypeId: 'ears', + provider, + scope, + }; + } + + // Standard OAuth Authorization Code flow requires clientId // Extract OAuth config - for connector specs, check secrets first, then config // For connector specs, OAuth config is always in secrets (encrypted) // Fallback to config for backwards compatibility with legacy connectors const authorizationUrl = secrets.authorizationUrl || config?.authorizationUrl; const clientId = secrets.clientId || config?.clientId; - const scope = secrets.scope || config?.scope; if (!authorizationUrl || !clientId) { throw new Error( 'Connector missing required OAuth configuration (authorizationUrl, clientId)' @@ -139,6 +181,7 @@ export class OAuthAuthorizationService { } return { + authTypeId: 'oauth_authorization_code', authorizationUrl, clientId, scope, @@ -186,4 +229,29 @@ export class OAuthAuthorizationService { return authUrl.toString(); } + + /** + * Builds an EARS authorization URL with PKCE parameters. + * + * EARS uses different parameter names than standard OAuth: + * - `callback_uri` instead of `redirect_uri` + * - `pkce_challenge` instead of `code_challenge` + * - `pkce_method` instead of `code_challenge_method` + * - No `client_id` or `response_type` (EARS manages client credentials) + */ + buildEarsAuthorizationUrl(params: BuildEarsAuthorizationUrlParams): string { + const { baseAuthorizationUrl, scope, callbackUri, state, pkceChallenge } = params; + + const authUrl = new URL(baseAuthorizationUrl); + authUrl.searchParams.set('callback_uri', callbackUri); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('pkce_challenge', pkceChallenge); + authUrl.searchParams.set('pkce_method', 'S256'); + + if (scope) { + authUrl.searchParams.set('scope', scope); + } + + return authUrl.toString(); + } } diff --git a/x-pack/platform/plugins/shared/actions/server/mocks.ts b/x-pack/platform/plugins/shared/actions/server/mocks.ts index 821780cbae4c4..82d1d7bd97756 100644 --- a/x-pack/platform/plugins/shared/actions/server/mocks.ts +++ b/x-pack/platform/plugins/shared/actions/server/mocks.ts @@ -40,6 +40,7 @@ const createSetupMock = () => { getActionsConfigurationUtilities: jest.fn().mockReturnValue({ getAwsSesConfig: jest.fn(), getWebhookSettings: jest.fn(), + getEarsUrl: jest.fn(), }), setEnabledConnectorTypes: jest.fn(), isActionTypeEnabled: jest.fn(), diff --git a/x-pack/platform/plugins/shared/actions/server/routes/index.ts b/x-pack/platform/plugins/shared/actions/server/routes/index.ts index d3aada9bcb97d..059903a4b7ee3 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/index.ts @@ -54,7 +54,7 @@ export function defineRoutes(opts: RouteOptions) { getGlobalExecutionKPIRoute(router, licenseState); getOAuthAccessToken(router, licenseState, actionsConfigUtils); - oauthAuthorizeRoute(router, licenseState, logger, core, oauthRateLimiter); + oauthAuthorizeRoute(router, licenseState, logger, core, oauthRateLimiter, actionsConfigUtils); oauthCallbackRoute(router, licenseState, actionsConfigUtils, logger, core, oauthRateLimiter); oauthCallbackScriptRoute(router); oauthDisconnectRoute(router, licenseState, logger, core); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts index ad366e1572e66..3dbd8b93914c5 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts @@ -113,6 +113,8 @@ describe('oauthAuthorizeRoute', () => { ); }); + const mockActionsConfigUtils = { getEarsUrl: jest.fn().mockReturnValue(undefined) }; + const registerRoute = (coreSetup = createMockCoreSetup()) => { const licenseState = licenseStateMock.create(); oauthAuthorizeRoute( @@ -120,7 +122,8 @@ describe('oauthAuthorizeRoute', () => { licenseState, mockLogger, coreSetup as never, - mockRateLimiter as never + mockRateLimiter as never, + mockActionsConfigUtils as never ); return router.post.mock.calls[0]; }; @@ -382,7 +385,8 @@ describe('oauthAuthorizeRoute', () => { licenseState, mockLogger, createMockCoreSetup() as never, - mockRateLimiter as never + mockRateLimiter as never, + mockActionsConfigUtils as never ); expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts index ce0b5ac886184..ce95e3ecbe429 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import type { IRouter, Logger, CoreSetup } from '@kbn/core/server'; +import { getEarsEndpointsForProvider, resolveEarsUrl } from '../lib/ears'; import type { ILicenseState } from '../lib'; import { INTERNAL_BASE_ACTION_API_PATH } from '../../common'; import type { ActionsRequestHandlerContext } from '../types'; @@ -16,6 +17,7 @@ import { OAuthStateClient } from '../lib/oauth_state_client'; import { OAuthAuthorizationService } from '../lib/oauth_authorization_service'; import type { ActionsPluginsStart } from '../plugin'; import type { OAuthRateLimiter } from '../lib/oauth_rate_limiter'; +import type { ActionsConfigurationUtilities } from '../actions_config'; const paramsSchema = schema.object({ connectorId: schema.string(), @@ -34,7 +36,8 @@ export const oauthAuthorizeRoute = ( licenseState: ILicenseState, logger: Logger, coreSetup: CoreSetup, - oauthRateLimiter: OAuthRateLimiter + oauthRateLimiter: OAuthRateLimiter, + actionsConfigUtils: ActionsConfigurationUtilities ) => { router.post( { @@ -153,14 +156,29 @@ export const oauthAuthorizeRoute = ( createdBy: profile_uid, }); - const authorizationUrl = oauthService.buildAuthorizationUrl({ - baseAuthorizationUrl: oauthConfig.authorizationUrl, - clientId: oauthConfig.clientId, - scope: oauthConfig.scope, - redirectUri, - state: state.state, - codeChallenge, - }); + let authorizationUrl: string; + if (oauthConfig.authTypeId === 'ears') { + const { authorizeEndpoint } = getEarsEndpointsForProvider(oauthConfig.provider); + authorizationUrl = oauthService.buildEarsAuthorizationUrl({ + baseAuthorizationUrl: resolveEarsUrl( + authorizeEndpoint, + actionsConfigUtils.getEarsUrl() + ), + scope: oauthConfig.scope, + callbackUri: redirectUri, + state: state.state, + pkceChallenge: codeChallenge, + }); + } else { + authorizationUrl = oauthService.buildAuthorizationUrl({ + baseAuthorizationUrl: oauthConfig.authorizationUrl, + clientId: oauthConfig.clientId, + scope: oauthConfig.scope, + redirectUri, + state: state.state, + codeChallenge, + }); + } return res.ok({ body: { @@ -175,6 +193,8 @@ export const oauthAuthorizeRoute = ( ? (err as Error & { statusCode: number }).statusCode : 500; + logger.error('Failed to initiate OAuth authorization', { error: err }); + return res.customError({ statusCode, body: { diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts index 5ceae6d3d1cce..d95c87387bfcd 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts @@ -11,6 +11,7 @@ jest.mock('./verify_access_and_context', () => ({ jest.mock('../lib/oauth_state_client'); jest.mock('../lib/user_connector_token_client'); jest.mock('../lib/request_oauth_authorization_code_token'); +jest.mock('../lib/ears/request_ears_token'); import { httpServiceMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { licenseStateMock } from '../lib/license_state.mock'; @@ -20,6 +21,7 @@ import { oauthCallbackRoute } from './oauth_callback'; import { OAuthStateClient } from '../lib/oauth_state_client'; import { UserConnectorTokenClient } from '../lib/user_connector_token_client'; import { requestOAuthAuthorizationCodeToken } from '../lib/request_oauth_authorization_code_token'; +import { requestEarsToken } from '../lib/ears/request_ears_token'; const KIBANA_URL = 'https://kibana.example.com'; @@ -31,6 +33,7 @@ const mockRequestOAuthAuthorizationCodeToken = requestOAuthAuthorizationCodeToken as jest.MockedFunction< typeof requestOAuthAuthorizationCodeToken >; +const mockRequestEarsToken = requestEarsToken as jest.MockedFunction; const configurationUtilities = actionsConfigMock.create(); const mockLogger = loggingSystemMock.create().get(); @@ -383,6 +386,56 @@ describe('oauthCallbackRoute', () => { }); }); + it('uses EARS token exchange when authType is set in config (not secrets)', async () => { + mockOAuthStateClientInstance.get.mockResolvedValue({ + id: 'state-id', + state: 'valid-state', + codeVerifier: 'test-verifier', + connectorId: 'connector-1', + kibanaReturnUrl: 'https://kibana.example.com/app/connectors', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: '2025-01-01T00:10:00.000Z', + createdBy: 'test-profile-uid', + }); + mockEncryptedSavedObjectsClient.getClient.mockReturnValue({ + getDecryptedAsInternalUser: jest.fn().mockResolvedValue({ + attributes: { + config: { authType: 'ears' }, + secrets: { provider: 'test-provider' }, + }, + }), + }); + mockRequestEarsToken.mockResolvedValue({ + tokenType: 'Bearer', + accessToken: 'ears-token', + refreshToken: 'ears-refresh', + expiresIn: 3600, + refreshTokenExpiresIn: 7200, + }); + + const [, handler] = registerRoute(); + const req = httpServerMock.createKibanaRequest({ + query: { code: 'auth-code', state: 'valid-state' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(createMockContext(), req, res); + + expect(mockRequestEarsToken).toHaveBeenCalledWith( + 'test-provider', + mockLogger, + expect.objectContaining({ code: 'auth-code', pkceVerifier: 'test-verifier' }), + configurationUtilities + ); + expect(mockRequestOAuthAuthorizationCodeToken).not.toHaveBeenCalled(); + expect(res.redirected).toHaveBeenCalledWith({ + headers: { + location: expect.stringContaining('oauth_authorization=success'), + }, + }); + }); + it('redirects with error on token exchange failure', async () => { const mockOAuthState = { id: 'state-id', 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 1f778ea5c8592..af7593f88f444 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 @@ -8,7 +8,8 @@ import { schema } from '@kbn/config-schema'; import type { CoreSetup, IRouter, KibanaResponseFactory, Logger } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; -import { capitalize, escape } from 'lodash'; +import { escape } from 'lodash'; +import { OAuthAuthorizationService } from '../lib'; import type { ActionsPluginsStart } from '../plugin'; import type { ILicenseState } from '../lib'; import { @@ -22,8 +23,8 @@ import type { ActionsConfigurationUtilities } from '../actions_config'; import { DEFAULT_ACTION_ROUTE_SECURITY } from './constants'; import { verifyAccessAndContext } from './verify_access_and_context'; import { OAuthStateClient } from '../lib/oauth_state_client'; -import { OAuthAuthorizationService } from '../lib/oauth_authorization_service'; import { requestOAuthAuthorizationCodeToken } from '../lib/request_oauth_authorization_code_token'; +import { requestEarsToken } from '../lib/ears/request_ears_token'; import type { OAuthRateLimiter } from '../lib/oauth_rate_limiter'; import { UserConnectorTokenClient } from '../lib/user_connector_token_client'; @@ -83,6 +84,8 @@ const querySchema = schema.object( ); interface OAuthConnectorSecrets { + authType?: string; + provider?: string; clientId?: string; clientSecret?: string; tokenUrl?: string; @@ -90,6 +93,7 @@ interface OAuthConnectorSecrets { } interface OAuthConnectorConfig { + authType?: string; clientId?: string; tokenUrl?: string; useBasicAuth?: boolean; @@ -384,7 +388,7 @@ export const oauthCallbackRoute = ( 'Handles the OAuth 2.0 authorization code callback from external providers. Exchanges the authorization code for access and refresh tokens.', }), tags: ['oas-tag:connectors'], - // authRequired: true is the default - user must have valid session + // authRequired: true is the default - user must have a valid session // The OAuth redirect happens in their browser, so they will have their session cookie }, validate: { @@ -541,34 +545,54 @@ export const oauthCallbackRoute = ( const config = rawAction.attributes.config; const secrets = rawAction.attributes.secrets; - const clientId = secrets.clientId || config?.clientId; - const clientSecret = secrets.clientSecret; - const tokenUrl = secrets.tokenUrl || config?.tokenUrl; - const useBasicAuth = secrets.useBasicAuth ?? config?.useBasicAuth ?? true; - - if (!clientId || !clientSecret || !tokenUrl) { - throw new Error( - 'Connector missing required OAuth configuration (clientId, clientSecret, tokenUrl)' + const authType = secrets.authType || config?.authType; + + let tokenResult; + if (authType === 'ears') { + const provider = secrets.provider; + if (!provider) { + throw new Error('Connector missing required OAuth configuration (provider)'); + } + + tokenResult = await requestEarsToken( + provider, + logger, + { + code, + pkceVerifier: oauthState.codeVerifier, + }, + configurationUtilities ); - } + } else { + const clientId = secrets.clientId || config?.clientId; + const clientSecret = secrets.clientSecret; + const useBasicAuth = secrets.useBasicAuth ?? config?.useBasicAuth ?? true; + const tokenUrl = secrets.tokenUrl || config?.tokenUrl; + if (!clientId || !clientSecret || !tokenUrl) { + throw new Error( + 'Connector missing required OAuth configuration (clientId, clientSecret, tokenUrl)' + ); + } - const redirectUri = OAuthAuthorizationService.getRedirectUri( - coreStart.http.basePath.publicBaseUrl - ); + // Build the redirect URI (must match the one sent to the authorization endpoint) + const redirectUri = OAuthAuthorizationService.getRedirectUri( + coreStart.http.basePath.publicBaseUrl + ); - const tokenResult = await requestOAuthAuthorizationCodeToken( - tokenUrl, - logger, - { - code, - redirectUri, - codeVerifier: oauthState.codeVerifier, - clientId, - clientSecret, - }, - configurationUtilities, - useBasicAuth - ); + tokenResult = await requestOAuthAuthorizationCodeToken( + tokenUrl, + logger, + { + code, + redirectUri, + codeVerifier: oauthState.codeVerifier, + clientId, + clientSecret, + }, + configurationUtilities, + useBasicAuth + ); + } routeLogger.debug( `Successfully exchanged authorization code for access token for connectorId: ${stateConnectorId}` ); @@ -588,7 +612,7 @@ export const oauthCallbackRoute = ( tokenType: 'access_token', profileUid, }); - const formattedToken = `${capitalize(tokenResult.tokenType)} ${tokenResult.accessToken}`; + const formattedToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; await userConnectorTokenClient.createWithRefreshToken({ connectorId: stateConnectorId, accessToken: formattedToken, diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.test.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.test.ts index fab22e86d860d..47436f529efec 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.test.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.test.ts @@ -40,13 +40,6 @@ describe('usesOAuthAuthorizationCode', () => { expect(usesOAuthAuthorizationCode(connector)).toBe(true); }); - it('returns true when config.auth.type is oauth_authorization_code', () => { - const connector = createConnector({ - config: { auth: { type: 'oauth_authorization_code' } }, - }); - expect(usesOAuthAuthorizationCode(connector)).toBe(true); - }); - it('returns true when authMode is per-user (API-created spec connector)', () => { const connector = createConnector({ authMode: 'per-user', config: {} }); expect(usesOAuthAuthorizationCode(connector)).toBe(true); diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.ts index ae075a43b2485..29e47ced25c80 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.ts @@ -7,10 +7,12 @@ import type { ActionConnector } from '../../types'; +const OAUTH_AUTH_TYPES = new Set(['oauth_authorization_code', 'ears']); + /** - * Checks if a connector uses OAuth Authorization Code flow + * Checks if a connector uses an OAuth Authorization Code flow * @param connector - The connector to check - * @returns True if the connector uses oauth_authorization_code auth type + * @returns True if the connector uses oauth_authorization_code or ears auth type */ export function usesOAuthAuthorizationCode(connector: ActionConnector): boolean { if (!connector || connector.isPreconfigured || connector.isSystemAction) { @@ -18,10 +20,7 @@ export function usesOAuthAuthorizationCode(connector: ActionConnector): boolean } const config = connector.config as Record; + const authType = config?.authType as string; - return ( - config?.authType === 'oauth_authorization_code' || - connector.authMode === 'per-user' || - (config?.auth as Record)?.type === 'oauth_authorization_code' - ); + return OAUTH_AUTH_TYPES.has(authType) || connector.authMode === 'per-user'; }