diff --git a/.gitignore b/.gitignore index 3e8687bf80df7..77a0dfcc7b5a2 100644 --- a/.gitignore +++ b/.gitignore @@ -175,8 +175,6 @@ oas_docs/output/kibana.serverless.tmp*.yaml oas_docs/output/kibana.tmp*.yaml oas_docs/output/kibana.new.yaml oas_docs/output/kibana.serverless.new.yaml -oas_docs/bundle.json -oas_docs/bundle.serverless.json .codeql .dependency-graph-log.json diff --git a/oas_docs/bundle.json b/oas_docs/bundle.json index 18a6084b79ae4..2aefb204530bb 100644 --- a/oas_docs/bundle.json +++ b/oas_docs/bundle.json @@ -382,6 +382,74 @@ }, "openapi": "3.0.0", "paths": { + "/api/actions/connector/_oauth_callback": { + "get": { + "description": "Handles the OAuth 2.0 authorization code callback from external providers. Exchanges the authorization code for access and refresh tokens.", + "operationId": "get-actions-connector-oauth-callback", + "parameters": [ + { + "description": "The authorization code returned by the OAuth provider.", + "in": "query", + "name": "code", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The state parameter for CSRF protection.", + "in": "query", + "name": "state", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Error code if the authorization failed.", + "in": "query", + "name": "error", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Human-readable error description.", + "in": "query", + "name": "error_description", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Session state from the OAuth provider (e.g., Microsoft).", + "in": "query", + "name": "session_state", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Returns an HTML page with error details if authorization fails." + }, + "302": { + "description": "Redirects to Kibana on successful authorization." + }, + "401": { + "description": "User is not authenticated." + } + }, + "summary": "Handle OAuth callback", + "tags": [ + "connectors" + ] + } + }, "/api/actions/connector/{id}": { "delete": { "description": "WARNING: When you delete a connector, it cannot be recovered.", diff --git a/oas_docs/bundle.serverless.json b/oas_docs/bundle.serverless.json index 87a19fbd7e200..3bd182ed4f50d 100644 --- a/oas_docs/bundle.serverless.json +++ b/oas_docs/bundle.serverless.json @@ -382,6 +382,74 @@ }, "openapi": "3.0.0", "paths": { + "/api/actions/connector/_oauth_callback": { + "get": { + "description": "Handles the OAuth 2.0 authorization code callback from external providers. Exchanges the authorization code for access and refresh tokens.", + "operationId": "get-actions-connector-oauth-callback", + "parameters": [ + { + "description": "The authorization code returned by the OAuth provider.", + "in": "query", + "name": "code", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The state parameter for CSRF protection.", + "in": "query", + "name": "state", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Error code if the authorization failed.", + "in": "query", + "name": "error", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Human-readable error description.", + "in": "query", + "name": "error_description", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Session state from the OAuth provider (e.g., Microsoft).", + "in": "query", + "name": "session_state", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Returns an HTML page with error details if authorization fails." + }, + "302": { + "description": "Redirects to Kibana on successful authorization." + }, + "401": { + "description": "User is not authenticated." + } + }, + "summary": "Handle OAuth callback", + "tags": [ + "connectors" + ] + } + }, "/api/actions/connector/{id}": { "delete": { "description": "WARNING: When you delete a connector, it cannot be recovered.", diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index df4d07e138467..55646cc4f01b3 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -410,6 +410,61 @@ paths: x-metaTags: - content: Kibana, Elastic Cloud Serverless name: product_name + /api/actions/connector/_oauth_callback: + get: + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/actions/connector/_oauth_callback
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Handles the OAuth 2.0 authorization code callback from external providers. Exchanges the authorization code for access and refresh tokens. + operationId: get-actions-connector-oauth-callback + parameters: + - description: The authorization code returned by the OAuth provider. + in: query + name: code + required: false + schema: + type: string + - description: The state parameter for CSRF protection. + in: query + name: state + required: false + schema: + type: string + - description: Error code if the authorization failed. + in: query + name: error + required: false + schema: + type: string + - description: Human-readable error description. + in: query + name: error_description + required: false + schema: + type: string + - description: Session state from the OAuth provider (e.g., Microsoft). + in: query + name: session_state + required: false + schema: + type: string + responses: + '200': + description: Returns an HTML page with error details if authorization fails. + '302': + description: Redirects to Kibana on successful authorization. + '401': + description: User is not authenticated. + summary: Handle OAuth callback + tags: + - connectors + x-metaTags: + - content: Kibana, Elastic Cloud Serverless + name: product_name /api/actions/connector/{id}: delete: description: |- diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index e1412e4e6a284..b54e0ec44d3b3 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -481,6 +481,61 @@ paths: x-metaTags: - content: Kibana name: product_name + /api/actions/connector/_oauth_callback: + get: + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/actions/connector/_oauth_callback
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. + + Handles the OAuth 2.0 authorization code callback from external providers. Exchanges the authorization code for access and refresh tokens. + operationId: get-actions-connector-oauth-callback + parameters: + - description: The authorization code returned by the OAuth provider. + in: query + name: code + required: false + schema: + type: string + - description: The state parameter for CSRF protection. + in: query + name: state + required: false + schema: + type: string + - description: Error code if the authorization failed. + in: query + name: error + required: false + schema: + type: string + - description: Human-readable error description. + in: query + name: error_description + required: false + schema: + type: string + - description: Session state from the OAuth provider (e.g., Microsoft). + in: query + name: session_state + required: false + schema: + type: string + responses: + '200': + description: Returns an HTML page with error details if authorization fails. + '302': + description: Redirects to Kibana on successful authorization. + '401': + description: User is not authenticated. + summary: Handle OAuth callback + tags: + - connectors + x-metaTags: + - content: Kibana + name: product_name /api/actions/connector/{id}: delete: description: |- diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index 59ece80e710d3..bd9578de252c3 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -951,6 +951,11 @@ "monitoring-telemetry": [ "reportedClusterUuids" ], + "oauth_state": [ + "connectorId", + "expiresAt", + "state" + ], "observability-onboarding-state": [ "progress", "state", diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index 76dcf780c9c36..c194a1e1dcb58 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -3127,6 +3127,20 @@ } } }, + "oauth_state": { + "dynamic": false, + "properties": { + "connectorId": { + "type": "keyword" + }, + "expiresAt": { + "type": "date" + }, + "state": { + "type": "keyword" + } + } + }, "observability-onboarding-state": { "properties": { "progress": { diff --git a/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/connector_token/10.2.0.json b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/connector_token/10.2.0.json new file mode 100644 index 0000000000000..3ffe9305f6591 --- /dev/null +++ b/packages/kbn-check-saved-objects-cli/src/migrations/__fixtures__/connector_token/10.2.0.json @@ -0,0 +1,22 @@ +{ + "10.1.0": [ + { + "connectorId": "abc123-def456-connector-id", + "tokenType": "access_token", + "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImlhdCI6MTUxNjIzOTAyMn0.test_signature", + "expiresAt": "2025-01-15T14:30:00.000Z", + "createdAt": "2025-01-15T13:00:00.000Z", + "updatedAt": "2025-01-15T13:00:00.000Z" + } + ], + "10.2.0": [ + { + "connectorId": "abc123-def456-connector-id", + "tokenType": "access_token", + "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3QgVXNlciIsImlhdCI6MTUxNjIzOTAyMn0.test_signature", + "expiresAt": "2025-01-15T14:30:00.000Z", + "createdAt": "2025-01-15T13:00:00.000Z", + "updatedAt": "2025-01-15T13:00:00.000Z" + } + ] +} diff --git a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts index a9f3af4f604ce..ce0041b74ed52 100644 --- a/src/core/packages/saved-objects/server-internal/src/object_types/index.ts +++ b/src/core/packages/saved-objects/server-internal/src/object_types/index.ts @@ -11,4 +11,4 @@ export { registerCoreObjectTypes } from './registration'; // set minimum number of registered saved objects to ensure no object types are removed after 8.8 // declared in internal implementation explicitly to prevent unintended changes. -export const SAVED_OBJECT_TYPES_COUNT = 145 as const; +export const SAVED_OBJECT_TYPES_COUNT = 146 as const; diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 17c63173b938d..25c65fa4081ef 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -89,7 +89,7 @@ describe('checking migration metadata changes on all registered SO types', () => "cloud-security-posture-settings": "86ac8eba7ea80c15ab48bf424f7c15f1d935774f84ff32c5c1e04c95c67e72b6", "config": "d368f46c1a9920560f23b0d2793379995ee24bbff8e0438661bc3d9f528f4947", "config-global": "e306e70563eb2f3204aff45506f3ca1c3c67461971a45fba471c72bd8eb4435b", - "connector_token": "6e14114a9a7ca64becf611b7db23d59438cd332fd5228fbf7c6cc2144e0459d0", + "connector_token": "04838c9e72fae94ee65cd2e0b7ac9acf883506c390304c5b02626961905d1656", "core-usage-stats": "0e76782718b2eef6104d2ddec46ed356339220997ad8da8d4f86433c3b46f77a", "csp-rule-template": "6fec028650a7aa0ef5a07d3d2906c32c2505da013adb2e51fa0e66bbf0ff70c5", "dashboard": "3cfab5e285c531092dd9abed38565e6e048b843d75ba9a6c1f5c3017a42db0c6", @@ -150,6 +150,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ml-module": "cb77705b41ea0a35d8ba79b19014a30069e0e93a2cfb7ae8c6f20a79207d5daa", "ml-trained-model": "133305438dc0b60a6660c44f0d8183ad5ba079db8fdd4e4f4b5ab3a09d2f29b8", "monitoring-telemetry": "fa7c4f2a099b4f0539e571372a598601c2a0c65ba50f6c34df23b4d6925cdc53", + "oauth_state": "9b204196198cd491014364003e68be49ecc193996dca38a49ac685402b757e96", "observability-onboarding-state": "b656db675800bfee8a2ddb5bf73b543542c7a7db64ed268ab5adcae6910773d2", "osquery-manager-usage-metric": "8833b9f812e9179897444c395761f9911945cfb77de9869c4e9b6ee6eeb0f573", "osquery-pack": "7ee940ea04c9c562406977efaa213050b2079c5bcc4e06ec56c8be6a85eb5ccd", @@ -531,6 +532,7 @@ describe('checking migration metadata changes on all registered SO types', () => "connector_token|global: 9c6972571df6c56a0d542bdca734a55a7a3859e5", "connector_token|mappings: 8c3f381518f3a37955cc7a434e72a81c11e28f1c", "connector_token|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "connector_token|10.2.0: 796867ae86b9d9eb58eb4647bc61fdb58fbeca3024cf4ff556f18e34b7b70060", "connector_token|10.1.0: ab76db77c61eebd5d731958f792a26402d8c216ed20de984c25f17bd0b59b784", "========================================================================================", "core-usage-stats|global: 0f6c6a66d1ec3ccababd15890476caf7f6f57700", @@ -988,6 +990,11 @@ describe('checking migration metadata changes on all registered SO types', () => "monitoring-telemetry|mappings: 400d044dff084cdfc58f413f5628494343bae327", "monitoring-telemetry|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", "======================================================================", + "oauth_state|global: a665293b001c9e7b6e92c0a50a553b8163dbcd41", + "oauth_state|mappings: 04721e2fa836fed1f3f2e9c343d96ec5304f8f09", + "oauth_state|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", + "oauth_state|10.1.0: 5e73676664142f84718b9e578df9d703e47a096a7e12137c166499438b1c6677", + "====================================================================================", "observability-onboarding-state|global: c226ba4dd0412c2d7fd7a01976461e9da00b78bf", "observability-onboarding-state|mappings: d6efe91e6efcc5e1b41fac37b731b715182939ce", "observability-onboarding-state|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", @@ -1347,7 +1354,7 @@ describe('checking migration metadata changes on all registered SO types', () => "cloud-security-posture-settings": "10.1.0", "config": "10.2.0", "config-global": "10.0.0", - "connector_token": "10.1.0", + "connector_token": "10.2.0", "core-usage-stats": "10.0.0", "csp-rule-template": "10.0.0", "dashboard": "10.3.0", @@ -1408,6 +1415,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ml-module": "10.0.0", "ml-trained-model": "10.0.0", "monitoring-telemetry": "10.0.0", + "oauth_state": "10.1.0", "observability-onboarding-state": "10.2.0", "osquery-manager-usage-metric": "10.0.0", "osquery-pack": "10.1.0", @@ -1502,7 +1510,7 @@ describe('checking migration metadata changes on all registered SO types', () => "cloud-security-posture-settings": "10.1.0", "config": "10.2.0", "config-global": "0.0.0", - "connector_token": "10.1.0", + "connector_token": "10.2.0", "core-usage-stats": "7.14.1", "csp-rule-template": "8.7.0", "dashboard": "10.3.0", @@ -1563,6 +1571,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ml-module": "7.10.0", "ml-trained-model": "7.10.0", "monitoring-telemetry": "0.0.0", + "oauth_state": "10.1.0", "observability-onboarding-state": "10.2.0", "osquery-manager-usage-metric": "0.0.0", "osquery-pack": "10.1.0", 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 47495cb8709a2..40774601b27c0 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 @@ -121,6 +121,7 @@ const previouslyRegisteredTypes = [ 'ml-module', 'ml-telemetry', 'monitoring-telemetry', + 'oauth_state', 'observability-onboarding-state', 'osquery-pack', 'osquery-pack-asset', 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 1e775f3b059de..e89829a2b227d 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 @@ -12,6 +12,7 @@ export * from './auth_types/bearer'; export * from './auth_types/basic'; export * from './auth_types/none'; export * from './auth_types/oauth'; +export * from './auth_types/oauth_authorization_code'; // Skipping PFX and CRT exports for now as they will require updates to // the formbuilder to support file upload fields. diff --git a/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth_authorization_code.ts b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth_authorization_code.ts new file mode 100644 index 0000000000000..1d1962e62bd0f --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/oauth_authorization_code.ts @@ -0,0 +1,116 @@ +/* + * 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'; + +const authSchema = z + .object({ + authorizationUrl: z.url().meta({ label: i18n.OAUTH_AUTHORIZATION_URL_LABEL }), + tokenUrl: z.url().meta({ label: i18n.OAUTH_TOKEN_URL_LABEL }), + clientId: z + .string() + .min(1, { message: i18n.OAUTH_CLIENT_ID_REQUIRED_MESSAGE }) + .meta({ label: i18n.OAUTH_CLIENT_ID_LABEL }), + scope: z.string().meta({ label: i18n.OAUTH_SCOPE_LABEL }).optional(), + clientSecret: z + .string() + .min(1, { message: i18n.OAUTH_CLIENT_SECRET_REQUIRED_MESSAGE }) + .meta({ label: i18n.OAUTH_CLIENT_SECRET_LABEL, sensitive: true }), + useBasicAuth: z.boolean().default(true).optional().meta({ + hidden: true, // Hidden from UI - uses connector spec defaults + }), + }) + .meta({ label: i18n.OAUTH_AUTHORIZATION_CODE_LABEL }); + +type AuthSchemaType = z.infer; + +/** + * OAuth2 Authorization Code Flow with PKCE + * + * This is a generic, reusable auth type for any OAuth2 provider that supports the + * Authorization Code flow (Microsoft, Google, Salesforce, Slack, etc.). + * + * ## How it works: + * 1. User clicks the "Authorize" button in the connector UI + * 2. _start_oauth_flow route generates PKCE parameters and returns the provider's authorization URL + * 3. UI opens the authorization URL where user authorizes the app + * 4. Provider redirects back to the _oauth_callback route with the authorization code + * 5. Callback route exchanges code for access/refresh tokens and stores them + * 6. Tokens are auto-refreshed when expired during connector execution + * + * ## Usage in connector specs: + * Different providers use different OAuth endpoints - specify these in your connector's auth defaults: + * + * ```typescript + * // Example: Microsoft/SharePoint + * auth: { + * types: [{ + * type: 'oauth_authorization_code', + * defaults: { + * authorizationUrl: 'https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize', + * tokenUrl: 'https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token', + * scope: 'https://graph.microsoft.com/.default offline_access' + * } + * }] + * } + * + * // Example: Google Drive + * auth: { + * types: [{ + * type: 'oauth_authorization_code', + * defaults: { + * authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + * tokenUrl: 'https://oauth2.googleapis.com/token', + * scope: 'https://www.googleapis.com/auth/drive.readonly' + * } + * }] + * } + * ``` + * + * Users will fill in their client ID, client secret, and can customize URLs/scopes if needed. + * The _start_oauth_flow and _oauth_callback routes are generic and work with any provider. + */ +export const OAuthAuthorizationCode: AuthTypeSpec = { + id: 'oauth_authorization_code', + schema: authSchema, + configure: async ( + ctx: AuthContext, + axiosInstance: AxiosInstance, + secret: AuthSchemaType + ): Promise => { + // For authorization code flow, tokens are managed separately via callback routes + // The getToken() method will retrieve already-stored tokens and auto-refresh if needed + // For this auth spec, getToken() calls getOAuthAuthorizationCodeAccessToken() + let token; + try { + token = await ctx.getToken({ + tokenUrl: secret.tokenUrl, + scope: secret.scope, + clientId: secret.clientId, + clientSecret: secret.clientSecret, + }); + } 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/translations.ts b/src/platform/packages/shared/kbn-connector-specs/src/auth_types/translations.ts index 6ea633f563d8c..c2975aea9ad5f 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 @@ -117,6 +117,20 @@ export const OAUTH_CLIENT_SECRET_REQUIRED_MESSAGE = i18n.translate( } ); +export const OAUTH_AUTHORIZATION_CODE_LABEL = i18n.translate( + 'connectorSpecs.oauthAuthorizationCode.label', + { + defaultMessage: 'OAuth2 Authorization Code', + } +); + +export const OAUTH_AUTHORIZATION_URL_LABEL = i18n.translate( + 'connectorSpecs.oauthAuthorizationUrl.label', + { + defaultMessage: 'Authorization URL', + } +); + export const CRT_AUTH_LABEL = i18n.translate('connectorSpecs.crt.label', { defaultMessage: 'SSL CRT and Key authentication', }); diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/notion/notion.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/notion/notion.ts index bccee2df15f1b..46f59ea7eb191 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/specs/notion/notion.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/notion/notion.ts @@ -22,7 +22,17 @@ export const NotionConnector: ConnectorSpec = { }, auth: { - types: ['bearer'], + types: [ + 'bearer', + { + type: 'oauth_authorization_code', + defaults: { + authorizationUrl: 'https://api.notion.com/v1/oauth/authorize', + tokenUrl: 'https://api.notion.com/v1/oauth/token', + useBasicAuth: true, // Notion requires HTTP Basic Auth for client credentials + }, + }, + ], headers: { 'Notion-Version': '2025-09-03', }, diff --git a/x-pack/platform/plugins/private/logstash/public/application/components/pipeline_list/pipeline_list.js b/x-pack/platform/plugins/private/logstash/public/application/components/pipeline_list/pipeline_list.js index 12c864e44b29f..91a2ff2ce0fdb 100644 --- a/x-pack/platform/plugins/private/logstash/public/application/components/pipeline_list/pipeline_list.js +++ b/x-pack/platform/plugins/private/logstash/public/application/components/pipeline_list/pipeline_list.js @@ -171,6 +171,7 @@ class PipelineListUi extends React.Component { const { isForbidden, isLoading } = this.state; return isForbidden && !isLoading ? ( { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, }); const localActionTypeRegistryParams = { diff --git a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts index 8a560b9d7d3db..923225d580488 100644 --- a/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/actions_client/actions_client.ts @@ -58,6 +58,7 @@ import type { ActionsAuthorization } from '../authorization/actions_authorizatio import { connectorAuditEvent, ConnectorAuditAction } from '../lib/audit_events'; import type { ActionsConfigurationUtilities } from '../actions_config'; import type { + OAuthAuthorizationCodeParams, OAuthClientCredentialsParams, OAuthJwtParams, OAuthParams, @@ -69,6 +70,11 @@ import type { GetOAuthClientCredentialsSecrets, } from '../lib/get_oauth_client_credentials_access_token'; import { getOAuthClientCredentialsAccessToken } from '../lib/get_oauth_client_credentials_access_token'; +import { + getOAuthAuthorizationCodeAccessToken, + type GetOAuthAuthorizationCodeConfig, + type GetOAuthAuthorizationCodeSecrets, +} from '../lib/get_oauth_authorization_code_access_token'; import { ACTION_FILTER, formatExecutionKPIResult, @@ -420,6 +426,32 @@ export class ActionsClient { ); throw Boom.badRequest(`Failed to retrieve access token`); } + } else if (type === 'authorization_code') { + const tokenOpts = options as OAuthAuthorizationCodeParams; + try { + accessToken = await getOAuthAuthorizationCodeAccessToken({ + connectorId: tokenOpts.connectorId, + logger: this.context.logger, + configurationUtilities, + credentials: { + config: tokenOpts.config as GetOAuthAuthorizationCodeConfig, + secrets: tokenOpts.secrets as GetOAuthAuthorizationCodeSecrets, + }, + connectorTokenClient: this.context.connectorTokenClient, + scope: tokenOpts.scope, + }); + + this.context.logger.debug( + () => + `Successfully retrieved access token using Authorization Code OAuth for connector ${tokenOpts.connectorId} with tokenUrl ${tokenOpts.tokenUrl}` + ); + } catch (err) { + this.context.logger.debug( + () => + `Failed to retrieve access token using Authorization Code OAuth for connector ${tokenOpts.connectorId} with tokenUrl ${tokenOpts.tokenUrl} - ${err.message}` + ); + throw Boom.badRequest(`Failed to retrieve access token`); + } } return { accessToken }; 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 f926424fb15cd..bb52d10a62b69 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 @@ -42,6 +42,10 @@ const defaultActionsConfig: ActionsConfig = { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, }; describe('ensureUriAllowed', () => { diff --git a/x-pack/platform/plugins/shared/actions/server/config.test.ts b/x-pack/platform/plugins/shared/actions/server/config.test.ts index a376702a660c5..88dbe0acc502f 100644 --- a/x-pack/platform/plugins/shared/actions/server/config.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/config.test.ts @@ -35,6 +35,16 @@ describe('config validation', () => { "microsoftExchangeUrl": "https://login.microsoftonline.com", "microsoftGraphApiScope": "https://graph.microsoft.com/.default", "microsoftGraphApiUrl": "https://graph.microsoft.com/v1.0", + "oAuthRateLimit": Object { + "authorize": Object { + "limit": 100, + "lookbackWindow": "1h", + }, + "callback": Object { + "limit": 100, + "lookbackWindow": "1h", + }, + }, "preconfigured": Object {}, "preconfiguredAlertHistoryEsIndex": false, "responseTimeout": "PT1M", @@ -69,6 +79,16 @@ describe('config validation', () => { "microsoftExchangeUrl": "https://login.microsoftonline.com", "microsoftGraphApiScope": "https://graph.microsoft.com/.default", "microsoftGraphApiUrl": "https://graph.microsoft.com/v1.0", + "oAuthRateLimit": Object { + "authorize": Object { + "limit": 100, + "lookbackWindow": "1h", + }, + "callback": Object { + "limit": 100, + "lookbackWindow": "1h", + }, + }, "preconfigured": Object { "mySlack1": Object { "actionTypeId": ".slack", @@ -212,6 +232,16 @@ describe('config validation', () => { "microsoftExchangeUrl": "https://login.microsoftonline.com", "microsoftGraphApiScope": "https://graph.microsoft.com/.default", "microsoftGraphApiUrl": "https://graph.microsoft.com/v1.0", + "oAuthRateLimit": Object { + "authorize": Object { + "limit": 100, + "lookbackWindow": "1h", + }, + "callback": Object { + "limit": 100, + "lookbackWindow": "1h", + }, + }, "preconfigured": Object {}, "preconfiguredAlertHistoryEsIndex": false, "responseTimeout": "PT1M", @@ -382,6 +412,16 @@ describe('config validation', () => { "microsoftExchangeUrl": "https://login.microsoftonline.com", "microsoftGraphApiScope": "https://graph.microsoft.com/.default", "microsoftGraphApiUrl": "https://graph.microsoft.com/v1.0", + "oAuthRateLimit": Object { + "authorize": Object { + "limit": 100, + "lookbackWindow": "1h", + }, + "callback": Object { + "limit": 100, + "lookbackWindow": "1h", + }, + }, "preconfigured": Object {}, "preconfiguredAlertHistoryEsIndex": false, "rateLimiter": Object { diff --git a/x-pack/platform/plugins/shared/actions/server/config.ts b/x-pack/platform/plugins/shared/actions/server/config.ts index 4e263eb5c9730..55a4b9e958a16 100644 --- a/x-pack/platform/plugins/shared/actions/server/config.ts +++ b/x-pack/platform/plugins/shared/actions/server/config.ts @@ -48,8 +48,8 @@ const connectorTypeSchema = schema.object({ maxAttempts: schema.maybe(schema.number({ min: MIN_MAX_ATTEMPTS, max: MAX_MAX_ATTEMPTS })), }); -// We leverage enabledActionTypes list by allowing the other plugins to overwrite it by using "setEnabledConnectorTypes" in the plugin setup. -// The list can be overwritten only if it's not already been set in the config. +// We leverage the enabledActionTypes list by allowing the other plugins to overwrite it by using "setEnabledConnectorTypes" in the plugin setup. +// The list can be overwritten only if it has not already been set in the config. const enabledConnectorTypesSchema = schema.arrayOf( schema.oneOf([schema.string(), schema.literal(EnabledActionTypes.Any)]), { @@ -73,6 +73,17 @@ const rateLimiterSchema = schema.recordOf( }) ); +const oAuthRateLimitSchema = schema.object({ + authorize: schema.object({ + lookbackWindow: schema.string({ defaultValue: '1h', validate: validateDuration }), + limit: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + }), + callback: schema.object({ + lookbackWindow: schema.string({ defaultValue: '1h', validate: validateDuration }), + limit: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + }), +}); + export const configSchema = schema.object({ allowedHosts: schema.arrayOf( schema.oneOf([schema.string({ hostname: true }), schema.literal(AllowedHosts.Any)]), @@ -200,11 +211,13 @@ export const configSchema = schema.object({ }) ), rateLimiter: schema.maybe(rateLimiterSchema), + oAuthRateLimit: oAuthRateLimitSchema, }); export type ActionsConfig = TypeOf; export type EnabledConnectorTypes = TypeOf; export type ConnectorRateLimiterConfig = TypeOf; +export type OAuthRateLimiterConfig = TypeOf; // It would be nicer to add the proxyBypassHosts / proxyOnlyHosts restriction on // simultaneous usage in the config validator directly, but there's no good way to express diff --git a/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts b/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts index 9064b6e71b84f..62143fc239052 100644 --- a/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts +++ b/x-pack/platform/plugins/shared/actions/server/constants/saved_objects.ts @@ -9,3 +9,4 @@ export const ACTION_SAVED_OBJECT_TYPE = 'action'; export const ALERT_SAVED_OBJECT_TYPE = 'alert'; export const ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE = 'action_task_params'; export const CONNECTOR_TOKEN_SAVED_OBJECT_TYPE = 'connector_token'; +export const OAUTH_STATE_SAVED_OBJECT_TYPE = 'oauth_state'; diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_connection.test.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_connection.test.ts index 52e257ae27acb..f19eb3b55506c 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_connection.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_connection.test.ts @@ -716,6 +716,10 @@ const BaseActionsConfig: ActionsConfig = { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, }; function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_proxy.test.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_proxy.test.ts index 6f5dbb34d07e9..adb8cf8c445aa 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_proxy.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/axios_utils_proxy.test.ts @@ -596,6 +596,10 @@ const BaseActionsConfig: ActionsConfig = { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, }; function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { diff --git a/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts b/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts index 2a4eec79f2c37..d805da65be50f 100644 --- a/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/integration_tests/get_axios_instance_connection.test.ts @@ -795,6 +795,10 @@ const BaseActionsConfig: ActionsConfig = { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, }; function getACUfromConfig(config: Partial = {}): ActionsConfigurationUtilities { diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts index 5990437b48159..e22d5f326d89f 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.mock.ts @@ -15,6 +15,8 @@ const createConnectorTokenClientMock = () => { update: jest.fn(), deleteConnectorTokens: jest.fn(), updateOrReplace: jest.fn(), + createWithRefreshToken: jest.fn(), + updateWithRefreshToken: jest.fn(), }; return mocked; }; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts index 308ee75ea5bf8..295709228d2ce 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/connector_token_client.ts @@ -25,14 +25,14 @@ interface ConstructorOptions { interface CreateOptions { connectorId: string; token: string; - expiresAtMillis: string; + expiresAtMillis?: string; tokenType?: string; } export interface UpdateOptions { id: string; token: string; - expiresAtMillis: string; + expiresAtMillis?: string; tokenType?: string; } @@ -40,10 +40,11 @@ interface UpdateOrReplaceOptions { connectorId: string; token: ConnectorToken | null; newToken: string; - expiresInSec: number; + expiresInSec?: number; tokenRequestDate: number; deleteExisting: boolean; } + export class ConnectorTokenClient { private readonly logger: Logger; private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; @@ -113,10 +114,12 @@ export class ConnectorTokenClient { try { const updateOperation = () => { + // Exclude id from attributes since it's saved object metadata, not document data + const { id: _id, ...attributesWithoutId } = attributes; return this.unsecuredSavedObjectsClient.create( CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, { - ...attributes, + ...attributesWithoutId, token, expiresAt: expiresAtMillis, tokenType: tokenType ?? 'access_token', @@ -214,7 +217,10 @@ export class ConnectorTokenClient { return { hasErrors: true, connectorToken: null }; } - if (isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt))) { + if ( + connectorTokensResult[0].attributes.expiresAt && + isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt)) + ) { this.logger.error( `Failed to get connector_token for connectorId "${connectorId}" and tokenType: "${ tokenType ?? 'access_token' @@ -291,11 +297,140 @@ export class ConnectorTokenClient { }); } else { await this.update({ - id: token.id!.toString(), + id: token.id!, token: newToken, expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), tokenType: 'access_token', }); } } + + /** + * Create new token with refresh token support + */ + public async createWithRefreshToken({ + connectorId, + accessToken, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + }: { + connectorId: string; + accessToken: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + }): Promise { + const id = SavedObjectsUtils.generateId(); + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + try { + const result = await this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + omitBy( + { + connectorId, + token: accessToken, + refreshToken, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: refreshTokenExpiresInMillis, + tokenType: tokenType ?? 'access_token', + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + }, + isUndefined + ), + { id } + ); + + return result.attributes as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to create connector_token with refresh token for connectorId "${connectorId}". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Update token with refresh token + */ + public async updateWithRefreshToken({ + id, + token, + refreshToken, + expiresIn, + refreshTokenExpiresIn, + tokenType, + }: { + id: string; + token: string; + refreshToken?: string; + expiresIn?: number; + refreshTokenExpiresIn?: number; + tokenType?: string; + }): Promise { + const { attributes, references, version } = + await this.unsecuredSavedObjectsClient.get( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + id + ); + const now = Date.now(); + const expiresInMillis = expiresIn ? new Date(now + expiresIn * 1000).toISOString() : undefined; + const refreshTokenExpiresInMillis = refreshTokenExpiresIn + ? new Date(now + refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + try { + const updateOperation = () => { + // Exclude id from attributes since it's saved object metadata, not document data + const { id: _id, ...attributesWithoutId } = attributes; + return this.unsecuredSavedObjectsClient.create( + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + omitBy( + { + ...attributesWithoutId, + token, + refreshToken: refreshToken ?? attributes.refreshToken, + expiresAt: expiresInMillis, + refreshTokenExpiresAt: + refreshTokenExpiresInMillis ?? attributes.refreshTokenExpiresAt, + tokenType: tokenType ?? 'access_token', + updatedAt: new Date(now).toISOString(), + }, + isUndefined + ) as ConnectorToken, + omitBy( + { + id, + overwrite: true, + references, + version, + }, + isUndefined + ) + ); + }; + + const result = await retryIfConflicts( + this.logger, + `accessToken.updateWithRefreshToken('${id}')`, + updateOperation, + MAX_RETRY_ATTEMPTS + ); + + return result.attributes as ConnectorToken; + } catch (err) { + this.logger.error( + `Failed to update connector_token with refresh token for id "${id}". Error: ${err.message}` + ); + throw err; + } + } } diff --git a/x-pack/platform/plugins/shared/actions/server/lib/custom_host_settings.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/custom_host_settings.test.ts index 440f0a8f9d0bc..7cff7f412e830 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/custom_host_settings.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/custom_host_settings.test.ts @@ -80,6 +80,10 @@ describe('custom_host_settings', () => { microsoftGraphApiUrl: DEFAULT_MICROSOFT_GRAPH_API_URL, microsoftGraphApiScope: DEFAULT_MICROSOFT_GRAPH_API_SCOPE, microsoftExchangeUrl: DEFAULT_MICROSOFT_EXCHANGE_URL, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, }; test('ensure it copies over the config parts that it does not touch', () => { 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 55ae89fc801f8..009b8b25a3a51 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 @@ -16,6 +16,7 @@ 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'; export type ConnectorInfo = Omit; @@ -28,6 +29,85 @@ 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, +}: { + error: AxiosErrorWithRetry; + connectorId: string; + secrets: OAuth2AuthCodeParams; + connectorTokenClient: ConnectorTokenClientContract; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + axiosInstance: AxiosInstance; +}): 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, + 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; @@ -98,9 +178,56 @@ export const getAxiosInstanceWithAuth = ({ axiosInstance.interceptors.response.use(onFulfilled, onRejected); } + // Add a response interceptor to handle 401 errors for OAuth authz code grant connectors + if (authTypeId === 'oauth_authorization_code' && connectorTokenClient) { + axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + return handleOAuth401Error({ + error, + connectorId, + secrets: secrets as OAuth2AuthCodeParams, + connectorTokenClient, + logger, + configurationUtilities, + axiosInstance, + }); + } + return Promise.reject(error); + } + ); + } + 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, + }); + } + + // For client credentials flow, request new token each time return await getOAuthClientCredentialsAccessToken({ connectorId, logger, diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_gcp_oauth_access_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_gcp_oauth_access_token.ts index ca2d0de2f0ea5..34ec21f3953cd 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_gcp_oauth_access_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_gcp_oauth_access_token.ts @@ -40,7 +40,10 @@ export const getGoogleOAuthJwtAccessToken = async ({ } } - if (!connectorToken || Date.parse(connectorToken.expiresAt) <= Date.now()) { + if ( + !connectorToken || + (connectorToken.expiresAt ? Date.parse(connectorToken.expiresAt) <= Date.now() : false) + ) { const requestTokenStart = Date.now(); // Request access token with service account credentials file 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 new file mode 100644 index 0000000000000..ff2b8d3a7eb41 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.ts @@ -0,0 +1,165 @@ +/* + * 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 pLimit from 'p-limit'; +import type { Logger } from '@kbn/core/server'; +import type { ActionsConfigurationUtilities } from '../actions_config'; +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)!; +} + +export interface GetOAuthAuthorizationCodeConfig { + clientId: string; + tokenUrl: string; + additionalFields?: Record; + useBasicAuth?: boolean; +} + +export interface GetOAuthAuthorizationCodeSecrets { + clientSecret: string; +} + +interface GetOAuthAuthorizationCodeAccessTokenOpts { + connectorId: string; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + credentials: { + config: GetOAuthAuthorizationCodeConfig; + secrets: GetOAuthAuthorizationCodeSecrets; + }; + connectorTokenClient: ConnectorTokenClientContract; + scope?: 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 OAuth2 Authorization Code flow + * Automatically refreshes expired tokens using the refresh token + */ +export const getOAuthAuthorizationCodeAccessToken = async ({ + connectorId, + logger, + configurationUtilities, + credentials, + connectorTokenClient, + scope, + forceRefresh = false, +}: GetOAuthAuthorizationCodeAccessTokenOpts): Promise => { + const { clientId, tokenUrl, additionalFields, useBasicAuth } = credentials.config; + const { clientSecret } = credentials.secrets; + + if (!clientId || !clientSecret) { + logger.warn(`Missing required fields for requesting OAuth Authorization Code access token`); + return null; + } + + // 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 } = 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; + + if (!forceRefresh && expiresAt > now) { + // Token still valid + logger.debug(`Using stored access token for connectorId: ${connectorId}`); + return connectorToken.token; + } + + // Access token expired - attempt refresh + if (!connectorToken.refreshToken) { + 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( + tokenUrl, + logger, + { + refreshToken: connectorToken.refreshToken, + clientId, + clientSecret, + scope, + ...additionalFields, + }, + configurationUtilities, + shouldUseBasicAuth + ); + + const newAccessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + + // Update stored token + await connectorTokenClient.updateWithRefreshToken({ + id: connectorToken.id!, + token: newAccessToken, + refreshToken: tokenResult.refreshToken || connectorToken.refreshToken, + 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_oauth_client_credentials_access_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_client_credentials_access_token.ts index 8d48e7560c791..8452b7ef9d53e 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_client_credentials_access_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_client_credentials_access_token.ts @@ -61,7 +61,10 @@ export const getOAuthClientCredentialsAccessToken = async ({ hasErrors = errors; } - if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + if ( + connectorToken === null || + (connectorToken.expiresAt ? Date.parse(connectorToken.expiresAt) <= Date.now() : false) + ) { // Save the time before requesting token so we can use it to calculate expiration const requestTokenStart = Date.now(); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_jwt_access_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_jwt_access_token.ts index 06791ac8543a2..20f0b21933f27 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_jwt_access_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_jwt_access_token.ts @@ -63,7 +63,10 @@ export const getOAuthJwtAccessToken = async ({ hasErrors = errors; } - if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + if ( + connectorToken === null || + (connectorToken.expiresAt ? Date.parse(connectorToken.expiresAt) <= Date.now() : false) + ) { // generate a new assertion const assertion = createJWTAssertion(logger, privateKey, privateKeyPassword, { audience: clientId, diff --git a/x-pack/platform/plugins/shared/actions/server/lib/index.ts b/x-pack/platform/plugins/shared/actions/server/lib/index.ts index 58f966cfc7b5b..d2fd2a34a9752 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/index.ts @@ -44,3 +44,5 @@ export type { TelemetryMetadata } from './token_tracking/gen_ai_token_tracking'; export { formatZodError } from './format_zod_error'; export { createConnectorTypeFromSpec } from './single_file_connectors/create_connector_from_spec'; export { getDeleteTokenAxiosInterceptor } from './delete_token_axios_interceptor'; +export { OAuthAuthorizationService } from './oauth_authorization_service'; +export type { OAuthConfig } from './oauth_authorization_service'; 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 new file mode 100644 index 0000000000000..8c132279c42c6 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_authorization_service.ts @@ -0,0 +1,186 @@ +/* + * 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 { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { ActionsClient } from '../actions_client'; +import { BASE_ACTION_API_PATH } from '../../common'; + +/** + * OAuth connector secrets stored in encrypted saved objects + */ +interface OAuthConnectorSecrets { + authorizationUrl?: string; + clientId?: string; + clientSecret?: string; + tokenUrl?: string; + scope?: string; +} + +/** + * OAuth connector configuration + */ +interface OAuthConnectorConfig { + authType?: string; + auth?: { + type?: string; + }; + authorizationUrl?: string; + clientId?: string; + tokenUrl?: string; + scope?: string; +} + +/** + * OAuth configuration required for authorization flow + */ +export interface OAuthConfig { + authorizationUrl: string; + clientId: string; + scope?: string; +} + +/** + * Parameters for building an OAuth authorization URL + */ +interface BuildAuthorizationUrlParams { + baseAuthorizationUrl: string; + clientId: string; + scope?: string; + redirectUri: string; + state: string; + codeChallenge: string; +} + +interface ConnectorWithOAuth { + actionTypeId: string; + name: string; + config: OAuthConnectorConfig; + secrets: OAuthConnectorSecrets; +} + +interface ConstructorOptions { + actionsClient: ActionsClient; + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + kibanaBaseUrl: string; +} + +/** + * Service for handling OAuth2 Authorization Code flow operations + * + * This service encapsulates the business logic for: + * - Validating connectors use OAuth Authorization Code flow + * - Retrieving OAuth configuration with decrypted secrets + * - Building OAuth authorization URLs with PKCE parameters + */ +export class OAuthAuthorizationService { + private readonly actionsClient: ActionsClient; + private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + private readonly kibanaBaseUrl: string; + + constructor({ actionsClient, encryptedSavedObjectsClient, kibanaBaseUrl }: ConstructorOptions) { + this.actionsClient = actionsClient; + this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; + this.kibanaBaseUrl = kibanaBaseUrl; + } + + /** + * Validates that a connector uses OAuth Authorization Code flow + * @throws Error if connector doesn't use oauth_authorization_code + */ + private validateOAuthConnector(config: OAuthConnectorConfig): void { + const isOAuthAuthCode = + config?.authType === 'oauth_authorization_code' || + config?.auth?.type === 'oauth_authorization_code'; + + if (!isOAuthAuthCode) { + throw new Error('Connector does not use OAuth Authorization Code flow'); + } + } + + /** + * Gets OAuth configuration for a connector with decrypted secrets + * @param connectorId - The connector ID + * @returns OAuth configuration including authorizationUrl, clientId, and optional scope + * @throws Error if connector is not found, not OAuth, or missing required config + */ + async getOAuthConfig(connectorId: string): Promise { + // Get connector to verify it exists and check auth type + const connector = await this.actionsClient.get({ id: connectorId }); + const config = connector.config as OAuthConnectorConfig; + + // Validate this is an OAuth connector + this.validateOAuthConnector(config); + + // Fetch connector with decrypted secrets + const rawAction = + await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + 'action', + connectorId + ); + + const secrets = rawAction.attributes.secrets; + + // 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)' + ); + } + + return { + authorizationUrl, + clientId, + scope, + }; + } + + /** + * Builds the redirect URI for OAuth callbacks + * + * The redirect URI is where the OAuth provider will send the user after authorization. + * It points to the oauth_callback route in this Kibana instance. + * + * @returns The complete redirect URI + * @throws Error if Kibana public base URL is not configured + */ + getRedirectUri(): string { + if (!this.kibanaBaseUrl) { + throw new Error( + 'Kibana public URL not configured. Please set server.publicBaseUrl in kibana.yml' + ); + } + return `${this.kibanaBaseUrl}${BASE_ACTION_API_PATH}/connector/_oauth_callback`; + } + + /** + * Builds an OAuth authorization URL with PKCE parameters + * @param params - Parameters for building the authorization URL + * @returns The complete authorization URL as a string + */ + buildAuthorizationUrl(params: BuildAuthorizationUrlParams): string { + const { baseAuthorizationUrl, clientId, scope, redirectUri, state, codeChallenge } = params; + + const authUrl = new URL(baseAuthorizationUrl); + authUrl.searchParams.set('client_id', clientId); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('state', state); + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + if (scope) { + authUrl.searchParams.set('scope', scope); + } + + return authUrl.toString(); + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_rate_limiter.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_rate_limiter.test.ts new file mode 100644 index 0000000000000..45318eccef281 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_rate_limiter.test.ts @@ -0,0 +1,193 @@ +/* + * 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 { OAuthRateLimiter } from './oauth_rate_limiter'; + +const DEFAULT_CONFIG = { + authorize: { limit: 10, lookbackWindow: '1h' }, + callback: { limit: 50, lookbackWindow: '1h' }, +}; + +describe('OAuthRateLimiter', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-06-24T15:30:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('log', () => { + it('should log OAuth requests for a user and endpoint', () => { + const rateLimiter = new OAuthRateLimiter({ config: DEFAULT_CONFIG }); + + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + rateLimiter.log('user1', 'authorize'); + + expect(rateLimiter.getLogs('user1', 'authorize')).toEqual([ + 1750779000000, 1750779001000, 1750779002000, + ]); + }); + + it('should track multiple users independently', () => { + const rateLimiter = new OAuthRateLimiter({ config: DEFAULT_CONFIG }); + + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + rateLimiter.log('user2', 'authorize'); + jest.advanceTimersByTime(1000); + rateLimiter.log('user1', 'authorize'); + + expect(rateLimiter.getLogs('user1', 'authorize')).toEqual([1750779000000, 1750779002000]); + expect(rateLimiter.getLogs('user2', 'authorize')).toEqual([1750779001000]); + }); + + it('should track multiple endpoints independently for the same user', () => { + const rateLimiter = new OAuthRateLimiter({ config: DEFAULT_CONFIG }); + + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + rateLimiter.log('user1', 'callback'); + jest.advanceTimersByTime(1000); + rateLimiter.log('user1', 'authorize'); + + expect(rateLimiter.getLogs('user1', 'authorize')).toEqual([1750779000000, 1750779002000]); + expect(rateLimiter.getLogs('user1', 'callback')).toEqual([1750779001000]); + }); + }); + + describe('isRateLimited', () => { + it('should return false when request count is below limit', () => { + const rateLimiter = new OAuthRateLimiter({ config: DEFAULT_CONFIG }); + + for (let i = 0; i < 5; i++) { + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + } + + expect(rateLimiter.isRateLimited('user1', 'authorize')).toBe(false); + }); + + it('should return true when request count reaches or exceeds limit', () => { + const rateLimiter = new OAuthRateLimiter({ config: DEFAULT_CONFIG }); + + for (let i = 0; i < 10; i++) { + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + } + + for (let i = 0; i < 15; i++) { + rateLimiter.log('user2', 'authorize'); + jest.advanceTimersByTime(1000); + } + + expect(rateLimiter.isRateLimited('user1', 'authorize')).toBe(true); + expect(rateLimiter.isRateLimited('user2', 'authorize')).toBe(true); + }); + + it('should respect different limits for different endpoints', () => { + const config = { + authorize: { limit: 5, lookbackWindow: '1h' }, + callback: { limit: 20, lookbackWindow: '1h' }, + }; + const rateLimiter = new OAuthRateLimiter({ config }); + + // Authorize endpoint - hit limit + for (let i = 0; i < 5; i++) { + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + } + + // Callback endpoint - under limit + for (let i = 0; i < 10; i++) { + rateLimiter.log('user1', 'callback'); + jest.advanceTimersByTime(1000); + } + + expect(rateLimiter.isRateLimited('user1', 'authorize')).toBe(true); + expect(rateLimiter.isRateLimited('user1', 'callback')).toBe(false); + }); + + it('should rate limit users independently', () => { + const config = { + authorize: { limit: 5, lookbackWindow: '1h' }, + callback: { limit: 50, lookbackWindow: '1h' }, + }; + const rateLimiter = new OAuthRateLimiter({ config }); + + // User1 hits limit + for (let i = 0; i < 5; i++) { + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + } + + // User2 under limit + for (let i = 0; i < 3; i++) { + rateLimiter.log('user2', 'authorize'); + jest.advanceTimersByTime(1000); + } + + expect(rateLimiter.isRateLimited('user1', 'authorize')).toBe(true); + expect(rateLimiter.isRateLimited('user2', 'authorize')).toBe(false); + }); + }); + + describe('lookback window cleanup', () => { + it('should cleanup old logs outside lookback window', () => { + const config = { + authorize: { limit: 100, lookbackWindow: '10s' }, + callback: { limit: 50, lookbackWindow: '1h' }, + }; + const rateLimiter = new OAuthRateLimiter({ config }); + + // Log 16 requests over 16 seconds + for (let i = 0; i <= 15; i++) { + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + } + + // Before cleanup, all logs present + expect(rateLimiter.getLogs('user1', 'authorize')).toHaveLength(16); + + // Trigger cleanup via isRateLimited + rateLimiter.isRateLimited('user1', 'authorize'); + + // After cleanup, only the last 10 seconds remain + expect(rateLimiter.getLogs('user1', 'authorize')).toHaveLength(10); + expect(rateLimiter.getLogs('user1', 'authorize')).toEqual([ + 1750779006000, 1750779007000, 1750779008000, 1750779009000, 1750779010000, 1750779011000, + 1750779012000, 1750779013000, 1750779014000, 1750779015000, + ]); + }); + + it('should allow requests after lookback window expires', () => { + const config = { + authorize: { limit: 5, lookbackWindow: '10s' }, + callback: { limit: 50, lookbackWindow: '1h' }, + }; + const rateLimiter = new OAuthRateLimiter({ config }); + + // Hit the limit + for (let i = 0; i < 5; i++) { + rateLimiter.log('user1', 'authorize'); + jest.advanceTimersByTime(1000); + } + + expect(rateLimiter.isRateLimited('user1', 'authorize')).toBe(true); + + // Advance time beyond lookback window + jest.advanceTimersByTime(11000); + + // Should no longer be rate limited + expect(rateLimiter.isRateLimited('user1', 'authorize')).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_rate_limiter.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_rate_limiter.ts new file mode 100644 index 0000000000000..d2bee379e995d --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_rate_limiter.ts @@ -0,0 +1,60 @@ +/* + * 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 { OAuthRateLimiterConfig } from '../config'; +import { parseDuration } from './parse_date'; + +type OAuthEndpoint = 'authorize' | 'callback'; + +export class OAuthRateLimiter { + private logsByUserAndEndpoint: Map; + private readonly config: OAuthRateLimiterConfig; + + constructor({ config }: { config: OAuthRateLimiterConfig }) { + this.logsByUserAndEndpoint = new Map(); + this.config = config; + } + + log(username: string, endpoint: OAuthEndpoint) { + const key = this.createKey(username, endpoint); + const now = Date.now(); + + if (!this.logsByUserAndEndpoint.has(key)) { + this.logsByUserAndEndpoint.set(key, []); + } + + this.logsByUserAndEndpoint.get(key)!.push(now); + } + + isRateLimited(username: string, endpoint: OAuthEndpoint): boolean { + const key = this.createKey(username, endpoint); + this.cleanupOldLogs(key, endpoint); + + const count = this.getLogs(username, endpoint).length; + const limit = this.config[endpoint].limit; + + return count >= limit; + } + + getLogs(username: string, endpoint: OAuthEndpoint): number[] { + const key = this.createKey(username, endpoint); + return this.logsByUserAndEndpoint.get(key) || []; + } + + private cleanupOldLogs(key: string, endpoint: OAuthEndpoint) { + const endpointConfig = this.config[endpoint]; + const now = Date.now(); + const cutoff = now - parseDuration(endpointConfig.lookbackWindow); + + const filtered = this.logsByUserAndEndpoint.get(key)?.filter((ts) => ts >= cutoff) || []; + this.logsByUserAndEndpoint.set(key, filtered); + } + + private createKey(username: string, endpoint: OAuthEndpoint): string { + return `${username}:${endpoint}`; + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_cleanup_task.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_cleanup_task.ts new file mode 100644 index 0000000000000..efa829ae982ed --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_cleanup_task.ts @@ -0,0 +1,126 @@ +/* + * 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, CoreSetup } from '@kbn/core/server'; +import type { + TaskManagerSetupContract, + TaskManagerStartContract, + IntervalSchedule, + ConcreteTaskInstance, +} from '@kbn/task-manager-plugin/server'; +import type { ActionsPluginsStart } from '../plugin'; +import { OAuthStateClient } from './oauth_state_client'; + +export const OAUTH_STATE_CLEANUP_TASK_TYPE = 'actions:oauth_state_cleanup'; +export const OAUTH_STATE_CLEANUP_TASK_ID = `Actions-${OAUTH_STATE_CLEANUP_TASK_TYPE}`; +export const OAUTH_STATE_CLEANUP_SCHEDULE: IntervalSchedule = { interval: '30m' }; + +interface TaskState extends Record { + runs: number; + last_cleanup_count: number; +} + +const emptyState: TaskState = { + runs: 0, + last_cleanup_count: 0, +}; + +export function initializeOAuthStateCleanupTask( + logger: Logger, + taskManager: TaskManagerSetupContract, + core: CoreSetup +) { + registerOAuthStateCleanupTask(logger, taskManager, core); +} + +export function scheduleOAuthStateCleanupTask( + logger: Logger, + taskManager: TaskManagerStartContract +) { + scheduleTask(logger, taskManager).catch(() => { + // catch to prevent unhandled promise rejection + }); +} + +function registerOAuthStateCleanupTask( + logger: Logger, + taskManager: TaskManagerSetupContract, + core: CoreSetup +) { + taskManager.registerTaskDefinitions({ + [OAUTH_STATE_CLEANUP_TASK_TYPE]: { + title: 'OAuth state cleanup task', + description: 'Periodically removes expired OAuth state objects', + timeout: '1m', + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + return { + run: async () => { + const state = taskInstance.state as TaskState; + + try { + const [coreStart, { encryptedSavedObjects }] = await core.getStartServices(); + + const unsecuredSavedObjectsClient = coreStart.savedObjects.createInternalRepository(); + const encryptedSavedObjectsClient = encryptedSavedObjects.getClient({ + includedHiddenTypes: ['oauth_state'], + }); + const oauthStateClient = new OAuthStateClient({ + encryptedSavedObjectsClient, + unsecuredSavedObjectsClient, + logger: logger.get('oauth_state_cleanup'), + }); + + const cleanupCount = await oauthStateClient.cleanupExpiredStates(); + + const updatedState: TaskState = { + runs: (state.runs || 0) + 1, + last_cleanup_count: cleanupCount, + }; + + return { + state: updatedState, + schedule: OAUTH_STATE_CLEANUP_SCHEDULE, + }; + } catch (error) { + logger.error( + `OAuth state cleanup task failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + + return { + state: { + runs: (state.runs || 0) + 1, + last_cleanup_count: 0, + }, + schedule: OAUTH_STATE_CLEANUP_SCHEDULE, + }; + } + }, + }; + }, + }, + }); +} + +async function scheduleTask(logger: Logger, taskManager: TaskManagerStartContract) { + try { + await taskManager.ensureScheduled({ + id: OAUTH_STATE_CLEANUP_TASK_ID, + taskType: OAUTH_STATE_CLEANUP_TASK_TYPE, + state: emptyState, + params: {}, + schedule: OAUTH_STATE_CLEANUP_SCHEDULE, + }); + } catch (e) { + logger.error( + `Error scheduling ${OAUTH_STATE_CLEANUP_TASK_ID}, received ${ + e instanceof Error ? e.message : String(e) + }` + ); + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_client.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_client.ts new file mode 100644 index 0000000000000..9a729bcfba9ee --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_client.ts @@ -0,0 +1,219 @@ +/* + * 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 crypto from 'crypto'; +import { omitBy, isUndefined } from 'lodash'; +import type { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; +import type { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { SavedObjectsUtils } from '@kbn/core/server'; +import { OAUTH_STATE_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; + +const STATE_EXPIRATION_MS = 10 * 60 * 1000; // 10 minutes + +interface OAuthStateAttributes { + state: string; + codeVerifier: string; + connectorId: string; + redirectUri: string; + kibanaReturnUrl: string; + createdAt: string; + expiresAt: string; + createdBy?: string; +} + +export interface OAuthState extends OAuthStateAttributes { + id: string; +} + +interface ConstructorOptions { + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +interface CreateStateOptions { + connectorId: string; + redirectUri: string; + kibanaReturnUrl: string; + createdBy?: string; +} + +/** + * Generates a cryptographically secure random string for OAuth2 state/verifier + */ +function generateRandomString(length: number = 32): string { + return crypto.randomBytes(length).toString('base64url'); +} + +/** + * Generates PKCE code challenge from code verifier + */ +function generateCodeChallenge(codeVerifier: string): string { + return crypto.createHash('sha256').update(codeVerifier).digest('base64url'); +} + +export class OAuthStateClient { + private readonly logger: Logger; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + + constructor({ + unsecuredSavedObjectsClient, + encryptedSavedObjectsClient, + logger, + }: ConstructorOptions) { + this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.logger = logger; + } + + /** + * Create new OAuth state with PKCE parameters + */ + public async create({ + connectorId, + redirectUri, + kibanaReturnUrl, + createdBy, + }: CreateStateOptions): Promise<{ + state: OAuthState; + codeChallenge: string; + }> { + const id = SavedObjectsUtils.generateId(); + const state = generateRandomString(32); + const codeVerifier = generateRandomString(128); // PKCE spec recommends 43-128 chars + const codeChallenge = generateCodeChallenge(codeVerifier); + const now = new Date(); + const expiresAt = new Date(now.getTime() + STATE_EXPIRATION_MS); + + this.logger.debug(`Creating OAuth state for connectorId "${connectorId}"`); + try { + const result = await this.unsecuredSavedObjectsClient.create( + OAUTH_STATE_SAVED_OBJECT_TYPE, + omitBy( + { + state, + codeVerifier, + connectorId, + redirectUri, + kibanaReturnUrl, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + createdBy, + }, + isUndefined + ), + { id } + ); + + return { + state: { + id: result.id, + ...result.attributes, + } as OAuthState, + codeChallenge, + }; + } catch (err) { + this.logger.error( + `Failed to create OAuth state for connectorId "${connectorId}". Error: ${err.message}` + ); + throw err; + } + } + + /** + * Get and validate OAuth state by state parameter + */ + public async get(stateParam: string): Promise { + try { + const result = await this.unsecuredSavedObjectsClient.find({ + type: OAUTH_STATE_SAVED_OBJECT_TYPE, + filter: `${OAUTH_STATE_SAVED_OBJECT_TYPE}.attributes.state: "${stateParam}"`, + perPage: 1, + }); + + if (result.saved_objects.length === 0) { + this.logger.warn(`OAuth state not found for state parameter: ${stateParam}`); + return null; + } + + const stateObject = result.saved_objects[0]; + + // Check if expired + if (new Date(stateObject.attributes.expiresAt) < new Date()) { + this.logger.warn(`OAuth state expired for state parameter: ${stateParam}`); + await this.delete(stateObject.id); + return null; + } + + // Decrypt code verifier + const decrypted = + await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + OAUTH_STATE_SAVED_OBJECT_TYPE, + stateObject.id + ); + + return { + id: stateObject.id, + ...decrypted.attributes, + }; + } catch (err) { + this.logger.error( + `Failed to fetch OAuth state for state parameter "${stateParam}". Error: ${err.message}` + ); + return null; + } + } + + /** + * Delete OAuth state (should be called after a successful token exchange) + */ + public async delete(id: string): Promise { + try { + await this.unsecuredSavedObjectsClient.delete(OAUTH_STATE_SAVED_OBJECT_TYPE, id); + } catch (err) { + this.logger.error(`Failed to delete OAuth state "${id}". Error: ${err.message}`); + throw err; + } + } + + /** + * Clean up expired OAuth states (called periodically by task manager) + */ + public async cleanupExpiredStates(): Promise { + try { + const finder = this.unsecuredSavedObjectsClient.createPointInTimeFinder( + { + type: OAUTH_STATE_SAVED_OBJECT_TYPE, + filter: `${OAUTH_STATE_SAVED_OBJECT_TYPE}.attributes.expiresAt < "${new Date().toISOString()}"`, + perPage: 100, + } + ); + + let totalDeleted = 0; + + for await (const response of finder.find()) { + await this.unsecuredSavedObjectsClient.bulkDelete( + response.saved_objects.map((obj) => ({ + type: OAUTH_STATE_SAVED_OBJECT_TYPE, + id: obj.id, + })) + ); + + totalDeleted += response.saved_objects.length; + } + + await finder.close(); + + this.logger.debug(`Cleaned up ${totalDeleted} expired OAuth states`); + return totalDeleted; + } catch (err) { + this.logger.error(`Failed to cleanup expired OAuth states. Error: ${err.message}`); + return 0; + } + } +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_authorization_code_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_authorization_code_token.ts new file mode 100644 index 0000000000000..e5758a07fc3ae --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_authorization_code_token.ts @@ -0,0 +1,52 @@ +/* + * 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 { ActionsConfigurationUtilities } from '../actions_config'; +import type { OAuthTokenResponse } from './request_oauth_token'; +import { requestOAuthToken } from './request_oauth_token'; +import type { AsApiContract } from '../../common'; + +export const OAUTH_AUTHORIZATION_CODE_GRANT_TYPE = 'authorization_code'; + +export interface AuthorizationCodeOAuthRequestParams { + code: string; + redirectUri: string; + codeVerifier: string; + clientId: string; + clientSecret: string; + [key: string]: unknown; +} + +const rewriteBodyRequest = (params: AuthorizationCodeOAuthRequestParams) => { + const { code, redirectUri, codeVerifier, clientId, clientSecret, ...rest } = params; + return { + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + client_id: clientId, + client_secret: clientSecret, + ...rest, + } as AsApiContract; +}; + +export async function requestOAuthAuthorizationCodeToken( + tokenUrl: string, + logger: Logger, + params: AuthorizationCodeOAuthRequestParams, + configurationUtilities: ActionsConfigurationUtilities, + useBasicAuth: boolean = true // Default to true (OAuth 2.0 recommended practice) +): Promise { + return await requestOAuthToken( + tokenUrl, + OAUTH_AUTHORIZATION_CODE_GRANT_TYPE, + configurationUtilities, + logger, + rewriteBodyRequest(params), + useBasicAuth + ); +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_jwt_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_jwt_token.ts index 27100f8213758..b398f4fb3af5b 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_jwt_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_jwt_token.ts @@ -14,7 +14,7 @@ import type { RewriteResponseCase } from '../../common'; // for OAuth 2.0 Client Authentication and Authorization Grants https://datatracker.ietf.org/doc/html/rfc7523#section-8.1 export const OAUTH_JWT_BEARER_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; -interface JWTOAuthRequestParams { +export interface JWTOAuthRequestParams { assertion: string; clientId?: string; clientSecret?: string; diff --git a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_refresh_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_refresh_token.ts new file mode 100644 index 0000000000000..f91a828c58a0e --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_refresh_token.ts @@ -0,0 +1,49 @@ +/* + * 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 { ActionsConfigurationUtilities } from '../actions_config'; +import type { OAuthTokenResponse } from './request_oauth_token'; +import { requestOAuthToken } from './request_oauth_token'; +import type { AsApiContract } from '../../common'; + +export const OAUTH_REFRESH_TOKEN_GRANT_TYPE = 'refresh_token'; + +export interface RefreshTokenOAuthRequestParams { + refreshToken: string; + clientId: string; + clientSecret: string; + scope?: string; + [key: string]: unknown; +} + +const rewriteBodyRequest = (params: RefreshTokenOAuthRequestParams) => { + const { refreshToken, clientId, clientSecret, ...rest } = params; + return { + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + ...rest, + } as AsApiContract; +}; + +export async function requestOAuthRefreshToken( + tokenUrl: string, + logger: Logger, + params: RefreshTokenOAuthRequestParams, + configurationUtilities: ActionsConfigurationUtilities, + useBasicAuth: boolean = true // Default to true (OAuth 2.0 recommended practice) +): Promise { + return await requestOAuthToken( + tokenUrl, + OAUTH_REFRESH_TOKEN_GRANT_TYPE, + configurationUtilities, + logger, + rewriteBodyRequest(params), + useBasicAuth + ); +} diff --git a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.ts b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.ts index 984933bb7cc24..6a115f15778c7 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/request_oauth_token.ts @@ -9,14 +9,21 @@ import qs from 'query-string'; import axios from 'axios'; import stringify from 'json-stable-stringify'; import type { Logger } from '@kbn/core/server'; +import type { RefreshTokenOAuthRequestParams } from './request_oauth_refresh_token'; +import type { JWTOAuthRequestParams } from './request_oauth_jwt_token'; +import type { ClientCredentialsOAuthRequestParams } from './request_oauth_client_credentials_token'; import { request } from './axios_utils'; import type { ActionsConfigurationUtilities } from '../actions_config'; import type { AsApiContract } from '../../common'; +import { getBasicAuthHeader } from './get_basic_auth_header'; +import type { AuthorizationCodeOAuthRequestParams } from './request_oauth_authorization_code_token'; export interface OAuthTokenResponse { tokenType: string; accessToken: string; - expiresIn: number; + expiresIn?: number; + refreshToken?: string; + refreshTokenExpiresIn?: number; } export async function requestOAuthToken( @@ -24,22 +31,42 @@ export async function requestOAuthToken( grantType: string, configurationUtilities: ActionsConfigurationUtilities, logger: Logger, - bodyRequest: AsApiContract + bodyRequest: AsApiContract, + useBasicAuth: boolean = false ): Promise { const axiosInstance = axios.create(); + type OAuthBodyRequest = + | AuthorizationCodeOAuthRequestParams + | ClientCredentialsOAuthRequestParams + | JWTOAuthRequestParams + | RefreshTokenOAuthRequestParams; + // Extract client credentials for Basic Auth if needed + const { + client_id: clientId, + client_secret: clientSecret, + ...restBody + } = bodyRequest as AsApiContract; + + const requestData = { + ...(useBasicAuth ? restBody : bodyRequest), + grant_type: grantType, + }; + + const requestHeaders = { + ...(useBasicAuth && clientId && clientSecret + ? getBasicAuthHeader({ username: clientId, password: clientSecret }) + : {}), + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }; + const res = await request({ axios: axiosInstance, url: tokenUrl, method: 'post', logger, - data: qs.stringify({ - ...bodyRequest, - grant_type: grantType, - }), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - }, + data: qs.stringify(requestData), + headers: requestHeaders, configurationUtilities, validateStatus: () => true, }); @@ -49,6 +76,8 @@ export async function requestOAuthToken( 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 = stringify(res.data); diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.test.ts b/x-pack/platform/plugins/shared/actions/server/plugin.test.ts index 653750a9bdca0..9ffc2a1e9bc60 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.test.ts @@ -58,6 +58,10 @@ function getConfig(overrides = {}) { usage: { url: 'ca.path', }, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, ...overrides, }; } @@ -108,6 +112,10 @@ describe('Actions Plugin', () => { usage: { url: 'ca.path', }, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, }); plugin = new ActionsPlugin(context); coreSetup = coreMock.createSetup(); @@ -501,6 +509,10 @@ describe('Actions Plugin', () => { usage: { url: 'ca.path', }, + oAuthRateLimit: { + authorize: { lookbackWindow: '1h', limit: 100 }, + callback: { lookbackWindow: '1h', limit: 100 }, + }, }); plugin = new ActionsPlugin(context); coreSetup = coreMock.createSetup(); diff --git a/x-pack/platform/plugins/shared/actions/server/plugin.ts b/x-pack/platform/plugins/shared/actions/server/plugin.ts index 5f8db4d98e91c..fc5cf797e0131 100644 --- a/x-pack/platform/plugins/shared/actions/server/plugin.ts +++ b/x-pack/platform/plugins/shared/actions/server/plugin.ts @@ -71,6 +71,10 @@ import { getActionsConfigurationUtilities } from './actions_config'; import { defineRoutes } from './routes'; import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task'; +import { + initializeOAuthStateCleanupTask, + scheduleOAuthStateCleanupTask, +} from './lib/oauth_state_cleanup_task'; import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, @@ -104,6 +108,7 @@ import { createBulkUnsecuredExecutionEnqueuerFunction } from './create_unsecured import { createSystemConnectors } from './create_system_actions'; import { ConnectorUsageReportingTask } from './usage/connector_usage_reporting_task'; import { ConnectorRateLimiter } from './lib/connector_rate_limiter'; +import { OAuthRateLimiter } from './lib/oauth_rate_limiter'; import type { GetAxiosInstanceWithAuthFnOpts } from './lib/get_axios_instance'; import { getAxiosInstanceWithAuth } from './lib/get_axios_instance'; @@ -378,18 +383,32 @@ export class ActionsPlugin }); } + // Initialize OAuth state cleanup task + initializeOAuthStateCleanupTask(this.logger, plugins.taskManager, core); + const subActionFramework = createSubActionConnectorFramework({ actionTypeRegistry, logger: this.logger, actionsConfigUtils, }); + // Initialize OAuth rate limiter + const oauthRateLimiter = new OAuthRateLimiter({ + config: this.actionsConfig.oAuthRateLimit, + }); + this.logger.info( + `OAuth rate limiter initialized with authorize limit: ${this.actionsConfig.oAuthRateLimit.authorize.limit}` + ); + // Routes defineRoutes({ router: core.http.createRouter(), licenseState: this.licenseState, actionsConfigUtils, usageCounter: this.usageCounter, + logger: this.logger, + core, + oauthRateLimiter, }); return { @@ -642,6 +661,7 @@ export class ActionsPlugin this.eventLogService!.isEsContextReady() .then(() => { scheduleActionsTelemetry(this.telemetryLogger, plugins.taskManager); + scheduleOAuthStateCleanupTask(this.logger, plugins.taskManager); }) .catch(() => {}); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/get_oauth_access_token.ts b/x-pack/platform/plugins/shared/actions/server/routes/get_oauth_access_token.ts index aa91aa5980f68..76b735a97f82f 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/get_oauth_access_token.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/get_oauth_access_token.ts @@ -44,13 +44,37 @@ const oauthClientCredentialsBodySchema = schema.object({ export type OAuthClientCredentialsParams = TypeOf; +const oauthAuthorizationCodeBodySchema = schema.object({ + connectorId: schema.string(), + tokenUrl: schema.string(), + scope: schema.maybe(schema.string()), + config: schema.object({ + clientId: schema.string(), + tokenUrl: schema.string(), + }), + secrets: schema.object({ + clientSecret: schema.string(), + }), +}); + +export type OAuthAuthorizationCodeParams = TypeOf; + const bodySchema = schema.object({ - type: schema.oneOf([schema.literal('jwt'), schema.literal('client')]), + type: schema.oneOf([ + schema.literal('jwt'), + schema.literal('client'), + schema.literal('authorization_code'), + ]), options: schema.conditional( schema.siblingRef('type'), schema.literal('jwt'), oauthJwtBodySchema, - oauthClientCredentialsBodySchema + schema.conditional( + schema.siblingRef('type'), + schema.literal('client'), + oauthClientCredentialsBodySchema, + oauthAuthorizationCodeBodySchema + ) ), }); 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 dc55f669445c6..c371b746e5ebd 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/index.ts @@ -7,6 +7,7 @@ import type { IRouter } from '@kbn/core/server'; import type { UsageCounter } from '@kbn/usage-collection-plugin/server'; +import type { Logger, CoreSetup } from '@kbn/core/server'; import { getAllConnectorsRoute } from './connector/get_all'; import { getAllConnectorsIncludingSystemRoute } from './connector/get_all_system'; import { listTypesRoute } from './connector/list_types'; @@ -19,19 +20,27 @@ import { executeConnectorRoute } from './connector/execute'; import { getConnectorRoute } from './connector/get'; import { updateConnectorRoute } from './connector/update'; import { getOAuthAccessToken } from './get_oauth_access_token'; +import { oauthAuthorizeRoute } from './oauth_authorize'; +import { oauthCallbackRoute } from './oauth_callback'; import type { ActionsConfigurationUtilities } from '../actions_config'; import { getGlobalExecutionLogRoute } from './get_global_execution_logs'; import { getGlobalExecutionKPIRoute } from './get_global_execution_kpi'; +import type { ActionsPluginsStart } from '../plugin'; +import type { OAuthRateLimiter } from '../lib/oauth_rate_limiter'; + export interface RouteOptions { router: IRouter; licenseState: ILicenseState; actionsConfigUtils: ActionsConfigurationUtilities; usageCounter?: UsageCounter; + logger: Logger; + core: CoreSetup; + oauthRateLimiter: OAuthRateLimiter; } export function defineRoutes(opts: RouteOptions) { - const { router, licenseState, actionsConfigUtils } = opts; + const { router, licenseState, actionsConfigUtils, logger, core, oauthRateLimiter } = opts; createConnectorRoute(router, licenseState); deleteConnectorRoute(router, licenseState); @@ -44,6 +53,8 @@ export function defineRoutes(opts: RouteOptions) { getGlobalExecutionKPIRoute(router, licenseState); getOAuthAccessToken(router, licenseState, actionsConfigUtils); + oauthAuthorizeRoute(router, licenseState, logger, core, oauthRateLimiter); + oauthCallbackRoute(router, licenseState, actionsConfigUtils, logger, core, oauthRateLimiter); getAllConnectorsIncludingSystemRoute(router, licenseState); listTypesWithSystemRoute(router, licenseState); } 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 new file mode 100644 index 0000000000000..a2d39e9452855 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import type { IRouter, Logger, CoreSetup } from '@kbn/core/server'; +import type { ILicenseState } from '../lib'; +import { INTERNAL_BASE_ACTION_API_PATH } from '../../common'; +import type { ActionsRequestHandlerContext } from '../types'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { DEFAULT_ACTION_ROUTE_SECURITY } from './constants'; +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'; + +const paramsSchema = schema.object({ + connectorId: schema.string(), +}); + +const bodySchema = schema.object({ + returnUrl: schema.maybe(schema.uri({ scheme: ['http', 'https'] })), +}); + +/** + * Initiates OAuth2 Authorization Code flow + * Returns authorization URL for user to visit + */ +export const oauthAuthorizeRoute = ( + router: IRouter, + licenseState: ILicenseState, + logger: Logger, + coreSetup: CoreSetup, + oauthRateLimiter: OAuthRateLimiter +) => { + router.post( + { + path: `${INTERNAL_BASE_ACTION_API_PATH}/connector/{connectorId}/_start_oauth_flow`, + security: DEFAULT_ACTION_ROUTE_SECURITY, + validate: { + params: paramsSchema, + body: bodySchema, + }, + options: { + access: 'internal', + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const { connectorId } = req.params; + + try { + const core = await context.core; + const routeLogger = logger.get('oauth_authorize'); + + // Check rate limit + const currentUser = core.security.authc.getCurrentUser(); + if (!currentUser) { + throw new Error('User should be authenticated to initiate OAuth authorization.'); + } + const username = currentUser.username; + oauthRateLimiter.log(username, 'authorize'); + if (oauthRateLimiter.isRateLimited(username, 'authorize')) { + routeLogger.warn( + `OAuth authorize rate limit exceeded for user: ${username}, connector: ${connectorId}` + ); + return res.customError({ + statusCode: 429, + body: { + message: 'Too many authorization attempts. Please try again later.', + }, + }); + } + + const [coreStart, { encryptedSavedObjects }] = await coreSetup.getStartServices(); + const kibanaUrl = coreStart.http.basePath.publicBaseUrl; + if (!kibanaUrl) { + return res.badRequest({ + body: { + message: + 'Kibana public URL not configured. Please set server.publicBaseUrl in kibana.yml', + }, + }); + } + + // Get OAuth configuration (validates connector and retrieves decrypted config) + const oauthService = new OAuthAuthorizationService({ + actionsClient: (await context.actions).getActionsClient(), + encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ + includedHiddenTypes: ['action'], + }), + kibanaBaseUrl: kibanaUrl, + }); + const oauthConfig = await oauthService.getOAuthConfig(connectorId); + const redirectUri = oauthService.getRedirectUri(); + + // Validate and build return URL for post-OAuth redirect + const requestedReturnUrl = req.body?.returnUrl; + let kibanaReturnUrl: string; + + if (requestedReturnUrl) { + // Security: Validate that returnUrl is same-origin to prevent open redirect attacks + const returnUrlObj = new URL(requestedReturnUrl); + const kibanaUrlObj = new URL(kibanaUrl); + + if (returnUrlObj.origin !== kibanaUrlObj.origin) { + return res.badRequest({ + body: { + message: `returnUrl must be same origin as Kibana. Expected: ${kibanaUrlObj.origin}, Got: ${returnUrlObj.origin}`, + }, + }); + } + kibanaReturnUrl = requestedReturnUrl; + } else { + // Default to connectors management page + kibanaReturnUrl = `${kibanaUrl}/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`; + } + + // Create OAuth state with PKCE + const oauthStateClient = new OAuthStateClient({ + encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ + includedHiddenTypes: ['oauth_state'], + }), + unsecuredSavedObjectsClient: core.savedObjects.getClient({ + includedHiddenTypes: ['oauth_state'], + }), + logger: routeLogger, + }); + const { state, codeChallenge } = await oauthStateClient.create({ + connectorId, + redirectUri, + kibanaReturnUrl, + }); + + const authorizationUrl = oauthService.buildAuthorizationUrl({ + baseAuthorizationUrl: oauthConfig.authorizationUrl, + clientId: oauthConfig.clientId, + scope: oauthConfig.scope, + redirectUri, + state: state.state, + codeChallenge, + }); + + return res.ok({ + body: { + authorizationUrl, + state: state.state, + }, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + const statusCode = + err instanceof Error && 'statusCode' in err + ? (err as Error & { statusCode: number }).statusCode + : 500; + + return res.customError({ + statusCode, + body: { + message: errorMessage || 'Failed to initiate OAuth authorization', + }, + }); + } + }) + ) + ); +}; 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 new file mode 100644 index 0000000000000..2413e2cda293b --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts @@ -0,0 +1,439 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import type { CoreSetup, IRouter, Logger } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import type { ActionsPluginsStart } from '../plugin'; +import type { ILicenseState } from '../lib'; +import { BASE_ACTION_API_PATH } from '../../common'; +import type { ActionsRequestHandlerContext } from '../types'; +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 { requestOAuthAuthorizationCodeToken } from '../lib/request_oauth_authorization_code_token'; +import { ConnectorTokenClient } from '../lib/connector_token_client'; +import type { OAuthRateLimiter } from '../lib/oauth_rate_limiter'; + +const querySchema = schema.object({ + code: schema.maybe( + schema.string({ + meta: { + description: i18n.translate('xpack.actions.oauthCallback.codeParamDescription', { + defaultMessage: 'The authorization code returned by the OAuth provider.', + }), + }, + }) + ), + state: schema.maybe( + schema.string({ + meta: { + description: i18n.translate('xpack.actions.oauthCallback.stateParamDescription', { + defaultMessage: 'The state parameter for CSRF protection.', + }), + }, + }) + ), + error: schema.maybe( + schema.string({ + meta: { + description: i18n.translate('xpack.actions.oauthCallback.errorParamDescription', { + defaultMessage: 'Error code if the authorization failed.', + }), + }, + }) + ), + error_description: schema.maybe( + schema.string({ + meta: { + description: i18n.translate( + 'xpack.actions.oauthCallback.errorDescriptionParamDescription', + { + defaultMessage: 'Human-readable error description.', + } + ), + }, + }) + ), + session_state: schema.maybe( + schema.string({ + meta: { + description: i18n.translate('xpack.actions.oauthCallback.sessionStateParamDescription', { + defaultMessage: 'Session state from the OAuth provider (e.g., Microsoft).', + }), + }, + }) + ), +}); + +interface OAuthConnectorSecrets { + clientId?: string; + clientSecret?: string; + tokenUrl?: string; + useBasicAuth?: boolean; +} + +interface OAuthConnectorConfig { + clientId?: string; + tokenUrl?: string; + useBasicAuth?: boolean; +} + +/** + * Generates a styled OAuth callback page using EUI-like styling + */ +function generateOAuthCallbackPage({ + title, + heading, + message, + details, + isSuccess, + autoClose, +}: { + title: string; + heading: string; + message: string; + details?: string; + isSuccess: boolean; + autoClose?: boolean; +}): string { + const iconColor = isSuccess ? '#00BFB3' : '#BD271E'; + const icon = isSuccess ? '✓' : '✕'; + + return ` + + + + + + ${title} + + ${ + autoClose + ? `` + : '' + } + + +
+
${icon}
+

${heading}

+

${message}

+ ${details ? `
${details}
` : ''} + ${ + autoClose + ? '' + : '' + } +
+ + + `; +} + +/** + * OAuth2 callback endpoint - handles authorization code exchange + */ +export const oauthCallbackRoute = ( + router: IRouter, + licenseState: ILicenseState, + configurationUtilities: ActionsConfigurationUtilities, + logger: Logger, + coreSetup: CoreSetup, + oauthRateLimiter: OAuthRateLimiter +) => { + router.get( + { + path: `${BASE_ACTION_API_PATH}/connector/_oauth_callback`, + security: DEFAULT_ACTION_ROUTE_SECURITY, + options: { + access: 'public', + summary: i18n.translate('xpack.actions.oauthCallback.routeSummary', { + defaultMessage: 'Handle OAuth callback', + }), + description: i18n.translate('xpack.actions.oauthCallback.routeDescription', { + defaultMessage: + '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 + // The OAuth redirect happens in their browser, so they will have their session cookie + }, + validate: { + request: { + query: querySchema, + }, + response: { + 302: { + description: i18n.translate('xpack.actions.oauthCallback.response302Description', { + defaultMessage: 'Redirects to Kibana on successful authorization.', + }), + }, + 200: { + description: i18n.translate('xpack.actions.oauthCallback.response200Description', { + defaultMessage: 'Returns an HTML page with error details if authorization fails.', + }), + }, + 401: { + description: i18n.translate('xpack.actions.oauthCallback.response401Description', { + defaultMessage: 'User is not authenticated.', + }), + }, + }, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const core = await context.core; + const routeLogger = logger.get('oauth_callback'); + + // Check rate limit + const currentUser = core.security.authc.getCurrentUser(); + if (!currentUser) { + return res.unauthorized({ + headers: { 'content-type': 'text/html' }, + body: generateOAuthCallbackPage({ + title: 'Authorization Failed', + heading: 'Authentication Required', + message: 'User should be authenticated to complete OAuth callback.', + details: 'Please log in and try again.', + isSuccess: false, + }), + }); + } + const username = currentUser.username; + oauthRateLimiter.log(username, 'callback'); + if (oauthRateLimiter.isRateLimited(username, 'callback')) { + routeLogger.warn(`OAuth callback rate limit exceeded for user: ${username}`); + return res.ok({ + headers: { 'content-type': 'text/html' }, + body: generateOAuthCallbackPage({ + title: 'OAuth Authorization Failed', + heading: 'Too Many Requests', + message: 'You have made too many authorization attempts.', + details: 'Please wait before trying again.', + isSuccess: false, + }), + }); + } + + // Handle OAuth errors or missing parameters + const { code, state: stateParam, error, error_description: errorDescription } = req.query; + if (error || !code || !stateParam) { + const errorMessage = error || 'Missing required OAuth parameters (code or state)'; + const details = errorDescription + ? `${errorMessage}\n\n${errorDescription}` + : errorMessage; + + return res.ok({ + headers: { 'content-type': 'text/html' }, + body: generateOAuthCallbackPage({ + title: 'OAuth Authorization Failed', + heading: 'Authorization Failed', + message: 'You can close this window and try again.', + details, + isSuccess: false, + }), + }); + } + + try { + const [, { encryptedSavedObjects }] = await coreSetup.getStartServices(); + + // Retrieve and validate state + const oauthStateClient = new OAuthStateClient({ + encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ + includedHiddenTypes: ['oauth_state'], + }), + unsecuredSavedObjectsClient: core.savedObjects.getClient({ + includedHiddenTypes: ['oauth_state'], + }), + logger: routeLogger, + }); + const oauthState = await oauthStateClient.get(stateParam); + if (!oauthState) { + return res.ok({ + headers: { 'content-type': 'text/html' }, + body: generateOAuthCallbackPage({ + title: 'OAuth Authorization Failed', + heading: 'Authorization Failed', + message: 'You can close this window and try again.', + details: + 'Invalid or expired state parameter. The authorization session may have timed out.', + isSuccess: false, + }), + }); + } + + // Get connector with decrypted secrets + const connectorEncryptedClient = encryptedSavedObjects.getClient({ + includedHiddenTypes: ['action'], + }); + const rawAction = await connectorEncryptedClient.getDecryptedAsInternalUser<{ + actionTypeId: string; + name: string; + config: OAuthConnectorConfig; + secrets: OAuthConnectorSecrets; + }>('action', oauthState.connectorId); + + const config = rawAction.attributes.config; + const secrets = rawAction.attributes.secrets; + // Extract OAuth config - for connector specs, secrets are stored directly + const clientId = secrets.clientId || config?.clientId; + const clientSecret = secrets.clientSecret; + const tokenUrl = secrets.tokenUrl || config?.tokenUrl; + const useBasicAuth = secrets.useBasicAuth ?? config?.useBasicAuth ?? true; // Default to true (OAuth 2.0 recommended practice) + if (!clientId || !clientSecret || !tokenUrl) { + throw new Error( + 'Connector missing required OAuth configuration (clientId, clientSecret, tokenUrl)' + ); + } + + // Exchange authorization code for tokens + const tokenResult = await requestOAuthAuthorizationCodeToken( + tokenUrl, + logger, + { + code, + redirectUri: oauthState.redirectUri, + codeVerifier: oauthState.codeVerifier, + clientId, + clientSecret, + }, + configurationUtilities, + useBasicAuth + ); + routeLogger.debug( + `Successfully exchanged authorization code for access token for connectorId: ${oauthState.connectorId}` + ); + + // Store tokens - first delete any existing tokens for this connector then create a new token record + const connectorTokenClient = new ConnectorTokenClient({ + encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ + includedHiddenTypes: ['connector_token'], + }), + unsecuredSavedObjectsClient: core.savedObjects.getClient({ + includedHiddenTypes: ['connector_token'], + }), + logger: routeLogger, + }); + await connectorTokenClient.deleteConnectorTokens({ + connectorId: oauthState.connectorId, + tokenType: 'access_token', + }); + const formattedToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + await connectorTokenClient.createWithRefreshToken({ + connectorId: oauthState.connectorId, + accessToken: formattedToken, + refreshToken: tokenResult.refreshToken, + expiresIn: tokenResult.expiresIn, + refreshTokenExpiresIn: tokenResult.refreshTokenExpiresIn, + tokenType: 'access_token', + }); + + // Clean up state + await oauthStateClient.delete(oauthState.id); + + // Redirect to Kibana with success indicator + const returnUrl = new URL(oauthState.kibanaReturnUrl); + returnUrl.searchParams.set('oauth_authorization', 'success'); + return res.redirected({ + headers: { + location: returnUrl.toString(), + }, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + routeLogger.error(`OAuth callback failed: ${errorMessage}`); + if (err instanceof Error && err.stack) { + routeLogger.debug(`OAuth callback error stack: ${err.stack}`); + } + return res.ok({ + headers: { 'content-type': 'text/html' }, + body: generateOAuthCallbackPage({ + title: 'OAuth Authorization Failed', + heading: 'Authorization Failed', + message: 'You can close this window and try again.', + details: errorMessage, + isSuccess: false, + }), + }); + } + }) + ) + ); +}; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts index c7648455e0a80..82c342f22d2df 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/index.ts @@ -13,7 +13,12 @@ import type { import type { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { getOldestIdleActionTask } from '@kbn/task-manager-plugin/server'; import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; -import { actionMappings, actionTaskParamsMappings, connectorTokenMappings } from './mappings'; +import { + actionMappings, + actionTaskParamsMappings, + connectorTokenMappings, + oauthStateMappings, +} from './mappings'; import { getActionsMigrations } from './actions_migrations'; import { getActionTaskParamsMigrations } from './action_task_params_migrations'; import type { InMemoryConnector, RawAction } from '../types'; @@ -24,11 +29,13 @@ import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + OAUTH_STATE_SAVED_OBJECT_TYPE, } from '../constants/saved_objects'; import { actionTaskParamsModelVersions, connectorModelVersions, connectorTokenModelVersions, + oauthStateModelVersions, } from './model_versions'; export function setupSavedObjects( @@ -129,13 +136,50 @@ export function setupSavedObjects( encryptedSavedObjects.registerType({ type: CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, - attributesToEncrypt: new Set(['token']), + attributesToEncrypt: new Set(['token', 'refreshToken']), attributesToIncludeInAAD: new Set([ 'connectorId', 'tokenType', 'expiresAt', 'createdAt', 'updatedAt', + 'refreshTokenExpiresAt', + ]), + }); + + savedObjects.registerType({ + name: OAUTH_STATE_SAVED_OBJECT_TYPE, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'agnostic', + mappings: oauthStateMappings, + management: { + importableAndExportable: false, + }, + modelVersions: oauthStateModelVersions, + excludeOnUpgrade: async () => { + // Clean up expired states older than 1 hour + const oneHourAgo = new Date(Date.now() - 3600000).toISOString(); + return { + bool: { + must: [{ term: { type: 'oauth_state' } }, { range: { expiresAt: { lt: oneHourAgo } } }], + }, + }; + }, + }); + + encryptedSavedObjects.registerType({ + type: OAUTH_STATE_SAVED_OBJECT_TYPE, + attributesToEncrypt: new Set(['codeVerifier']), + attributesToIncludeInAAD: new Set([ + 'state', + 'connectorId', + 'redirectUri', + 'authorizationUrl', + 'scope', + 'createdAt', + 'expiresAt', + 'createdBy', ]), }); } diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts index a39deae207a31..d23836ef00487 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/mappings.ts @@ -92,5 +92,48 @@ export const connectorTokenMappings: SavedObjectsTypeMappingDefinition = { // updatedAt: { // type: 'date', // }, + // refreshToken: { + // type: 'binary', + // }, + // refreshTokenExpiresAt: { + // type: 'date', + // }, + }, +}; + +export const oauthStateMappings: SavedObjectsTypeMappingDefinition = { + dynamic: false, + properties: { + state: { + type: 'keyword', + }, + connectorId: { + type: 'keyword', + }, + expiresAt: { + type: 'date', + }, + // NO NEED TO BE INDEXED + // codeVerifier: { + // type: 'binary', + // }, + // redirectUri: { + // type: 'keyword', + // }, + // authorizationUrl: { + // type: 'keyword', + // }, + // scope: { + // type: 'keyword', + // }, + // createdAt: { + // type: 'date', + // }, + // createdBy: { + // type: 'keyword', + // }, + // kibanaReturnUrl: { + // type: 'keyword', + // }, }, }; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/connector_token_model_versions.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/connector_token_model_versions.ts index 458d4ade5b46b..267f9c8138c99 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/connector_token_model_versions.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/connector_token_model_versions.ts @@ -6,7 +6,10 @@ */ import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; -import { rawConnectorTokenSchemaV1 } from '../schemas/raw_connector_token'; +import { + rawConnectorTokenSchemaV1, + rawConnectorTokenSchemaV2, +} from '../schemas/raw_connector_token'; export const connectorTokenModelVersions: SavedObjectsModelVersionMap = { '1': { @@ -16,4 +19,11 @@ export const connectorTokenModelVersions: SavedObjectsModelVersionMap = { create: rawConnectorTokenSchemaV1, }, }, + '2': { + changes: [], // backwards-compatible schema evolution + schemas: { + forwardCompatibility: rawConnectorTokenSchemaV2.extends({}, { unknowns: 'ignore' }), + create: rawConnectorTokenSchemaV2, + }, + }, }; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts index f573864ffbec4..60a0cb4fe4f3f 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/index.ts @@ -8,3 +8,4 @@ export { connectorModelVersions } from './connector_model_versions'; export { connectorTokenModelVersions } from './connector_token_model_versions'; export { actionTaskParamsModelVersions } from './action_task_params_model_versions'; +export { oauthStateModelVersions } from './oauth_state_model_versions'; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/oauth_state_model_versions.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/oauth_state_model_versions.ts new file mode 100644 index 0000000000000..598cc40960fa2 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/model_versions/oauth_state_model_versions.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawOAuthStateSchemaV1 } from '../schemas/raw_oauth_state'; + +export const oauthStateModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawOAuthStateSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawOAuthStateSchemaV1, + }, + }, +}; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_connector_token/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_connector_token/index.ts index 66d20c740f8d2..5942efa16e1c5 100644 --- a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_connector_token/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_connector_token/index.ts @@ -6,3 +6,4 @@ */ export { rawConnectorTokenSchema as rawConnectorTokenSchemaV1 } from './v1'; +export { rawConnectorTokenSchema as rawConnectorTokenSchemaV2 } from './v2'; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_connector_token/v2.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_connector_token/v2.ts new file mode 100644 index 0000000000000..b9e17acf4e471 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_connector_token/v2.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { rawConnectorTokenSchema as rawConnectorTokenSchemaV1 } from './v1'; + +export const rawConnectorTokenSchema = rawConnectorTokenSchemaV1.extends({ + expiresAt: schema.maybe(schema.string()), // turned into an optional field + refreshToken: schema.maybe(schema.string()), + refreshTokenExpiresAt: schema.maybe(schema.string()), +}); diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_oauth_state/index.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_oauth_state/index.ts new file mode 100644 index 0000000000000..cc63d8597a840 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_oauth_state/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 { rawOAuthStateSchema as rawOAuthStateSchemaV1 } from './v1'; diff --git a/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_oauth_state/v1.ts b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_oauth_state/v1.ts new file mode 100644 index 0000000000000..f611b449c01ef --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/saved_objects/schemas/raw_oauth_state/v1.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const rawOAuthStateSchema = schema.object({ + state: schema.string(), + codeVerifier: schema.string(), + connectorId: schema.string(), + redirectUri: schema.string(), + scope: schema.maybe(schema.string()), + kibanaReturnUrl: schema.string(), // in case of OAuth success, redirect to this URL + createdAt: schema.string(), + expiresAt: schema.string(), + createdBy: schema.maybe(schema.string()), +}); diff --git a/x-pack/platform/plugins/shared/actions/server/types.ts b/x-pack/platform/plugins/shared/actions/server/types.ts index 2333ccec5ddcb..615495e4782ff 100644 --- a/x-pack/platform/plugins/shared/actions/server/types.ts +++ b/x-pack/platform/plugins/shared/actions/server/types.ts @@ -297,12 +297,15 @@ export interface ResponseSettings { } export interface ConnectorToken extends SavedObjectAttributes { + id?: string; connectorId: string; tokenType: string; token: string; - expiresAt: string; + expiresAt?: string; createdAt: string; updatedAt?: string; + refreshToken?: string; + refreshTokenExpiresAt?: string; } // This unallowlist should only contain connector types that require a request or API key for diff --git a/x-pack/platform/plugins/shared/cloud_connect/public/application/components/onboarding/connection_wizard/index.tsx b/x-pack/platform/plugins/shared/cloud_connect/public/application/components/onboarding/connection_wizard/index.tsx index 6424f5da54184..9a2fca47d6809 100644 --- a/x-pack/platform/plugins/shared/cloud_connect/public/application/components/onboarding/connection_wizard/index.tsx +++ b/x-pack/platform/plugins/shared/cloud_connect/public/application/components/onboarding/connection_wizard/index.tsx @@ -180,6 +180,7 @@ export const ConnectionWizard: React.FC = ({ onConnect }) <> ; const mockedPackagePolicyService = packagePolicyService as jest.Mocked; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/transform/install.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/transform/install.ts index 6f9d8e17a2a1a..d9a68cc783510 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/transform/install.ts @@ -54,10 +54,11 @@ import { STACK_COMPONENT_TEMPLATE_ECS_MAPPINGS, } from '../../../../constants'; +import { appContextService } from '../../../app_context'; + import { deleteTransforms } from './remove'; import { getDestinationIndexAliases } from './transform_utils'; import { loadMappingForTransform } from './mappings'; -import { appContextService } from '../../../app_context'; const DEFAULT_TRANSFORM_TEMPLATES_PRIORITY = 250; enum TRANSFORM_SPECS_TYPES { diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts index 282b190d95587..725f9a4b4c993 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.test.ts @@ -83,7 +83,7 @@ describe('CrowdStrikeTokenManager', () => { jest.spyOn(connectorTokenClientMock, 'updateOrReplace').mockImplementation(async (options) => { // Calculate expiration time based on expiresInSec const expiresAt = new Date( - options.tokenRequestDate + options.expiresInSec * 1000 + options.tokenRequestDate + (options.expiresInSec ?? 0) * 1000 ).toISOString(); cachedTokenMock = createConnectorTokenMock({ connectorId: options.connectorId, diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.ts index ff21eee662a22..6e128b2edc359 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/crowdstrike/token_manager.ts @@ -45,7 +45,7 @@ export class CrowdStrikeTokenManager { const now = new Date(); now.setSeconds(now.getSeconds() - 5); // 5-second safety margin - const isExpired = token.expiresAt < now.toISOString(); + const isExpired = token.expiresAt ? token.expiresAt < now.toISOString() : true; if (isExpired) { this.logger.debug(`Cached access token expired at [${token.expiresAt}]`); diff --git a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.ts b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.ts index ef6159995cf0a..f4d26e37fb348 100644 --- a/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.ts +++ b/x-pack/platform/plugins/shared/stack_connectors/server/connector_types/microsoft_defender_endpoint/o_auth_token_manager.ts @@ -47,7 +47,7 @@ export class OAuthTokenManager { const now = new Date(); now.setSeconds(now.getSeconds() - 5); // Allows for a threshold of -5s before considering the token expired - const isExpired = token.expiresAt < now.toISOString(); + const isExpired = token.expiresAt ? token.expiresAt < now.toISOString() : true; if (isExpired) { this.logger.debug(`Cached access token expired at [${token.expiresAt}]`); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.tsx index 07c47905bd27e..4ac6639a558ab 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/condition_editor.tsx @@ -125,8 +125,20 @@ export function ConditionEditor(props: ConditionEditorProps) { id="xpack.streams.conditionEditor.arrayOperatorHelpText" defaultMessage="Use {includes} for array/multivalue fields. For partial matches, use {contains}." values={{ - includes: includes, - contains: contains, + includes: ( + + {i18n.translate('xpack.streams.conditionEditor.strong.includesLabel', { + defaultMessage: 'includes', + })} + + ), + contains: ( + + {i18n.translate('xpack.streams.conditionEditor.strong.containsLabel', { + defaultMessage: 'contains', + })} + + ), }} /> ) : undefined diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/use_oauth_authorize.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/use_oauth_authorize.tsx new file mode 100644 index 0000000000000..3394035437910 --- /dev/null +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/use_oauth_authorize.tsx @@ -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 { useState, useCallback } from 'react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { INTERNAL_BASE_ACTION_API_PATH } from '../constants'; + +interface OAuthAuthorizeResponse { + authorizationUrl: string; + state: string; +} + +export function useOAuthAuthorize() { + const { http } = useKibana().services; + const [isAuthorizing, setIsAuthorizing] = useState(false); + + const authorize = useCallback( + async (connectorId: string) => { + setIsAuthorizing(true); + try { + const { authorizationUrl } = await http!.post( + `${INTERNAL_BASE_ACTION_API_PATH}/connector/${encodeURIComponent( + connectorId + )}/_start_oauth_flow`, + { + body: JSON.stringify({}), + } + ); + + // Open authorization URL in a new tab + window.open(authorizationUrl, '_blank', 'noopener,noreferrer'); + + return true; + } catch (error) { + throw error; + } finally { + setIsAuthorizing(false); + } + }, + [http] + ); + + return { + authorize, + isAuthorizing, + }; +} 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 new file mode 100644 index 0000000000000..9b2a30a83fe53 --- /dev/null +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/lib/check_oauth_auth_code.ts @@ -0,0 +1,26 @@ +/* + * 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 { ActionConnector } from '../../types'; + +/** + * Checks if a connector uses OAuth Authorization Code flow + * @param connector - The connector to check + * @returns True if the connector uses oauth_authorization_code auth type + */ +export function usesOAuthAuthorizationCode(connector: ActionConnector): boolean { + if (!connector || connector.isPreconfigured || connector.isSystemAction) { + return false; + } + + const config = connector.config as Record; + + return ( + config?.authType === 'oauth_authorization_code' || + (config?.auth as Record)?.type === 'oauth_authorization_code' + ); +} diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/footer.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/footer.tsx index ca9b6e8b583cc..f65f54fd46f3d 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/footer.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/footer.tsx @@ -15,6 +15,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { ActionConnector } from '../../../../types'; +import { usesOAuthAuthorizationCode } from '../../../lib/check_oauth_auth_code'; interface Props { onClose: () => void; @@ -23,6 +25,9 @@ interface Props { showButtons: boolean; disabled: boolean; onClickSave: () => void; + connector: ActionConnector; + onAuthorize?: () => void; + isAuthorizing?: boolean; } const FlyoutFooterComponent: React.FC = ({ @@ -32,6 +37,9 @@ const FlyoutFooterComponent: React.FC = ({ showButtons, disabled, onClickSave, + connector, + onAuthorize, + isAuthorizing, }) => { return ( @@ -44,29 +52,56 @@ const FlyoutFooterComponent: React.FC = ({ - {showButtons && ( - - {isSaved ? ( - - ) : ( - - )} - - )} + + {usesOAuthAuthorizationCode(connector) && onAuthorize && ( + + + {isAuthorizing ? ( + + ) : ( + + )} + + + )} + {showButtons && ( + + + {isSaved ? ( + + ) : ( + + )} + + + )} + diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx index 6e1e0c91a62aa..477498ed8bef8 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/action_connector_form/edit_connector_flyout/index.tsx @@ -40,6 +40,7 @@ import { hasSaveActionsCapability } from '../../../lib/capabilities'; import { TestConnectorForm } from '../test_connector_form'; import { ConnectorRulesList } from '../connector_rules_list'; import { useExecuteConnector } from '../../../hooks/use_execute_connector'; +import { useOAuthAuthorize } from '../../../hooks/use_oauth_authorize'; import { FlyoutHeader } from './header'; import { FlyoutFooter } from './footer'; @@ -78,6 +79,7 @@ const EditConnectorFlyoutComponent: React.FC = ({ const { docLinks, application: { capabilities }, + notifications: { toasts }, } = useKibana().services; const isMounted = useRef(false); @@ -130,6 +132,7 @@ const EditConnectorFlyoutComponent: React.FC = ({ const [showConfirmModal, setShowConfirmModal] = useState(false); const [isEdit, setIsEdit] = useState(true); const [isSaved, setIsSaved] = useState(false); + const { authorize, isAuthorizing } = useOAuthAuthorize(); const { preSubmitValidator, submit, isValid: isFormValid, isSubmitting } = formState; const hasErrors = isFormValid === false; const isSaving = isUpdatingConnector || isSubmitting || isExecutingConnector; @@ -238,6 +241,36 @@ const EditConnectorFlyoutComponent: React.FC = ({ onFormModifiedChange, ]); + const handleAuthorize = useCallback(async () => { + if (!connector) return; + + try { + await authorize(connector.id); + + toasts.addSuccess({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.authorizeSuccessTitle', + { defaultMessage: 'Authorization window opened' } + ), + text: i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.authorizeSuccessText', + { + defaultMessage: + 'Complete the authorization in the new window, then test your connector.', + } + ), + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.editConnectorForm.authorizeErrorTitle', + { defaultMessage: 'Failed to start authorization' } + ), + text: error.message, + }); + } + }, [connector, authorize, toasts]); + useEffect(() => { isMounted.current = true; @@ -380,6 +413,9 @@ const EditConnectorFlyoutComponent: React.FC = ({ disabled={disabled} showButtons={showButtons} onClickSave={onClickSave} + connector={connector} + onAuthorize={handleAuthorize} + isAuthorizing={isAuthorizing} /> {showConfirmModal && ( diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index a2428a9683ec5..af7291f113cca 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -124,6 +124,28 @@ const ActionsConnectorsList = ({ chrome.docTitle.change(getCurrentDocTitle('connectors')); }, [chrome, setBreadcrumbs]); + // Check for OAuth authorization success and show toast notification + useEffect(() => { + const params = new URLSearchParams(location.search); + if (params.get('oauth_authorization') === 'success') { + toasts.addSuccess({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.oauthAuthorizationSuccessTitle', + { defaultMessage: 'Authorization successful' } + ), + text: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.oauthAuthorizationSuccessMessage', + { defaultMessage: 'Your connector has been authorized successfully.' } + ), + }); + + // Clean up the URL parameter + params.delete('oauth_authorization'); + const newUrl = `${location.pathname}${params.toString() ? `?${params.toString()}` : ''}`; + history.replace(newUrl); + } + }, [location.search, location.pathname, history, toasts]); + useEffect(() => { (async () => { try { diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 37c4fa0de0b7b..697da0cd627de 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -105,6 +105,7 @@ export default function ({ getService }: FtrProviderContext) { 'actions:.xmatters', 'actions:.xsoar', 'actions:connector_usage_reporting', + 'actions:oauth_state_cleanup', 'actions_telemetry', 'ad_hoc_run-backfill', 'alert-deletion', diff --git a/x-pack/solutions/observability/plugins/profiling/public/components/flamegraph/index.tsx b/x-pack/solutions/observability/plugins/profiling/public/components/flamegraph/index.tsx index f77c0197c0ee9..6b1445bc38389 100644 --- a/x-pack/solutions/observability/plugins/profiling/public/components/flamegraph/index.tsx +++ b/x-pack/solutions/observability/plugins/profiling/public/components/flamegraph/index.tsx @@ -147,6 +147,7 @@ export function FlameGraph({ if (!isWebGLAvailable) { return (