diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e04025198c0ec..ace41b710c85c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2371,6 +2371,10 @@ 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 +/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 @@ -2382,6 +2386,7 @@ src/platform/packages/shared/kbn-connector-specs/src/specs/amazon_s3/** @elastic src/platform/packages/shared/kbn-connector-specs/src/specs/atlassian/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/brave_search/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/github/** @elastic/workflows-eng +src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/** @elastic/workchat-eng src/platform/packages/shared/kbn-connector-specs/src/specs/greynoise/** @elastic/workflows-eng diff --git a/config/serverless.es.yml b/config/serverless.es.yml index ec585b1b75812..6f9601e905ab6 100644 --- a/config/serverless.es.yml +++ b/config/serverless.es.yml @@ -74,6 +74,10 @@ uiSettings.overrides.defaultRoute: /app/elasticsearch # Specify in telemetry the project type telemetry.labels.serverless: search +# Alerts and LLM config +xpack.actions.enabledActionTypes: + ['.email', '.index', '.slack', '.slack_api', '.jira', '.jira-cloud', '.webhook', '.teams', '.gen-ai', '.bedrock', '.gemini', '.inference', '.mcp', '.notion', '.github', '.gmail', '.google_drive', '.google_calendar', '.sharepoint-online'] + # Customize empty page state for analytics apps no_data_page.analyticsNoDataPageFlavor: 'serverless_search' @@ -111,6 +115,9 @@ xpack.searchQueryRules.enabled: true ## Search Connectors in stack management xpack.contentConnectors.ui.enabled: false +## OAuth with Elastic-owned apps +xpack.actions.ears.url: https://elastic-auth-redirect-service.eu-west-1.aws.svc.qa.elastic.cloud + # Elastic Managed LLMs xpack.actions.preconfigured: Anthropic-Claude-Sonnet-3-7: @@ -293,3 +300,15 @@ xpack.actions.preconfigured: inferenceId: ".google-gemini-3.0-flash-chat_completion" providerConfig: model_id: "google-gemini-3.0-flash" + + +uiSettings: + overrides: + 'workflows:ui:enabled': true + +logging: + loggers: + - name: plugins.actions + level: debug + appenders: [default] + diff --git a/config/serverless.yml b/config/serverless.yml index a05e60b6fdee8..bf801a2fdd5c8 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -294,3 +294,10 @@ xpack.genAiSettings: showAiBreadcrumb: false showSpacesIntegration: false showAiAssistantsVisibilitySetting: false + +# Debug-level logging +logging: + loggers: + - name: plugins.actions + level: debug + appenders: [default] diff --git a/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md b/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md index 5695e4697137d..86f9751b24d62 100644 --- a/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md +++ b/docs/reference/connectors-kibana/_snippets/data-context-sources-connectors-list.md @@ -5,6 +5,7 @@ * [Figma](/reference/connectors-kibana/figma-action-type.md): Browse design files, inspect structure, render nodes as images, and explore team projects in Figma. * [Jina Reader](/reference/connectors-kibana/jina-action-type.md): Convert web pages into markdown from their URL and search the web for better LLM grounding. * [GitHub](/reference/connectors-kibana/github-action-type.md): Search code, issues, and pull requests, and access repository contents and metadata from GitHub. +* [Gmail](/reference/connectors-kibana/gmail-action-type.md): Search and read emails from Gmail. * [Google Calendar](/reference/connectors-kibana/google-calendar-action-type.md): Search and access events and calendars in Google Calendar. * [Google Drive](/reference/connectors-kibana/google-drive-action-type.md): Search and access files and folders in Google Drive. * [Jira Cloud](/reference/connectors-kibana/jira-cloud-action-type.md): Search issues with JQL, retrieve project and issue details, and look up users in Jira Cloud. diff --git a/docs/reference/connectors-kibana/gmail-action-type.md b/docs/reference/connectors-kibana/gmail-action-type.md new file mode 100644 index 0000000000000..da65e2266a71d --- /dev/null +++ b/docs/reference/connectors-kibana/gmail-action-type.md @@ -0,0 +1,58 @@ +--- +navigation_title: "Gmail" +mapped_pages: + - https://www.elastic.co/guide/en/kibana/current/gmail-action-type.html +applies_to: + stack: preview 9.4 + serverless: preview +--- + +# Gmail connector [gmail-action-type] + +The Gmail connector enables searching and reading emails from Gmail via the Gmail API. + +## Create connectors in {{kib}} [define-gmail-ui] + +You can create connectors in **{{stack-manage-app}} > {{connectors-ui}}**. + +### Connector configuration [gmail-connector-configuration] + +Gmail connectors use the following configuration: + +Bearer Token +: A Google OAuth 2.0 access token with Gmail API scopes. See [Get API credentials](#gmail-api-credentials) for instructions. + +## Test connectors [gmail-action-configuration] + +You can test connectors when creating or editing the connector in {{kib}}. The test verifies connectivity by fetching the authenticated user's profile from the Gmail API. + +The Gmail connector has the following actions: + +Search messages +: Search for messages using Gmail search syntax. + - **query** (optional): Gmail search query (e.g. `from:user@example.com`, `is:unread`, `subject:report`, `after:2024/01/01`, `has:attachment`). + - **maxResults** (optional): Maximum number of messages to return (1–100). Defaults to 50. + - **pageToken** (optional): Pagination token from a previous response. + +Get message +: Retrieve a single message by ID with full headers and body. + - **messageId** (required): The ID of the message to retrieve. + - **format** (optional): `minimal` (headers only), `full` (default), or `raw` (RFC 2822). + +List messages +: List messages, optionally filtered by label. + - **maxResults** (optional): Maximum number of messages to return (1–100). Defaults to 50. + - **pageToken** (optional): Pagination token from a previous response. + - **labelIds** (optional): Array of label IDs (e.g. INBOX, SENT). + +## Get API credentials [gmail-api-credentials] + +To use the Gmail connector, you need a Google OAuth 2.0 access token with Gmail API scopes. You can obtain one using the [Google OAuth 2.0 Playground](https://developers.google.com/oauthplayground/): + +1. Open the OAuth 2.0 Playground and ensure **Use your own OAuth credentials** is checked if you have a project. +2. In **Step 1 - Select & authorize APIs**, select the Gmail API v1 scope: `https://www.googleapis.com/auth/gmail.readonly` (or `https://www.googleapis.com/auth/gmail.metadata` for metadata only; use `https://mail.google.com/` for full access). +3. Click **Authorize APIs** and sign in with your Google account. +4. In **Step 2 - Exchange authorization code for tokens**, click **Exchange authorization code for tokens**. +5. Copy the **Access token** and use it as the Bearer token when creating or activating the Gmail data source in Kibana. + +The token expires after a short time (e.g. one hour). For long-lived access, use a refresh token flow or re-authorize as needed. diff --git a/docs/reference/toc.yml b/docs/reference/toc.yml index fc6f8034bcdf9..bb2c4b1ffcc52 100644 --- a/docs/reference/toc.yml +++ b/docs/reference/toc.yml @@ -77,10 +77,11 @@ toc: - file: connectors-kibana/alienvault-otx-action-type.md - file: connectors-kibana/amazon-s3-action-type.md - file: connectors-kibana/brave-search-action-type.md + - file: connectors-kibana/figma-action-type.md - file: connectors-kibana/firecrawl-action-type.md - file: connectors-kibana/github-action-type.md + - file: connectors-kibana/gmail-action-type.md - file: connectors-kibana/google-calendar-action-type.md - - file: connectors-kibana/figma-action-type.md - file: connectors-kibana/google-drive-action-type.md - file: connectors-kibana/greynoise-action-type.md - file: connectors-kibana/jina-action-type.md diff --git a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts index f31a617562a3d..9850543b48382 100644 --- a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts @@ -183,6 +183,7 @@ const previouslyRegisteredTypes = [ 'uptime-dynamic-settings', 'synthetics-dynamic-settings', 'uptime-synthetics-api-key', + 'user_connector_token', 'url', 'usage-counter', // added in 8.16.0: richer mappings, located in .kibana_usage_counters 'usage-counters', // deprecated in favor of 'usage-counter' 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/all_specs.ts b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts index e4d9eb6667ff9..9f952f9db9c79 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts @@ -31,3 +31,4 @@ export * from './specs/firecrawl/firecrawl'; export * from './specs/zoom/zoom'; export * from './specs/zendesk/zendesk'; export * from './specs/amazon_s3/amazon_s3'; +export * from './specs/gmail/gmail'; 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..9e098e81dc697 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 via Elastic-owned apps', +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts b/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts index a6242ede04097..5dcb17b9ace52 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts @@ -147,4 +147,5 @@ export const ConnectorIconsMap: Map< '.amazon_s3', lazy(() => import(/* webpackChunkName: "connectorIconAmazons3" */ './specs/amazon_s3/icon')), ], + ['.gmail', lazy(() => import(/* webpackChunkName: "connectorIconGmail" */ './specs/gmail/icon'))], ]); 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/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.test.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.test.ts new file mode 100644 index 0000000000000..3c6b452daef95 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.test.ts @@ -0,0 +1,28 @@ +/* + * 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 { GmailConnector } from './gmail'; + +describe('GmailConnector', () => { + it('has required metadata', () => { + expect(GmailConnector.metadata.id).toBe('.gmail'); + expect(GmailConnector.metadata.displayName).toBe('Gmail'); + expect(GmailConnector.metadata.supportedFeatureIds).toContain('workflows'); + }); + + it('exposes searchMessages, getMessage, listMessages actions', () => { + expect(GmailConnector.actions.searchMessages).toBeDefined(); + expect(GmailConnector.actions.getMessage).toBeDefined(); + expect(GmailConnector.actions.listMessages).toBeDefined(); + }); + + it('has a test handler', () => { + expect(GmailConnector.test?.handler).toBeDefined(); + }); +}); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.ts new file mode 100644 index 0000000000000..646987681a3eb --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/gmail.ts @@ -0,0 +1,195 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { z } from '@kbn/zod/v4'; +import type { ConnectorSpec } from '../../connector_spec'; + +const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'; +const DEFAULT_MAX_RESULTS = 10; +const MAX_PAGE_SIZE = 100; + +function throwGmailError(error: unknown): void { + const axiosError = error as { + response?: { data?: { error?: { message?: string; code?: number } } }; + }; + const gmailError = axiosError.response?.data?.error; + if (gmailError) { + throw new Error(`Gmail API error (${gmailError.code}): ${gmailError.message ?? 'Unknown'}`); + } +} + +export const GmailConnector: ConnectorSpec = { + metadata: { + id: '.gmail', + displayName: 'Gmail', + description: i18n.translate('core.kibanaConnectorSpecs.gmail.metadata.description', { + defaultMessage: 'Search and read emails from Gmail', + }), + minimumLicense: 'enterprise', + supportedFeatureIds: ['workflows'], + }, + auth: { + types: [ + 'bearer', + { + type: 'ears', + defaults: { + provider: 'google', + scope: 'https://www.googleapis.com/auth/gmail.readonly', + }, + }, + ], + headers: { + Accept: 'application/json', + }, + }, + actions: { + searchMessages: { + isTool: true, + input: z.object({ + query: z + .string() + .optional() + .describe( + 'Gmail search query. Use specific operators to narrow results and avoid large responses: from:, to:, subject:, is:unread, is:read, after:YYYY/MM/DD, before:YYYY/MM/DD, newer_than:Nd, has:attachment. Example: "from:alice@example.com is:unread newer_than:7d". Prefer narrow queries; do not search without filters.' + ), + maxResults: z + .number() + .optional() + .default(DEFAULT_MAX_RESULTS) + .describe( + 'Maximum number of message IDs to return (1-100). Prefer 10-20 to keep context small; increase only if user explicitly needs more.' + ), + pageToken: z.string().optional().describe('Token for pagination from a previous response'), + }), + handler: async (ctx, input) => { + const typedInput = input as { + query?: string; + maxResults?: number; + pageToken?: string; + }; + const params: Record = { + maxResults: Math.min(typedInput.maxResults ?? DEFAULT_MAX_RESULTS, MAX_PAGE_SIZE), + }; + if (typedInput.query) params.q = typedInput.query; + if (typedInput.pageToken) params.pageToken = typedInput.pageToken; + try { + const response = await ctx.client.get(`${GMAIL_API_BASE}/messages`, { params }); + return { + messages: response.data.messages ?? [], + nextPageToken: response.data.nextPageToken, + resultSizeEstimate: response.data.resultSizeEstimate, + }; + } catch (error: unknown) { + throwGmailError(error); + throw error; + } + }, + }, + getMessage: { + isTool: true, + input: z.object({ + messageId: z + .string() + .min(1, { message: 'messageId is required to retrieve a Gmail message' }) + .describe( + 'Required. The Gmail message ID (e.g. from searchMessages or listMessages). Always pass this when calling getMessage.' + ), + format: z + .enum(['minimal', 'full', 'raw']) + .optional() + .default('minimal') + .describe( + 'Message format: use "minimal" (headers only) to save context; use "full" only when the user needs the email body content.' + ), + }), + handler: async (ctx, input) => { + const typedInput = input as { messageId: string; format?: string }; + try { + const response = await ctx.client.get( + `${GMAIL_API_BASE}/messages/${typedInput.messageId}`, + { + params: { format: typedInput.format ?? 'minimal' }, + } + ); + return response.data; + } catch (error: unknown) { + throwGmailError(error); + throw error; + } + }, + }, + listMessages: { + isTool: true, + input: z.object({ + maxResults: z + .number() + .optional() + .default(DEFAULT_MAX_RESULTS) + .describe( + 'Maximum number of message IDs to return (1-100). Prefer 10-20 to keep context small.' + ), + pageToken: z.string().optional().describe('Token for pagination from a previous response'), + labelIds: z + .array(z.string()) + .optional() + .describe('Only return messages with these label IDs (e.g. INBOX, SENT)'), + }), + handler: async (ctx, input) => { + const typedInput = input as { + maxResults?: number; + pageToken?: string; + labelIds?: string[]; + }; + const params: Record = { + maxResults: Math.min(typedInput.maxResults ?? DEFAULT_MAX_RESULTS, MAX_PAGE_SIZE), + }; + if (typedInput.pageToken) params.pageToken = typedInput.pageToken; + if (typedInput.labelIds?.length) params.labelIds = typedInput.labelIds; + try { + const response = await ctx.client.get(`${GMAIL_API_BASE}/messages`, { params }); + return { + messages: response.data.messages ?? [], + nextPageToken: response.data.nextPageToken, + resultSizeEstimate: response.data.resultSizeEstimate, + }; + } catch (error: unknown) { + throwGmailError(error); + throw error; + } + }, + }, + }, + test: { + description: i18n.translate('core.kibanaConnectorSpecs.gmail.test.description', { + defaultMessage: 'Verifies Gmail connection by fetching user profile', + }), + handler: async (ctx) => { + ctx.log.debug('Gmail test handler'); + try { + const response = await ctx.client.get(`${GMAIL_API_BASE}/profile`); + if (response.status !== 200) { + return { ok: false, message: 'Failed to connect to Gmail API' }; + } + return { + ok: true, + message: `Successfully connected to Gmail as ${response.data?.emailAddress ?? 'user'}`, + }; + } catch (error) { + return { + ok: false, + message: `Failed to connect to Gmail API: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + }; + } + }, + }, +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/icon/gmail.png b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/icon/gmail.png new file mode 100644 index 0000000000000..45c5a18875a3a Binary files /dev/null and b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/icon/gmail.png differ diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/icon/index.tsx b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/icon/index.tsx new file mode 100644 index 0000000000000..3d3e88097c6e1 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/gmail/icon/index.tsx @@ -0,0 +1,18 @@ +/* + * 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 React from 'react'; +import { EuiIcon } from '@elastic/eui'; +import type { ConnectorIconProps } from '../../../types'; + +import gmailIcon from './gmail.png'; + +export default (props: ConnectorIconProps) => { + return ; +}; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/google_calendar.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/google_calendar.ts index 9ed8835ae5d9b..ee71a8790b0fe 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/google_calendar.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/google_calendar.ts @@ -50,7 +50,16 @@ export const GoogleCalendar: ConnectorSpec = { supportedFeatureIds: ['workflows', 'agentBuilder'], }, auth: { - types: ['bearer'], + types: [ + 'bearer', + { + type: 'ears', + defaults: { + provider: 'google', + scope: 'https://www.googleapis.com/auth/calendar.readonly', + }, + }, + ], headers: { Accept: 'application/json', }, diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/google_drive.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/google_drive.ts index 3df84872295ae..5f1cd3a0cd5c6 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/google_drive.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/google_drive.ts @@ -56,7 +56,16 @@ export const GoogleDriveConnector: ConnectorSpec = { supportedFeatureIds: ['workflows', 'agentBuilder'], }, auth: { - types: ['bearer'], + types: [ + 'bearer', + { + type: 'ears', + defaults: { + provider: 'google', + scope: 'https://www.googleapis.com/auth/drive.readonly', + }, + }, + ], headers: { Accept: 'application/json', }, 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 e35031be77eb4..4b72c06a44c27 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'; @@ -235,3 +236,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.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..e5b86956a51d5 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_stored_oauth_token_with_refresh.test.ts @@ -0,0 +1,255 @@ +/* + * 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(), +}; + +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' + ); + }); + }); +}); 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..162a6d0711e92 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_stored_oauth_token_with_refresh.ts @@ -0,0 +1,200 @@ +/* + * 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 locks to prevent concurrent token refreshes for the same connector +const tokenRefreshLocks = new Map>(); + +function getOrCreateLock(connectorId: string): ReturnType { + if (!tokenRefreshLocks.has(connectorId)) { + tokenRefreshLocks.set(connectorId, pLimit(1)); + } + return tokenRefreshLocks.get(connectorId)!; +} + +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 for this connector to prevent concurrent token refreshes + const lock = getOrCreateLock(connectorId); + + 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(connectorId); + } + + 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 07252821d582a..5ca50f4188d9a 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,6 +87,7 @@ 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', @@ -112,6 +114,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, @@ -153,7 +156,7 @@ describe('OAuthAuthorizationService', () => { mockActionsClient.get.mockResolvedValue(getResult); 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 ea17d4f04aaff..1350f29624d8f 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 @@ -13,6 +13,7 @@ import { BASE_ACTION_API_PATH } from '../../common'; * OAuth connector secrets stored in encrypted saved objects */ interface OAuthConnectorSecrets { + provider?: string; authorizationUrl?: string; clientId?: string; clientSecret?: string; @@ -35,14 +36,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 */ @@ -55,6 +68,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; @@ -85,17 +109,19 @@ 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): void { - const isOAuthAuthCode = - config?.authType === 'oauth_authorization_code' || - config?.auth?.type === 'oauth_authorization_code'; + private validateOAuthConnector( + config: OAuthConnectorConfig + ): 'oauth_authorization_code' | 'ears' { + const authType = config?.authType || config?.auth?.type; - if (!isOAuthAuthCode) { - throw new Error('Connector does not use OAuth Authorization Code flow'); + if (authType === 'oauth_authorization_code' || authType === 'ears') { + return authType; } + + throw new Error('Connector does not use OAuth Authorization Code or EARS flow'); } /** @@ -111,8 +137,8 @@ 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); + // Validate this is an OAuth connector and get the resolved auth type + const authTypeId = this.validateOAuthConnector(config); // Fetch connector with decrypted secrets const rawAction = @@ -123,13 +149,26 @@ export class OAuthAuthorizationService { ); const secrets = rawAction.attributes.secrets; + 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)' @@ -137,6 +176,7 @@ export class OAuthAuthorizationService { } return { + authTypeId: 'oauth_authorization_code', authorizationUrl, clientId, scope, @@ -184,4 +224,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..8a5d4d0b78430 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( { @@ -91,6 +94,7 @@ export const oauthAuthorizeRoute = ( const [coreStart, { encryptedSavedObjects, spaces }] = await coreSetup.getStartServices(); const kibanaUrl = coreStart.http.basePath.publicBaseUrl; + logger.debug(`Kibana public URL: ${kibanaUrl}`); if (!kibanaUrl) { return res.badRequest({ body: { @@ -153,14 +157,32 @@ 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); + const earsBaseUrl = actionsConfigUtils.getEarsUrl(); + logger.info(`EARS Base URL ${earsBaseUrl}`); + logger.info(`EARS Authorization URL ${authorizeEndpoint}`); + 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 +197,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.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts index 1f778ea5c8592..037f9d42d619e 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; @@ -384,7 +387,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 +544,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; + + 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 +611,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/data_sources/server/sources/gmail/data_type.ts b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/data_type.ts new file mode 100644 index 0000000000000..eec5cdfff515f --- /dev/null +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/data_type.ts @@ -0,0 +1,29 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { DataSource } from '@kbn/data-catalog-plugin'; + +export const gmailDataSource: DataSource = { + id: 'gmail', + name: 'Gmail', + description: i18n.translate('xpack.dataSources.gmail.description', { + defaultMessage: 'Connect to Gmail to search and read your emails.', + }), + iconType: '.gmail', + + stackConnectors: [ + { + type: '.gmail', + config: {}, + }, + ], + + workflows: { + directory: __dirname + '/workflows', + }, +}; diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/index.ts b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/index.ts new file mode 100644 index 0000000000000..7b2a20f5e0308 --- /dev/null +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/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 { gmailDataSource } from './data_type'; diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/get_message.yaml b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/get_message.yaml new file mode 100644 index 0000000000000..ad8b4c9811d18 --- /dev/null +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/get_message.yaml @@ -0,0 +1,23 @@ +version: '1' +name: 'sources.gmail.get_message' +description: 'Retrieve one Gmail message by ID. You must call search or list first to get message IDs, then pass one of those IDs here.' +tags: ['agent-builder-tool'] +enabled: true +triggers: + - type: manual +inputs: + - name: messageId + type: string + required: true + description: "Required. The Gmail message ID to fetch. Get this from searchMessages or listMessages (id field); always pass it when calling get_message." + - name: format + type: string + required: false + description: "Message format: 'minimal' (headers only, recommended) or 'full' (includes body). Use minimal unless user needs email body." +steps: + - name: get_message + type: gmail.getMessage + connector-id: <%= gmail-stack-connector-id %> + with: + messageId: "${{inputs.messageId}}" + format: "${{inputs.format}}" diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/list_messages.yaml b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/list_messages.yaml new file mode 100644 index 0000000000000..f12c745296690 --- /dev/null +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/list_messages.yaml @@ -0,0 +1,23 @@ +version: '1' +name: 'sources.gmail.list_messages' +description: 'List Gmail message IDs by label (e.g. INBOX, SENT). Prefer search when the user has a specific query; limit maxResults (e.g. 10-20).' +tags: ['agent-builder-tool'] +enabled: true +triggers: + - type: manual +inputs: + - name: maxResults + type: number + required: false + description: "Max messages to return (default 10, max 100). Prefer 10-20 to keep context small." + - name: pageToken + type: string + required: false + description: Pagination token from a previous response +steps: + - name: list_messages + type: gmail.listMessages + connector-id: <%= gmail-stack-connector-id %> + with: + maxResults: ${{inputs.maxResults}} + pageToken: "${{inputs.pageToken}}" diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/search.yaml b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/search.yaml new file mode 100644 index 0000000000000..5bcfbe3fbe800 --- /dev/null +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/gmail/workflows/search.yaml @@ -0,0 +1,28 @@ +version: '1' +name: 'sources.gmail.search' +description: 'Search for emails in Gmail. Use a specific query (from:, subject:, is:unread, after:, newer_than:Nd) and limit maxResults (e.g. 10-20) to avoid large responses.' +tags: ['agent-builder-tool'] +enabled: true +triggers: + - type: manual +inputs: + - name: query + type: string + required: false + description: "Gmail search query. Use operators to narrow results: from:user@example.com, to:, subject:, is:unread, is:read, after:2024/01/01, newer_than:7d, has:attachment. Prefer specific queries; do not leave empty or broad." + - name: maxResults + type: number + required: false + description: "Max messages to return (default 10, max 100). Prefer 10-20 to keep context small." + - name: pageToken + type: string + required: false + description: Pagination token from a previous response +steps: + - name: search_messages + type: gmail.searchMessages + connector-id: <%= gmail-stack-connector-id %> + with: + query: "${{inputs.query}}" + maxResults: ${{inputs.maxResults}} + pageToken: "${{inputs.pageToken}}" diff --git a/x-pack/platform/plugins/shared/data_sources/server/sources/index.ts b/x-pack/platform/plugins/shared/data_sources/server/sources/index.ts index 3a5b9c8aa56c2..a01166350fce0 100644 --- a/x-pack/platform/plugins/shared/data_sources/server/sources/index.ts +++ b/x-pack/platform/plugins/shared/data_sources/server/sources/index.ts @@ -21,6 +21,7 @@ import { pagerdutyDataSource } from './pagerduty'; import { servicenowDataSource } from './servicenow'; import { amazonS3DataSource } from './amazon_s3'; import { tavilyDataSource } from './tavily'; +import { gmailDataSource } from './gmail'; export function registerDataSources(dataCatalog: DataCatalogPluginSetup) { dataCatalog.register(figmaDataSource); @@ -39,4 +40,5 @@ export function registerDataSources(dataCatalog: DataCatalogPluginSetup) { dataCatalog.register(pagerdutyDataSource); dataCatalog.register(amazonS3DataSource); dataCatalog.register(tavilyDataSource); + dataCatalog.register(gmailDataSource); } 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 9b2a30a83fe53..b6de251434d2c 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,9 +20,8 @@ export function usesOAuthAuthorizationCode(connector: ActionConnector): boolean } const config = connector.config as Record; + const authType = + (config?.authType as string) || ((config?.auth as Record)?.type as string); - return ( - config?.authType === 'oauth_authorization_code' || - (config?.auth as Record)?.type === 'oauth_authorization_code' - ); + return OAUTH_AUTH_TYPES.has(authType); }