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
+ ? '
This window will close automatically, or you can close it manually.
'
+ : ''
+ }
+
+
+
+ `;
+}
+
+/**
+ * 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 (