diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index f1cdc83dbaa1f..af44f801040fd 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -454,9 +454,9 @@ paths: type: string responses: '200': - description: Returns an HTML page with error details if authorization fails. + description: Returns an HTML callback page. '302': - description: Redirects to Kibana on successful authorization. + description: Redirects to the return URL with authorization result query parameters. '401': description: User is not authenticated. summary: Handle OAuth callback @@ -465,6 +465,22 @@ paths: x-metaTags: - content: Kibana, Elastic Cloud Serverless name: product_name + /api/actions/connector/_oauth_callback_script: + get: + operationId: get-actions-connector-oauth-callback-script + parameters: [] + responses: {} + summary: '' + tags: [] + x-metaTags: + - content: Kibana, Elastic Cloud Serverless + name: product_name + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/actions/connector/_oauth_callback_script
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. /api/actions/connector/{id}: delete: description: |- diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index 85a4a50187e76..6078663d54340 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -525,9 +525,9 @@ paths: type: string responses: '200': - description: Returns an HTML page with error details if authorization fails. + description: Returns an HTML callback page. '302': - description: Redirects to Kibana on successful authorization. + description: Redirects to the return URL with authorization result query parameters. '401': description: User is not authenticated. summary: Handle OAuth callback @@ -536,6 +536,22 @@ paths: x-metaTags: - content: Kibana name: product_name + /api/actions/connector/_oauth_callback_script: + get: + operationId: get-actions-connector-oauth-callback-script + parameters: [] + responses: {} + summary: '' + tags: [] + x-metaTags: + - content: Kibana + name: product_name + description: |- + **Spaces method and path for this operation:** + +
get /s/{space_id}/api/actions/connector/_oauth_callback_script
+ + Refer to [Spaces](https://www.elastic.co/docs/deploy-manage/manage-spaces) for more information. /api/actions/connector/{id}: delete: description: |- diff --git a/package.json b/package.json index ca715f189692c..4417063eafa9a 100644 --- a/package.json +++ b/package.json @@ -914,6 +914,7 @@ "@kbn/response-ops-alerts-filters-form": "link:x-pack/platform/packages/shared/response-ops/alerts-filters-form", "@kbn/response-ops-alerts-table": "link:x-pack/platform/packages/shared/response-ops/alerts-table", "@kbn/response-ops-form-generator": "link:x-pack/platform/packages/shared/response-ops/form-generator", + "@kbn/response-ops-oauth-hooks": "link:x-pack/platform/packages/shared/response-ops/oauth-hooks", "@kbn/response-ops-recurring-schedule-form": "link:x-pack/platform/packages/shared/response-ops/recurring-schedule-form", "@kbn/response-ops-retry-service": "link:x-pack/platform/packages/shared/response-ops/retry-service", "@kbn/response-ops-rule-form": "link:x-pack/platform/packages/shared/response-ops/rule_form", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 01e1b44b9333c..59978e1aca896 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -181,7 +181,7 @@ pageLoadAssetSize: telemetryManagementSection: 5522 timelines: 192134 transform: 16515 - triggersActionsUi: 112000 + triggersActionsUi: 126052 uiActions: 35278 uiActionsEnhanced: 17373 unifiedDocViewer: 14513 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 b314c62448098..f370fdf707cbd 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 @@ -150,7 +150,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ml-module": "cb77705b41ea0a35d8ba79b19014a30069e0e93a2cfb7ae8c6f20a79207d5daa", "ml-trained-model": "133305438dc0b60a6660c44f0d8183ad5ba079db8fdd4e4f4b5ab3a09d2f29b8", "monitoring-telemetry": "fa7c4f2a099b4f0539e571372a598601c2a0c65ba50f6c34df23b4d6925cdc53", - "oauth_state": "8902d67a5fb68ccea1f3a63100dc45b84a764c786dd13d77b75ca5a901f42335", + "oauth_state": "3938a56c1eeb6529ed2bb0f9026463063287ca1a29c285e4a7ce40b87faf359b", "observability-onboarding-state": "b656db675800bfee8a2ddb5bf73b543542c7a7db64ed268ab5adcae6910773d2", "osquery-manager-usage-metric": "8833b9f812e9179897444c395761f9911945cfb77de9869c4e9b6ee6eeb0f573", "osquery-pack": "7ee940ea04c9c562406977efaa213050b2079c5bcc4e06ec56c8be6a85eb5ccd", @@ -1002,7 +1002,7 @@ describe('checking migration metadata changes on all registered SO types', () => "oauth_state|global: a665293b001c9e7b6e92c0a50a553b8163dbcd41", "oauth_state|mappings: 04721e2fa836fed1f3f2e9c343d96ec5304f8f09", "oauth_state|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", - "oauth_state|10.1.0: 8324da0f847d48f9b0de638008110b966275a95f53d3efc9242fe25f5a2f1f13", + "oauth_state|10.1.0: 236a9b2c3c43ceb53d30058aa6e5d82359abd4288cf1b7fcce954cd00ee53def", "====================================================================================", "observability-onboarding-state|global: c226ba4dd0412c2d7fd7a01976461e9da00b78bf", "observability-onboarding-state|mappings: d6efe91e6efcc5e1b41fac37b731b715182939ce", diff --git a/tsconfig.base.json b/tsconfig.base.json index 5a69e5fc06384..b995d3ef20296 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1844,6 +1844,8 @@ "@kbn/response-ops-alerts-table/*": ["x-pack/platform/packages/shared/response-ops/alerts-table/*"], "@kbn/response-ops-form-generator": ["x-pack/platform/packages/shared/response-ops/form-generator"], "@kbn/response-ops-form-generator/*": ["x-pack/platform/packages/shared/response-ops/form-generator/*"], + "@kbn/response-ops-oauth-hooks": ["x-pack/platform/packages/shared/response-ops/oauth-hooks"], + "@kbn/response-ops-oauth-hooks/*": ["x-pack/platform/packages/shared/response-ops/oauth-hooks/*"], "@kbn/response-ops-recurring-schedule-form": ["x-pack/platform/packages/shared/response-ops/recurring-schedule-form"], "@kbn/response-ops-recurring-schedule-form/*": ["x-pack/platform/packages/shared/response-ops/recurring-schedule-form/*"], "@kbn/response-ops-retry-service": ["x-pack/platform/packages/shared/response-ops/retry-service"], diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/index.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/index.ts new file mode 100644 index 0000000000000..725e891d3936d --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { + useConnectorOAuthConnect, + OAuthRedirectMode, + type ConnectorOAuthConnectProps, + type ConnectorOAuthConnect, +} from './use_connector_oauth_connect'; + +export { + useConnectorOAuthDisconnect, + type ConnectorOAuthDisconnectProps, + type ConnectorOAuthDisconnect, +} from './use_connector_oauth_disconnect'; + +export { useOAuthRedirectResult, type OAuthRedirectResultProps } from './use_oauth_redirect_result'; diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_connect.test.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_connect.test.ts new file mode 100644 index 0000000000000..15a99c5849c69 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_connect.test.ts @@ -0,0 +1,369 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('@kbn/kibana-react-plugin/public'); + +const mockUseMutation = jest.fn(); +jest.mock('@kbn/react-query', () => ({ + useMutation: (...args: unknown[]) => mockUseMutation(...args), +})); + +import { renderHook, act } from '@testing-library/react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useConnectorOAuthConnect, OAuthRedirectMode } from './use_connector_oauth_connect'; +import { OAuthAuthorizationStatus } from '@kbn/actions-plugin/common'; +import { OAUTH_BROADCAST_CHANNEL_NAME } from '../oauth'; + +const mockHttpPost = jest.fn(); +(useKibana as jest.Mock).mockReturnValue({ services: { http: { post: mockHttpPost } } }); + +class MockBroadcastChannel { + static instances: MockBroadcastChannel[] = []; + name: string; + onmessage: ((event: MessageEvent) => void) | null = null; + close = jest.fn(); + + constructor(name: string) { + this.name = name; + MockBroadcastChannel.instances.push(this); + } + + postMessage = jest.fn(); +} + +describe('useConnectorOAuthConnect', () => { + const originalBroadcastChannel = globalThis.BroadcastChannel; + const originalWindowOpen = window.open; + const originalLocationAssign = window.location.assign; + + let mockMutate: jest.Mock; + let capturedMutationOptions: Record; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + MockBroadcastChannel.instances = []; + globalThis.BroadcastChannel = MockBroadcastChannel as never; + window.open = jest.fn(); + Object.defineProperty(window, 'location', { + value: { ...window.location, assign: jest.fn(), href: 'http://localhost/app/connectors' }, + writable: true, + }); + + mockMutate = jest.fn(); + mockUseMutation.mockImplementation((options: Record) => { + capturedMutationOptions = options; + return { mutate: mockMutate, isLoading: false }; + }); + + mockHttpPost.mockResolvedValue({ + authorizationUrl: 'https://oauth.provider/authorize?code=abc', + }); + }); + + afterEach(() => { + jest.useRealTimers(); + globalThis.BroadcastChannel = originalBroadcastChannel; + window.open = originalWindowOpen; + Object.defineProperty(window, 'location', { + value: { ...window.location, assign: originalLocationAssign }, + writable: true, + }); + }); + + it('returns initial state', () => { + const { result } = renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + }) + ); + + expect(result.current.isConnecting).toBe(false); + expect(result.current.isAwaitingCallback).toBe(false); + expect(typeof result.current.connect).toBe('function'); + }); + + it('calls mutate with auto_close=true in the returnUrl for NewTab mode', () => { + const { result } = renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + returnUrl: 'http://localhost/app/connectors', + }) + ); + + act(() => result.current.connect()); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const { returnUrl } = mockMutate.mock.calls[0][0]; + const url = new URL(returnUrl); + expect(url.searchParams.get('auto_close')).toBe('true'); + }); + + it('does not set auto_close in Redirect mode', () => { + const { result } = renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.Redirect, + returnUrl: 'http://localhost/app/connectors', + }) + ); + + act(() => result.current.connect()); + + const { returnUrl } = mockMutate.mock.calls[0][0]; + const url = new URL(returnUrl); + expect(url.searchParams.has('auto_close')).toBe(false); + }); + + it('sends returnUrl as undefined when not provided', () => { + const { result } = renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + }) + ); + + act(() => result.current.connect()); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const { returnUrl } = mockMutate.mock.calls[0][0]; + expect(returnUrl).toBeUndefined(); + }); + + it('encodes the connectorId in the mutation URL', () => { + renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'id/with special&chars', + redirectMode: OAuthRedirectMode.Redirect, + }) + ); + + const mutationFn = capturedMutationOptions.mutationFn as (args: { + returnUrl: string; + }) => Promise; + mutationFn({ returnUrl: 'http://localhost/app' }); + + expect(mockHttpPost).toHaveBeenCalledWith( + expect.stringContaining(encodeURIComponent('id/with special&chars')), + expect.anything() + ); + }); + + it('uses custom returnUrl when provided', () => { + const { result } = renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.Redirect, + returnUrl: 'https://custom.url/callback', + }) + ); + + act(() => result.current.connect()); + + const { returnUrl } = mockMutate.mock.calls[0][0]; + expect(returnUrl).toBe('https://custom.url/callback'); + }); + + describe('NewTab mode - onSuccess callback', () => { + it('opens a new tab and sets isAwaitingCallback on mutation success', () => { + const { result } = renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + onSuccess: jest.fn(), + }) + ); + + const onMutationSuccess = capturedMutationOptions.onSuccess as (data: { + authorizationUrl: string; + }) => void; + + act(() => { + onMutationSuccess({ authorizationUrl: 'https://oauth.provider/auth' }); + }); + + expect(window.open).toHaveBeenCalledWith('https://oauth.provider/auth', '_blank', 'noopener'); + expect(result.current.isAwaitingCallback).toBe(true); + }); + }); + + describe('NewTab mode - BroadcastChannel', () => { + it('invokes onSuccess when receiving a success message for the matching connectorId', () => { + const onSuccess = jest.fn(); + renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + onSuccess, + }) + ); + + const onMutationSuccess = capturedMutationOptions.onSuccess as (data: { + authorizationUrl: string; + }) => void; + act(() => onMutationSuccess({ authorizationUrl: 'https://oauth.provider/auth' })); + + const channel = MockBroadcastChannel.instances.find( + (c) => c.name === OAUTH_BROADCAST_CHANNEL_NAME + )!; + + act(() => { + channel.onmessage!({ + data: { connectorId: 'conn-1', status: OAuthAuthorizationStatus.Success }, + } as MessageEvent); + }); + + expect(onSuccess).toHaveBeenCalled(); + }); + + it('ignores BroadcastChannel messages for a different connectorId', () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + onSuccess, + onError, + }) + ); + + const onMutationSuccess = capturedMutationOptions.onSuccess as (data: { + authorizationUrl: string; + }) => void; + act(() => onMutationSuccess({ authorizationUrl: 'https://oauth.provider/auth' })); + + const channel = MockBroadcastChannel.instances.find( + (c) => c.name === OAUTH_BROADCAST_CHANNEL_NAME + )!; + + act(() => { + channel.onmessage!({ + data: { connectorId: 'different-id', status: OAuthAuthorizationStatus.Success }, + } as MessageEvent); + }); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('invokes onError when receiving an error message', () => { + const onError = jest.fn(); + renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + onError, + }) + ); + + const onMutationSuccess = capturedMutationOptions.onSuccess as (data: { + authorizationUrl: string; + }) => void; + act(() => onMutationSuccess({ authorizationUrl: 'https://oauth.provider/auth' })); + + const channel = MockBroadcastChannel.instances.find( + (c) => c.name === OAUTH_BROADCAST_CHANNEL_NAME + )!; + + act(() => { + channel.onmessage!({ + data: { + connectorId: 'conn-1', + status: OAuthAuthorizationStatus.Error, + error: 'Provider denied access', + }, + } as MessageEvent); + }); + + expect(onError).toHaveBeenCalledWith(new Error('Provider denied access')); + }); + }); + + describe('NewTab mode - timeout', () => { + it('fires onError when the timeout elapses', () => { + const onError = jest.fn(); + renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + timeout: 5000, + onError, + }) + ); + + const onMutationSuccess = capturedMutationOptions.onSuccess as (data: { + authorizationUrl: string; + }) => void; + act(() => onMutationSuccess({ authorizationUrl: 'https://oauth.provider/auth' })); + + act(() => jest.advanceTimersByTime(5000)); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.any(String) }) + ); + }); + + it('does not fire timeout if BroadcastChannel message arrives first', () => { + const onError = jest.fn(); + const onSuccess = jest.fn(); + renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.NewTab, + timeout: 5000, + onSuccess, + onError, + }) + ); + + const onMutationSuccess = capturedMutationOptions.onSuccess as (data: { + authorizationUrl: string; + }) => void; + act(() => onMutationSuccess({ authorizationUrl: 'https://oauth.provider/auth' })); + + const channel = MockBroadcastChannel.instances.find( + (c) => c.name === OAUTH_BROADCAST_CHANNEL_NAME + )!; + + act(() => { + channel.onmessage!({ + data: { connectorId: 'conn-1', status: OAuthAuthorizationStatus.Success }, + } as MessageEvent); + }); + + act(() => jest.advanceTimersByTime(5000)); + + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + }); + + describe('Redirect mode', () => { + it('calls window.location.assign on mutation success', () => { + renderHook(() => + useConnectorOAuthConnect({ + connectorId: 'conn-1', + redirectMode: OAuthRedirectMode.Redirect, + }) + ); + + const onMutationSuccess = capturedMutationOptions.onSuccess as (data: { + authorizationUrl: string; + }) => void; + + act(() => { + onMutationSuccess({ authorizationUrl: 'https://oauth.provider/auth' }); + }); + + expect(window.location.assign).toHaveBeenCalledWith('https://oauth.provider/auth'); + expect(window.open).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_connect.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_connect.ts new file mode 100644 index 0000000000000..8afdb49aa35d7 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_connect.ts @@ -0,0 +1,192 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { useMutation } from '@kbn/react-query'; +import { isError } from 'lodash'; +import type { StartOAuthFlowRequestBody, StartOAuthFlowResponse } from '@kbn/actions-plugin/common'; +import { + OAUTH_CALLBACK_QUERY_PARAMS, + OAuthAuthorizationStatus, + INTERNAL_BASE_ACTION_API_PATH, +} from '@kbn/actions-plugin/common'; +import { i18n } from '@kbn/i18n'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { OAUTH_BROADCAST_CHANNEL_NAME, type OAuthFlowCompletedMessage } from '../oauth'; + +export enum OAuthRedirectMode { + NewTab = 'new_tab', + Redirect = 'redirect', +} + +interface ConnectorOAuthConnectBaseProps { + connectorId: string; + returnUrl?: string; + onError?: (error: Error) => void; +} + +interface ConnectorOAuthConnectNewTabProps extends ConnectorOAuthConnectBaseProps { + redirectMode?: OAuthRedirectMode.NewTab; + timeout?: number; + onSuccess?: () => void; +} + +interface ConnectorOAuthConnectRedirectProps extends ConnectorOAuthConnectBaseProps { + redirectMode?: OAuthRedirectMode.Redirect; + timeout?: never; + onSuccess?: never; +} + +export type ConnectorOAuthConnectProps = + | ConnectorOAuthConnectNewTabProps + | ConnectorOAuthConnectRedirectProps; + +export interface ConnectorOAuthConnect { + connect: () => void; + cancelConnect: () => void; + isConnecting: boolean; + isAwaitingCallback: boolean; +} + +const DEFAULT_TIMEOUT_MS = 60 * 1000 * 10; + +/** + * Initiates the OAuth authorization code grant flow for a connector + * (via `connect()`). + * + * In `NewTab` mode, the hook listens for flow completion and invokes + * `onSuccess`/`onError` callbacks. `isAwaitingCallback` is `true` while waiting + * for the user to complete authorization in the other tab, and resets after flow + * completion or after `timeout` elapses. + * + * In `Redirect` mode, the page navigates away. `onSuccess` and + * `isAwaitingCallback` are not applicable. + * + * When `returnUrl` is provided, the OAuth callback redirects to that URL with + * result query parameters (requires `useOAuthRedirectResult` at that page). + * When omitted, the callback renders a self-contained HTML page. + */ +export const useConnectorOAuthConnect = ({ + connectorId, + redirectMode = OAuthRedirectMode.Redirect, + returnUrl, + timeout = DEFAULT_TIMEOUT_MS, + onSuccess, + onError, +}: ConnectorOAuthConnectProps): ConnectorOAuthConnect => { + const { + services: { http }, + } = useKibana<{ http: HttpStart }>(); + + const onSuccessRef = useRef(onSuccess); + const onErrorRef = useRef(onError); + onSuccessRef.current = onSuccess; + onErrorRef.current = onError; + + const [isAwaitingCallback, setIsAwaitingCallback] = useState(false); + + const handleAuthRedirect = useCallback( + (authorizationUrl: string) => { + switch (redirectMode) { + case OAuthRedirectMode.NewTab: + window.open(authorizationUrl, '_blank', 'noopener'); + return; + case OAuthRedirectMode.Redirect: + window.location.assign(authorizationUrl); + return; + } + }, + [redirectMode] + ); + + const { mutate: startOAuthFlow, isLoading: isConnecting } = useMutation< + StartOAuthFlowResponse, + Error, + StartOAuthFlowRequestBody + >({ + mutationFn: (request) => + http.post<{ authorizationUrl: string }>( + `${INTERNAL_BASE_ACTION_API_PATH}/connector/${encodeURIComponent( + connectorId + )}/_start_oauth_flow`, + { body: JSON.stringify({ returnUrl: request.returnUrl }) } + ), + onSuccess: ({ authorizationUrl }) => { + setIsAwaitingCallback(true); + handleAuthRedirect(authorizationUrl); + }, + onError: (error) => { + onErrorRef.current?.(isError(error) ? error : new Error(String(error))); + }, + }); + + const connect = useCallback(() => { + setIsAwaitingCallback(false); + let resolvedReturnUrl: string | undefined; + if (returnUrl) { + const url = new URL(returnUrl); + if (redirectMode === OAuthRedirectMode.NewTab) { + url.searchParams.set(OAUTH_CALLBACK_QUERY_PARAMS.AUTO_CLOSE, 'true'); + } + resolvedReturnUrl = url.toString(); + } + startOAuthFlow({ returnUrl: resolvedReturnUrl }); + }, [startOAuthFlow, redirectMode, returnUrl]); + + const cancelConnect = useCallback(() => { + setIsAwaitingCallback(false); + }, []); + + // Handle OAuth callback timeout + useEffect(() => { + if (redirectMode !== OAuthRedirectMode.NewTab || !isAwaitingCallback) { + return; + } + + const callbackTimeout = setTimeout(() => { + setIsAwaitingCallback(false); + onErrorRef.current?.( + new Error( + i18n.translate('xpack.responseOpsOAuthHooks.timeoutError', { + defaultMessage: 'OAuth authorization timed out', + }) + ) + ); + }, timeout); + return () => clearTimeout(callbackTimeout); + }, [redirectMode, isAwaitingCallback, timeout]); + + // Handle OAuth flow completed in new tab + useEffect(() => { + if (redirectMode !== OAuthRedirectMode.NewTab || !isAwaitingCallback) { + return; + } + + const broadcastChannel = new BroadcastChannel(OAUTH_BROADCAST_CHANNEL_NAME); + + broadcastChannel.onmessage = (event: MessageEvent) => { + if (event.data.connectorId !== connectorId) { + return; + } + if (event.data.status === OAuthAuthorizationStatus.Success) { + onSuccessRef.current?.(); + } else { + onErrorRef.current?.(new Error(event.data.error)); + } + setIsAwaitingCallback(false); + }; + return () => broadcastChannel.close(); + }, [connectorId, redirectMode, isAwaitingCallback]); + + return { + connect, + cancelConnect, + isConnecting, + isAwaitingCallback, + }; +}; diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_disconnect.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_disconnect.ts new file mode 100644 index 0000000000000..3109e2cb91472 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_connector_oauth_disconnect.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 { useMutation } from '@kbn/react-query'; +import { INTERNAL_BASE_ACTION_API_PATH } from '@kbn/actions-plugin/common'; +import type { HttpStart } from '@kbn/core-http-browser'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +export interface ConnectorOAuthDisconnectProps { + connectorId: string; + onSuccess?: () => void; + onError?: (error: Error) => void; +} + +export interface ConnectorOAuthDisconnect { + disconnect: () => void; + isDisconnecting: boolean; +} + +/** + * Disconnects a connector from its OAuth authorization by removing all stored + * access and refresh tokens (via `disconnect()`). + */ +export const useConnectorOAuthDisconnect = ({ + connectorId, + onSuccess, + onError, +}: ConnectorOAuthDisconnectProps): ConnectorOAuthDisconnect => { + const { + services: { http }, + } = useKibana<{ http: HttpStart }>(); + + const { mutate: disconnect, isLoading: isDisconnecting } = useMutation({ + mutationFn: () => + http.post( + `${INTERNAL_BASE_ACTION_API_PATH}/connector/${encodeURIComponent( + connectorId + )}/_oauth_disconnect` + ), + onSuccess: () => onSuccess?.(), + onError: (error) => onError?.(error), + }); + + return { + disconnect, + isDisconnecting, + }; +}; diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_oauth_redirect_result.test.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_oauth_redirect_result.test.ts new file mode 100644 index 0000000000000..b049156051de0 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_oauth_redirect_result.test.ts @@ -0,0 +1,139 @@ +/* + * 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. + */ + +const mockReplace = jest.fn(); +jest.mock('react-router-dom', () => ({ + useLocation: jest.fn(), + useHistory: jest.fn(() => ({ replace: mockReplace })), +})); + +import { renderHook } from '@testing-library/react'; +import { useLocation } from 'react-router-dom'; +import { useOAuthRedirectResult } from './use_oauth_redirect_result'; +import { OAUTH_BROADCAST_CHANNEL_NAME } from '../oauth'; + +const useLocationMock = useLocation as jest.MockedFunction; + +class MockBroadcastChannel { + static instances: MockBroadcastChannel[] = []; + name: string; + messages: unknown[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + + constructor(name: string) { + this.name = name; + MockBroadcastChannel.instances.push(this); + } + + postMessage(data: unknown) { + this.messages.push(data); + } + + close = jest.fn(); +} + +describe('useOAuthRedirectResult', () => { + const originalBroadcastChannel = globalThis.BroadcastChannel; + const originalWindowClose = window.close; + + beforeEach(() => { + jest.clearAllMocks(); + MockBroadcastChannel.instances = []; + globalThis.BroadcastChannel = MockBroadcastChannel as never; + window.close = jest.fn(); + }); + + afterEach(() => { + globalThis.BroadcastChannel = originalBroadcastChannel; + window.close = originalWindowClose; + }); + + const setLocation = (search: string, pathname = '/app/connectors') => { + useLocationMock.mockReturnValue({ + search, + pathname, + hash: '', + state: undefined, + key: 'default', + }); + }; + + it('does nothing when no oauth_authorization param is present', () => { + setLocation(''); + const onSuccess = jest.fn(); + const onError = jest.fn(); + + renderHook(() => useOAuthRedirectResult({ onSuccess, onError })); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('does nothing when connector_id param is missing', () => { + setLocation('?oauth_authorization=success'); + const onSuccess = jest.fn(); + + renderHook(() => useOAuthRedirectResult({ onSuccess })); + + expect(onSuccess).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('calls onSuccess and broadcasts success message', () => { + setLocation('?oauth_authorization=success&connector_id=conn-1'); + const onSuccess = jest.fn(); + + renderHook(() => useOAuthRedirectResult({ onSuccess })); + + expect(onSuccess).toHaveBeenCalledWith('conn-1'); + expect(MockBroadcastChannel.instances).toHaveLength(1); + expect(MockBroadcastChannel.instances[0].name).toBe(OAUTH_BROADCAST_CHANNEL_NAME); + expect(MockBroadcastChannel.instances[0].messages).toEqual([ + { connectorId: 'conn-1', status: 'success' }, + ]); + expect(MockBroadcastChannel.instances[0].close).toHaveBeenCalled(); + }); + + it('calls onError and broadcasts error message when authorization fails', () => { + setLocation('?oauth_authorization=error&connector_id=conn-2'); + const onError = jest.fn(); + + renderHook(() => useOAuthRedirectResult({ onError })); + + expect(onError).toHaveBeenCalledWith('conn-2', expect.any(Error)); + expect(MockBroadcastChannel.instances[0].messages).toEqual([ + { connectorId: 'conn-2', status: 'error', error: expect.any(String) }, + ]); + }); + + it('replaces the URL with OAuth params stripped', () => { + setLocation('?oauth_authorization=success&connector_id=conn-1&page=2'); + + renderHook(() => useOAuthRedirectResult({})); + + expect(mockReplace).toHaveBeenCalledWith(expect.stringContaining('page=2')); + expect(mockReplace).toHaveBeenCalledWith(expect.not.stringContaining('oauth_authorization')); + expect(mockReplace).toHaveBeenCalledWith(expect.not.stringContaining('connector_id')); + }); + + it('calls window.close() when auto_close=true', () => { + setLocation('?oauth_authorization=success&connector_id=conn-1&auto_close=true'); + + renderHook(() => useOAuthRedirectResult({})); + + expect(window.close).toHaveBeenCalled(); + }); + + it('does not call window.close() when auto_close is absent', () => { + setLocation('?oauth_authorization=success&connector_id=conn-1'); + + renderHook(() => useOAuthRedirectResult({})); + + expect(window.close).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_oauth_redirect_result.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_oauth_redirect_result.ts new file mode 100644 index 0000000000000..026b65ea19fcc --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/hooks/use_oauth_redirect_result.ts @@ -0,0 +1,88 @@ +/* + * 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 { useEffect, useRef } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { OAuthAuthorizationStatus, OAUTH_CALLBACK_QUERY_PARAMS } from '@kbn/actions-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { + OAUTH_BROADCAST_CHANNEL_NAME, + stripOAuthCallbackQueryParams, + type OAuthFlowCompletedErrorMessage, + type OAuthFlowCompletedSuccessMessage, +} from '../oauth'; + +export interface OAuthRedirectResultProps { + onSuccess?: (connectorId: string) => void; + onError?: (connectorId: string, error: Error) => void; +} + +/** + * Detects OAuth flow completion from URL query parameters set by the server-side + * callback route. If the `auto_close` query parameter is present, it closes the + * current tab after processing. + * + * Should be rendered in any page that serves as an OAuth return URL. + */ +export const useOAuthRedirectResult = ({ + onSuccess, + onError, +}: OAuthRedirectResultProps = {}): void => { + const location = useLocation(); + const history = useHistory(); + + const onSuccessRef = useRef(onSuccess); + const onErrorRef = useRef(onError); + onSuccessRef.current = onSuccess; + onErrorRef.current = onError; + + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const oauthAuthorization = searchParams.get(OAUTH_CALLBACK_QUERY_PARAMS.AUTHORIZATION_STATUS); + const connectorId = searchParams.get(OAUTH_CALLBACK_QUERY_PARAMS.CONNECTOR_ID); + if (!oauthAuthorization || !connectorId) { + return; + } + + const broadcastChannel = new BroadcastChannel(OAUTH_BROADCAST_CHANNEL_NAME); + + if (oauthAuthorization === OAuthAuthorizationStatus.Success) { + onSuccessRef.current?.(connectorId); + broadcastChannel.postMessage({ + connectorId, + status: OAuthAuthorizationStatus.Success, + } as OAuthFlowCompletedSuccessMessage); + } else { + const error = searchParams.get(OAUTH_CALLBACK_QUERY_PARAMS.ERROR); + const errorMessage = + error || + i18n.translate('xpack.responseOpsOAuthHooks.redirectResultError', { + defaultMessage: 'OAuth authorization failed', + }); + onErrorRef.current?.(connectorId, new Error(errorMessage)); + broadcastChannel.postMessage({ + connectorId, + status: OAuthAuthorizationStatus.Error, + error: errorMessage, + } as OAuthFlowCompletedErrorMessage); + } + broadcastChannel.close(); + + const autoClose = searchParams.get(OAUTH_CALLBACK_QUERY_PARAMS.AUTO_CLOSE); + + const updatedUrl = new URL( + stripOAuthCallbackQueryParams( + `${window.location.origin}${location.pathname}${location.search}` + ) + ); + history.replace(`${updatedUrl.pathname}${updatedUrl.search}`); + + if (autoClose === 'true') { + window.close(); + } + }, [location.search, history, location.pathname]); +}; diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/index.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/index.ts new file mode 100644 index 0000000000000..25d478308483b --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + useConnectorOAuthConnect, + OAuthRedirectMode, + type ConnectorOAuthConnectProps, + type ConnectorOAuthConnect, + useConnectorOAuthDisconnect, + type ConnectorOAuthDisconnectProps, + type ConnectorOAuthDisconnect, + useOAuthRedirectResult, + type OAuthRedirectResultProps, +} from './hooks'; diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/jest.config.js b/x-pack/platform/packages/shared/response-ops/oauth-hooks/jest.config.js new file mode 100644 index 0000000000000..298efa10cd6d6 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/jest.config.js @@ -0,0 +1,12 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../..', + roots: ['/x-pack/platform/packages/shared/response-ops/oauth-hooks'], +}; diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/kibana.jsonc b/x-pack/platform/packages/shared/response-ops/oauth-hooks/kibana.jsonc new file mode 100644 index 0000000000000..1dce41988d875 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-browser", + "id": "@kbn/response-ops-oauth-hooks", + "owner": "@elastic/response-ops", + "group": "platform", + "visibility": "shared" +} diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/oauth.test.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/oauth.test.ts new file mode 100644 index 0000000000000..77637b191761c --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/oauth.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { stripOAuthCallbackQueryParams } from './oauth'; + +describe('stripOAuthCallbackQueryParams', () => { + it('removes all OAuth callback query params', () => { + const url = + 'https://localhost:5601/app/connectors?oauth_authorization=success&connector_id=abc&auto_close=true'; + expect(stripOAuthCallbackQueryParams(url)).toBe('https://localhost:5601/app/connectors'); + }); + + it('preserves non-OAuth query params', () => { + const url = + 'https://localhost:5601/app/connectors?page=1&oauth_authorization=error&connector_id=abc'; + expect(stripOAuthCallbackQueryParams(url)).toBe('https://localhost:5601/app/connectors?page=1'); + }); + + it('returns the URL unchanged when no OAuth params are present', () => { + const url = 'https://localhost:5601/app/connectors?page=1&sort=name'; + expect(stripOAuthCallbackQueryParams(url)).toBe(url); + }); + + it('handles URLs with no query params', () => { + const url = 'https://localhost:5601/app/connectors'; + expect(stripOAuthCallbackQueryParams(url)).toBe('https://localhost:5601/app/connectors'); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/oauth.ts b/x-pack/platform/packages/shared/response-ops/oauth-hooks/oauth.ts new file mode 100644 index 0000000000000..1d5706b202486 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/oauth.ts @@ -0,0 +1,45 @@ +/* + * 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 { + OAUTH_CALLBACK_QUERY_PARAMS, + OAUTH_BROADCAST_CHANNEL_NAME, + type OAuthAuthorizationStatus, +} from '@kbn/actions-plugin/common'; + +export { OAUTH_BROADCAST_CHANNEL_NAME }; + +interface OAuthFlowCompletedMessageBase { + connectorId: string; +} + +export interface OAuthFlowCompletedSuccessMessage extends OAuthFlowCompletedMessageBase { + status: OAuthAuthorizationStatus.Success; +} + +export interface OAuthFlowCompletedErrorMessage extends OAuthFlowCompletedMessageBase { + status: OAuthAuthorizationStatus.Error; + error: string; +} + +export type OAuthFlowCompletedMessage = + | OAuthFlowCompletedSuccessMessage + | OAuthFlowCompletedErrorMessage; + +/** + * Strips all OAuth callback query parameters from a URL string. + * + * @param urlStr - An absolute URL string to sanitize. + * @returns The URL string with OAuth callback query parameters removed. + */ +export const stripOAuthCallbackQueryParams = (urlStr: string): string => { + const url = new URL(urlStr); + Object.values(OAUTH_CALLBACK_QUERY_PARAMS).forEach((param) => { + url.searchParams.delete(param); + }); + return url.toString(); +}; diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/package.json b/x-pack/platform/packages/shared/response-ops/oauth-hooks/package.json new file mode 100644 index 0000000000000..09a0c5e317300 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/response-ops-oauth-hooks", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} diff --git a/x-pack/platform/packages/shared/response-ops/oauth-hooks/tsconfig.json b/x-pack/platform/packages/shared/response-ops/oauth-hooks/tsconfig.json new file mode 100644 index 0000000000000..ed05f95c4dc5b --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/oauth-hooks/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@kbn/tsconfig-base/tsconfig.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/kibana-react-plugin", + "@kbn/actions-plugin", + "@kbn/react-query", + "@kbn/i18n", + "@kbn/core-http-browser" + ] +} diff --git a/x-pack/platform/plugins/shared/actions/common/index.ts b/x-pack/platform/plugins/shared/actions/common/index.ts index f98d7b360a43f..f67daad538d07 100644 --- a/x-pack/platform/plugins/shared/actions/common/index.ts +++ b/x-pack/platform/plugins/shared/actions/common/index.ts @@ -86,3 +86,15 @@ export const DEFAULT_MICROSOFT_GRAPH_API_SCOPE = 'https://graph.microsoft.com/.d // OOMs, so that seems like a safe limit for now. export const MAX_EMAIL_BODY_LENGTH = 25 * 1000 * 1000; // 25MB export const DEFAULT_EMAIL_BODY_LENGTH = MAX_EMAIL_BODY_LENGTH; + +export type { + StartOAuthFlowRequestBody, + StartOAuthFlowPathParams, + StartOAuthFlowResponse, + DisconnectOAuthPathParams, +} from './routes/connector/apis/oauth'; +export { + OAuthAuthorizationStatus, + OAUTH_CALLBACK_QUERY_PARAMS, + OAUTH_BROADCAST_CHANNEL_NAME, +} from './oauth_callback'; diff --git a/x-pack/platform/plugins/shared/actions/common/oauth_callback.ts b/x-pack/platform/plugins/shared/actions/common/oauth_callback.ts new file mode 100644 index 0000000000000..ab6a620c729c2 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/oauth_callback.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. + */ + +export enum OAuthAuthorizationStatus { + Success = 'success', + Error = 'error', +} + +export const OAUTH_CALLBACK_QUERY_PARAMS = { + CONNECTOR_ID: 'connector_id', + AUTHORIZATION_STATUS: 'oauth_authorization', + ERROR: 'error', + AUTO_CLOSE: 'auto_close', +} as const; + +export const OAUTH_BROADCAST_CHANNEL_NAME = 'oauth_flow_completed'; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/index.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/index.ts new file mode 100644 index 0000000000000..dae73e74427cf --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/index.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. + */ + +export { + startOAuthFlowRequestBodySchema, + startOAuthFlowPathParamsSchema, + disconnectOAuthPathParamsSchema, +} from './schemas/latest'; + +export type { + StartOAuthFlowRequestBody, + StartOAuthFlowPathParams, + StartOAuthFlowResponse, + DisconnectOAuthPathParams, +} from './types/latest'; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/schemas/latest.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/schemas/latest.ts new file mode 100644 index 0000000000000..a8a89d97ddf9c --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/schemas/latest.ts @@ -0,0 +1,12 @@ +/* + * 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 { + startOAuthFlowRequestBodySchema, + startOAuthFlowPathParamsSchema, + disconnectOAuthPathParamsSchema, +} from './v1'; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/schemas/v1.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/schemas/v1.ts new file mode 100644 index 0000000000000..6b9fd2e615ef8 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/schemas/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 startOAuthFlowRequestBodySchema = schema.object({ + returnUrl: schema.maybe(schema.uri({ scheme: ['http', 'https'] })), +}); + +export const startOAuthFlowPathParamsSchema = schema.object({ + connectorId: schema.string(), +}); + +export const disconnectOAuthPathParamsSchema = schema.object({ + connectorId: schema.string(), +}); diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/types/latest.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/types/latest.ts new file mode 100644 index 0000000000000..ab3f7581b2043 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/types/latest.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 type * from './v1'; diff --git a/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/types/v1.ts b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/types/v1.ts new file mode 100644 index 0000000000000..49d8deaf61813 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/common/routes/connector/apis/oauth/types/v1.ts @@ -0,0 +1,22 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import type { + startOAuthFlowPathParamsSchema, + startOAuthFlowRequestBodySchema, + disconnectOAuthPathParamsSchema, +} from '../schemas/v1'; + +export type StartOAuthFlowRequestBody = TypeOf; +export type StartOAuthFlowPathParams = TypeOf; + +export interface StartOAuthFlowResponse { + authorizationUrl: string; +} + +export type DisconnectOAuthPathParams = TypeOf; 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 009b8b25a3a51..2695083f22289 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 @@ -169,34 +169,34 @@ export const getAxiosInstanceWithAuth = ({ return config; }); - // add a response interceptor to clean up saved tokens if necessary if (connectorTokenClient) { - const { onFulfilled, onRejected } = getDeleteTokenAxiosInterceptor({ - connectorTokenClient, - connectorId, - }); - 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, - }); + if (authTypeId === 'oauth_authorization_code') { + // Add a response interceptor to handle 401 errors for OAuth authz code grant connectors + axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + return handleOAuth401Error({ + error, + connectorId, + secrets: secrets as OAuth2AuthCodeParams, + connectorTokenClient, + logger, + configurationUtilities, + axiosInstance, + }); + } + return Promise.reject(error); } - return Promise.reject(error); - } - ); + ); + } else { + // add a response interceptor to clean up saved tokens if necessary + const { onFulfilled, onRejected } = getDeleteTokenAxiosInterceptor({ + connectorTokenClient, + connectorId, + }); + axiosInstance.interceptors.response.use(onFulfilled, onRejected); + } } const configureCtx = { 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 index 6280c69d69707..fe7cd1525f137 100644 --- 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 @@ -19,7 +19,7 @@ interface OAuthStateAttributes { state: string; codeVerifier: string; connectorId: string; - kibanaReturnUrl: string; + kibanaReturnUrl?: string; spaceId: string; createdAt: string; expiresAt: string; @@ -38,7 +38,7 @@ interface ConstructorOptions { interface CreateStateOptions { connectorId: string; - kibanaReturnUrl: string; + kibanaReturnUrl?: string; spaceId: string; createdBy?: string; } 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 c371b746e5ebd..d3aada9bcb97d 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/index.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/index.ts @@ -21,7 +21,8 @@ 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 { oauthCallbackRoute, oauthCallbackScriptRoute } from './oauth_callback'; +import { oauthDisconnectRoute } from './oauth_disconnect'; import type { ActionsConfigurationUtilities } from '../actions_config'; import { getGlobalExecutionLogRoute } from './get_global_execution_logs'; import { getGlobalExecutionKPIRoute } from './get_global_execution_kpi'; @@ -55,6 +56,8 @@ export function defineRoutes(opts: RouteOptions) { getOAuthAccessToken(router, licenseState, actionsConfigUtils); oauthAuthorizeRoute(router, licenseState, logger, core, oauthRateLimiter); oauthCallbackRoute(router, licenseState, actionsConfigUtils, logger, core, oauthRateLimiter); + oauthCallbackScriptRoute(router); + oauthDisconnectRoute(router, licenseState, logger, core); getAllConnectorsIncludingSystemRoute(router, licenseState); listTypesWithSystemRoute(router, licenseState); } diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts index 8842aa92f2a3f..039039693f213 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.test.ts @@ -71,7 +71,10 @@ const createMockCoreSetup = (publicBaseUrl: string | undefined = KIBANA_URL) => }); const createMockContext = ( - currentUser: { username: string } | null = { username: 'testuser' } + currentUser: { username: string; profile_uid?: string } | null = { + username: 'testuser', + profile_uid: 'test-profile-uid', + } ) => ({ core: Promise.resolve({ security: { @@ -129,7 +132,7 @@ describe('oauthAuthorizeRoute', () => { expect(config.path).toBe('/internal/actions/connector/{connectorId}/_start_oauth_flow'); }); - it('throws when no current user', async () => { + it('returns unauthorized when no current user', async () => { const [, handler] = registerRoute(); const context = createMockContext(null); const req = httpServerMock.createKibanaRequest({ @@ -140,14 +143,31 @@ describe('oauthAuthorizeRoute', () => { await handler(context, req, res); - expect(res.customError).toHaveBeenCalledWith( - expect.objectContaining({ - statusCode: 500, - body: { - message: 'User should be authenticated to initiate OAuth authorization.', - }, - }) - ); + expect(res.unauthorized).toHaveBeenCalledWith({ + body: { + message: 'User should be authenticated to initiate OAuth authorization.', + }, + }); + }); + + it('returns error when profile UID is missing', async () => { + const [, handler] = registerRoute(); + const context = createMockContext({ username: 'testuser' }); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + body: {}, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.customError).toHaveBeenCalledWith({ + statusCode: 500, + body: { + message: 'Unable to retrieve Kibana user profile ID.', + }, + }); + expect(mockRateLimiter.log).not.toHaveBeenCalled(); }); it('returns 429 when rate limited', async () => { @@ -163,7 +183,7 @@ describe('oauthAuthorizeRoute', () => { await handler(context, req, res); - expect(mockRateLimiter.log).toHaveBeenCalledWith('testuser', 'authorize'); + expect(mockRateLimiter.log).toHaveBeenCalledWith('test-profile-uid', 'authorize'); expect(res.customError).toHaveBeenCalledWith( expect.objectContaining({ statusCode: 429, @@ -273,7 +293,7 @@ describe('oauthAuthorizeRoute', () => { }); }); - it('uses default return URL when not provided', async () => { + it('omits return URL when not provided', async () => { mockOAuthServiceInstance.getOAuthConfig.mockResolvedValue({ authorizationUrl: 'https://provider.example.com/authorize', clientId: 'client-id', @@ -299,7 +319,7 @@ describe('oauthAuthorizeRoute', () => { expect(mockOAuthStateClientInstance.create).toHaveBeenCalledWith( expect.objectContaining({ - kibanaReturnUrl: `${KIBANA_URL}/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`, + kibanaReturnUrl: undefined, }) ); }); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts index 2330d8acc2f88..d1127df97186b 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_authorize.ts @@ -59,13 +59,27 @@ export const oauthAuthorizeRoute = ( // Check rate limit const currentUser = core.security.authc.getCurrentUser(); if (!currentUser) { - throw new Error('User should be authenticated to initiate OAuth authorization.'); + return res.unauthorized({ + body: { + message: 'User should be authenticated to initiate OAuth authorization.', + }, + }); + } + const { profile_uid } = currentUser; + + if (!profile_uid) { + return res.customError({ + statusCode: 500, + body: { + message: 'Unable to retrieve Kibana user profile ID.', + }, + }); } - const username = currentUser.username; - oauthRateLimiter.log(username, 'authorize'); - if (oauthRateLimiter.isRateLimited(username, 'authorize')) { + + oauthRateLimiter.log(profile_uid, 'authorize'); + if (oauthRateLimiter.isRateLimited(profile_uid, 'authorize')) { routeLogger.warn( - `OAuth authorize rate limit exceeded for user: ${username}, connector: ${connectorId}` + `OAuth authorize rate limit exceeded for user: ${profile_uid}, connector: ${connectorId}` ); return res.customError({ statusCode: 429, @@ -102,12 +116,13 @@ export const oauthAuthorizeRoute = ( const oauthConfig = await oauthService.getOAuthConfig(connectorId, namespace); const redirectUri = OAuthAuthorizationService.getRedirectUri(kibanaUrl); - // Validate and build return URL for post-OAuth redirect + // Validate return URL for post-OAuth redirect. + // When not provided, the callback route will render a self-contained + // HTML page instead of redirecting. const requestedReturnUrl = req.body?.returnUrl; - let kibanaReturnUrl: string; + let kibanaReturnUrl: string | undefined; if (requestedReturnUrl) { - // Security: Validate that returnUrl is same-origin to prevent open redirect attacks const returnUrlObj = new URL(requestedReturnUrl); const kibanaUrlObj = new URL(kibanaUrl); @@ -119,9 +134,6 @@ export const oauthAuthorizeRoute = ( }); } kibanaReturnUrl = requestedReturnUrl; - } else { - // Default to connectors management page - kibanaReturnUrl = `${kibanaUrl}/app/management/insightsAndAlerting/triggersActionsConnectors/connectors`; } // Create OAuth state with PKCE diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts index b894609778bd1..1be860e474288 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.test.ts @@ -9,7 +9,7 @@ jest.mock('./verify_access_and_context', () => ({ verifyAccessAndContext: jest.fn(), })); jest.mock('../lib/oauth_state_client'); -jest.mock('../lib/connector_token_client'); +jest.mock('../lib/user_connector_token_client'); jest.mock('../lib/request_oauth_authorization_code_token'); import { httpServiceMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; @@ -18,14 +18,14 @@ import { actionsConfigMock } from '../actions_config.mock'; import { verifyAccessAndContext } from './verify_access_and_context'; import { oauthCallbackRoute } from './oauth_callback'; import { OAuthStateClient } from '../lib/oauth_state_client'; -import { ConnectorTokenClient } from '../lib/connector_token_client'; +import { UserConnectorTokenClient } from '../lib/user_connector_token_client'; import { requestOAuthAuthorizationCodeToken } from '../lib/request_oauth_authorization_code_token'; const KIBANA_URL = 'https://kibana.example.com'; const MockOAuthStateClient = OAuthStateClient as jest.MockedClass; -const MockConnectorTokenClient = ConnectorTokenClient as jest.MockedClass< - typeof ConnectorTokenClient +const MockUserConnectorTokenClient = UserConnectorTokenClient as jest.MockedClass< + typeof UserConnectorTokenClient >; const mockRequestOAuthAuthorizationCodeToken = requestOAuthAuthorizationCodeToken as jest.MockedFunction< @@ -81,7 +81,10 @@ const createMockCoreSetup = () => ({ }); const createMockContext = ( - currentUser: { username: string } | null = { username: 'testuser' } + currentUser: { username: string; profile_uid?: string } | null = { + username: 'testuser', + profile_uid: 'test-profile-uid', + } ) => ({ core: Promise.resolve({ security: { @@ -115,7 +118,9 @@ describe('oauthCallbackRoute', () => { }); MockOAuthStateClient.mockImplementation(() => mockOAuthStateClientInstance as never); - MockConnectorTokenClient.mockImplementation(() => mockConnectorTokenClientInstance as never); + MockUserConnectorTokenClient.mockImplementation( + () => mockConnectorTokenClientInstance as never + ); }); const registerRoute = (coreSetup = createMockCoreSetup()) => { @@ -153,6 +158,23 @@ describe('oauthCallbackRoute', () => { ); }); + it('returns error page when profile UID is missing', async () => { + const [, handler] = registerRoute(); + const context = createMockContext({ username: 'testuser' }); + const req = httpServerMock.createKibanaRequest({ query: { code: 'abc', state: 'xyz' } }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.ok).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { 'content-type': 'text/html' }, + body: expect.stringContaining('Unable to retrieve Kibana user profile ID'), + }) + ); + expect(mockRateLimiter.log).not.toHaveBeenCalled(); + }); + it('returns rate limit page when rate limited', async () => { mockRateLimiter.isRateLimited.mockReturnValue(true); @@ -163,20 +185,30 @@ describe('oauthCallbackRoute', () => { await handler(context, req, res); - expect(mockRateLimiter.log).toHaveBeenCalledWith('testuser', 'callback'); + expect(mockRateLimiter.log).toHaveBeenCalledWith('test-profile-uid', 'callback'); expect(res.ok).toHaveBeenCalledWith( expect.objectContaining({ headers: { 'content-type': 'text/html' }, - body: expect.stringContaining('Too Many Requests'), + body: expect.stringContaining('Too many authorization attempts'), }) ); }); it('returns error page when OAuth error parameter is present', async () => { + mockOAuthStateClientInstance.get.mockResolvedValue({ + id: 'state-id', + state: 'valid-state', + codeVerifier: 'test-verifier', + connectorId: 'connector-1', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: '2025-01-01T00:10:00.000Z', + }); + const [, handler] = registerRoute(); const context = createMockContext(); const req = httpServerMock.createKibanaRequest({ - query: { error: 'access_denied', error_description: 'User cancelled' }, + query: { error: 'access_denied', error_description: 'User cancelled', state: 'valid-state' }, }); const res = httpServerMock.createResponseFactory(); @@ -185,12 +217,22 @@ describe('oauthCallbackRoute', () => { expect(res.ok).toHaveBeenCalledWith( expect.objectContaining({ headers: { 'content-type': 'text/html' }, - body: expect.stringContaining('Authorization Failed'), + body: expect.stringContaining('access_denied'), }) ); }); it('returns error page when code is missing', async () => { + mockOAuthStateClientInstance.get.mockResolvedValue({ + id: 'state-id', + state: 'some-state', + codeVerifier: 'test-verifier', + connectorId: 'connector-1', + spaceId: 'default', + createdAt: '2025-01-01T00:00:00.000Z', + expiresAt: '2025-01-01T00:10:00.000Z', + }); + const [, handler] = registerRoute(); const context = createMockContext(); const req = httpServerMock.createKibanaRequest({ @@ -203,7 +245,7 @@ describe('oauthCallbackRoute', () => { expect(res.ok).toHaveBeenCalledWith( expect.objectContaining({ headers: { 'content-type': 'text/html' }, - body: expect.stringContaining('Authorization Failed'), + body: expect.stringContaining('Missing required OAuth authorization code'), }) ); }); @@ -310,6 +352,7 @@ describe('oauthCallbackRoute', () => { expect(mockConnectorTokenClientInstance.deleteConnectorTokens).toHaveBeenCalledWith({ connectorId: 'connector-1', tokenType: 'access_token', + profileUid: 'test-profile-uid', }); expect(mockConnectorTokenClientInstance.createWithRefreshToken).toHaveBeenCalledWith({ connectorId: 'connector-1', @@ -318,6 +361,7 @@ describe('oauthCallbackRoute', () => { expiresIn: 3600, refreshTokenExpiresIn: undefined, tokenType: 'access_token', + profileUid: 'test-profile-uid', }); // Verify state cleanup @@ -326,12 +370,13 @@ describe('oauthCallbackRoute', () => { // Verify redirect expect(res.redirected).toHaveBeenCalledWith({ headers: { - location: 'https://kibana.example.com/app/connectors?oauth_authorization=success', + location: + 'https://kibana.example.com/app/connectors?oauth_authorization=success&connector_id=connector-1', }, }); }); - it('returns error page on token exchange failure', async () => { + it('redirects with error on token exchange failure', async () => { const mockOAuthState = { id: 'state-id', state: 'valid-state', @@ -369,15 +414,15 @@ describe('oauthCallbackRoute', () => { await handler(context, req, res); - expect(res.ok).toHaveBeenCalledWith( - expect.objectContaining({ - headers: { 'content-type': 'text/html' }, - body: expect.stringContaining('Token exchange failed'), - }) - ); + expect(res.redirected).toHaveBeenCalledWith({ + headers: { + location: + 'https://kibana.example.com/app/connectors?oauth_authorization=error&connector_id=connector-1&error=OAuth+authorization+failed', + }, + }); }); - it('returns error page when connector is missing required OAuth config', async () => { + it('redirects with error when connector is missing required OAuth config', async () => { const mockOAuthState = { id: 'state-id', state: 'valid-state', @@ -409,13 +454,12 @@ describe('oauthCallbackRoute', () => { await handler(context, req, res); - expect(res.ok).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.stringContaining( - 'Connector missing required OAuth configuration (clientId, clientSecret, tokenUrl)' - ), - }) - ); + expect(res.redirected).toHaveBeenCalledWith({ + headers: { + location: + 'https://kibana.example.com/app/connectors?oauth_authorization=error&connector_id=connector-1&error=OAuth+authorization+failed', + }, + }); }); it('calls verifyAccessAndContext with the license state', () => { diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts index bb3295a028cb6..94e1b8b034d16 100644 --- a/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_callback.ts @@ -6,12 +6,17 @@ */ import { schema } from '@kbn/config-schema'; -import type { CoreSetup, IRouter, Logger } from '@kbn/core/server'; +import type { CoreSetup, IRouter, KibanaResponseFactory, Logger } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; -import { escape } from 'lodash'; +import { capitalize, escape } from 'lodash'; import type { ActionsPluginsStart } from '../plugin'; import type { ILicenseState } from '../lib'; -import { BASE_ACTION_API_PATH } from '../../common'; +import { + BASE_ACTION_API_PATH, + OAuthAuthorizationStatus, + OAUTH_CALLBACK_QUERY_PARAMS, + OAUTH_BROADCAST_CHANNEL_NAME, +} from '../../common'; import type { ActionsRequestHandlerContext } from '../types'; import type { ActionsConfigurationUtilities } from '../actions_config'; import { DEFAULT_ACTION_ROUTE_SECURITY } from './constants'; @@ -19,8 +24,8 @@ import { verifyAccessAndContext } from './verify_access_and_context'; import { OAuthStateClient } from '../lib/oauth_state_client'; import { OAuthAuthorizationService } from '../lib/oauth_authorization_service'; import { requestOAuthAuthorizationCodeToken } from '../lib/request_oauth_authorization_code_token'; -import { ConnectorTokenClient } from '../lib/connector_token_client'; import type { OAuthRateLimiter } from '../lib/oauth_rate_limiter'; +import { UserConnectorTokenClient } from '../lib/user_connector_token_client'; const querySchema = schema.object({ code: schema.maybe( @@ -86,8 +91,34 @@ interface OAuthConnectorConfig { useBasicAuth?: boolean; } +type RespondWithErrorOptions = { + details: string; +} & ( + | { connectorId: string; returnUrl?: string } + | { connectorId?: undefined; returnUrl?: undefined } +); + +interface RespondWithSuccessOptions { + connectorId: string; + returnUrl?: string; +} + +interface OAuthCallbackBroadcast { + connectorId: string; + status: OAuthAuthorizationStatus; + error?: string; +} + +const AUTO_CLOSE_DELAY_SECONDS = 3; + /** - * Generates a styled OAuth callback page using EUI-like styling + * Path for the companion JS served by {@link oauthCallbackScriptRoute}. + * Loaded via `` - : '' - } - +
${icon}

${sanitisedHeading}

@@ -202,15 +229,132 @@ function generateOAuthCallbackPage({ ${sanitisedDetails ? `
${sanitisedDetails}
` : ''} ${ autoClose - ? '' + ? `` : '' }
+ `; } +const GENERIC_OAUTH_ERROR = i18n.translate('xpack.actions.oauthCallback.error.generic', { + defaultMessage: 'OAuth authorization failed', +}); + +const buildOAuthReturnUrl = ( + kibanaReturnUrl: string, + connectorId: string, + status: OAuthAuthorizationStatus, + errorMessage?: string +): string => { + const returnUrl = new URL(kibanaReturnUrl); + returnUrl.searchParams.set(OAUTH_CALLBACK_QUERY_PARAMS.AUTHORIZATION_STATUS, status); + returnUrl.searchParams.set(OAUTH_CALLBACK_QUERY_PARAMS.CONNECTOR_ID, connectorId); + if (errorMessage) { + returnUrl.searchParams.set(OAUTH_CALLBACK_QUERY_PARAMS.ERROR, errorMessage); + } + return returnUrl.toString(); +}; + +/** + * Returns an OAuth error response. Redirects when `returnUrl` and `connectorId` + * are set; otherwise renders an HTML callback page. + * + * @param res - Kibana response factory + * @param options.details - Error details + * @param options.connectorId - Connector ID; enables auto-close + * @param options.returnUrl - When set (with connectorId), triggers a redirect instead + */ +const respondWithError = ( + res: KibanaResponseFactory, + { details, connectorId, returnUrl }: RespondWithErrorOptions +) => { + if (returnUrl) { + return res.redirected({ + headers: { + location: buildOAuthReturnUrl( + returnUrl, + connectorId, + OAuthAuthorizationStatus.Error, + details + ), + }, + }); + } + return res.ok({ + headers: { 'content-type': 'text/html' }, + body: generateOAuthCallbackPage({ + title: i18n.translate('xpack.actions.oauthCallback.page.errorTitle', { + defaultMessage: 'OAuth Authorization Failed', + }), + heading: i18n.translate('xpack.actions.oauthCallback.page.errorHeading', { + defaultMessage: 'Authorization Failed', + }), + message: i18n.translate('xpack.actions.oauthCallback.page.errorMessage', { + defaultMessage: 'You can close this window and try again.', + }), + details, + isSuccess: false, + broadcast: connectorId + ? { + connectorId, + status: OAuthAuthorizationStatus.Error, + error: details, + } + : undefined, + }), + }); +}; + +/** + * Returns an OAuth success response. Same redirect-vs-page branching as + * {@link respondWithError}. + * + * @param res - Kibana response factory + * @param options.connectorId - Connector ID + * @param options.returnUrl - When set, triggers a redirect instead of rendering the page + */ +const respondWithSuccess = ( + res: KibanaResponseFactory, + { connectorId, returnUrl }: RespondWithSuccessOptions +) => { + if (returnUrl) { + return res.redirected({ + headers: { + location: buildOAuthReturnUrl(returnUrl, connectorId, OAuthAuthorizationStatus.Success), + }, + }); + } + return res.ok({ + headers: { 'content-type': 'text/html' }, + body: generateOAuthCallbackPage({ + title: i18n.translate('xpack.actions.oauthCallback.page.successTitle', { + defaultMessage: 'OAuth Authorization Successful', + }), + heading: i18n.translate('xpack.actions.oauthCallback.page.successHeading', { + defaultMessage: 'Authorization Successful', + }), + message: i18n.translate('xpack.actions.oauthCallback.page.autoCloseMessage', { + defaultMessage: + 'This window will close in {seconds, plural, one {# second} other {# seconds}}.', + values: { seconds: AUTO_CLOSE_DELAY_SECONDS }, + }), + isSuccess: true, + autoClose: true, + broadcast: { + connectorId, + status: OAuthAuthorizationStatus.Success, + }, + }), + }); +}; + /** * OAuth2 callback endpoint - handles authorization code exchange */ @@ -246,12 +390,13 @@ export const oauthCallbackRoute = ( response: { 302: { description: i18n.translate('xpack.actions.oauthCallback.response302Description', { - defaultMessage: 'Redirects to Kibana on successful authorization.', + defaultMessage: + 'Redirects to the return URL with authorization result query parameters.', }), }, 200: { description: i18n.translate('xpack.actions.oauthCallback.response200Description', { - defaultMessage: 'Returns an HTML page with error details if authorization fails.', + defaultMessage: 'Returns an HTML callback page.', }), }, 401: { @@ -267,85 +412,99 @@ export const oauthCallbackRoute = ( 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.', + title: i18n.translate('xpack.actions.oauthCallback.page.authRequiredTitle', { + defaultMessage: 'Authorization Failed', + }), + heading: i18n.translate('xpack.actions.oauthCallback.page.authRequiredHeading', { + defaultMessage: 'Authentication Required', + }), + message: i18n.translate('xpack.actions.oauthCallback.page.authRequiredMessage', { + defaultMessage: 'You must be logged in to complete the OAuth authorization.', + }), + details: i18n.translate('xpack.actions.oauthCallback.page.authRequiredDetails', { + defaultMessage: '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, + + const { profile_uid: profileUid } = currentUser; + + if (!profileUid) { + return respondWithError(res, { + details: i18n.translate('xpack.actions.oauthCallback.error.missingProfileUid', { + defaultMessage: 'Unable to retrieve Kibana user profile ID.', + }), + }); + } + + oauthRateLimiter.log(profileUid, 'callback'); + if (oauthRateLimiter.isRateLimited(profileUid, 'callback')) { + routeLogger.warn(`OAuth callback rate limit exceeded for user: ${profileUid}`); + return respondWithError(res, { + details: i18n.translate('xpack.actions.oauthCallback.error.rateLimited', { + defaultMessage: 'Too many authorization attempts. Please wait before trying again.', }), }); } - // 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, + if (!stateParam) { + return respondWithError(res, { + details: i18n.translate('xpack.actions.oauthCallback.error.missingState', { + defaultMessage: 'Missing required OAuth state parameter.', }), }); } - try { - const [coreStart, { encryptedSavedObjects, spaces }] = await coreSetup.getStartServices(); + const [coreStart, { encryptedSavedObjects, spaces }] = await coreSetup.getStartServices(); - // Retrieve and validate state - const oauthStateClient = new OAuthStateClient({ - encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ - includedHiddenTypes: ['oauth_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 respondWithError(res, { + details: i18n.translate('xpack.actions.oauthCallback.error.invalidState', { + defaultMessage: + 'Invalid or expired state parameter. The authorization session may have timed out.', }), - 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, - }), + } + + const { connectorId: stateConnectorId, kibanaReturnUrl } = oauthState; + + if (error || !code) { + const providerError = + error || + i18n.translate('xpack.actions.oauthCallback.error.missingCode', { + defaultMessage: 'Missing required OAuth authorization code', }); - } + const details = errorDescription + ? `${providerError}\n\n${errorDescription}` + : providerError; + routeLogger.error(`OAuth provider error for connector ${stateConnectorId}: ${details}`); + return respondWithError(res, { + details, + connectorId: stateConnectorId, + returnUrl: kibanaReturnUrl, + }); + } - // Get connector with decrypted secrets using the spaceId from the OAuth state + try { const connectorEncryptedClient = encryptedSavedObjects.getClient({ includedHiddenTypes: ['action'], }); @@ -358,27 +517,25 @@ export const oauthCallbackRoute = ( name: string; config: OAuthConnectorConfig; secrets: OAuthConnectorSecrets; - }>('action', oauthState.connectorId, { namespace }); + }>('action', stateConnectorId, { namespace }); 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) + const useBasicAuth = secrets.useBasicAuth ?? config?.useBasicAuth ?? true; + if (!clientId || !clientSecret || !tokenUrl) { throw new Error( 'Connector missing required OAuth configuration (clientId, clientSecret, tokenUrl)' ); } - // Build the redirect URI (must match the one sent to the authorization endpoint) const redirectUri = OAuthAuthorizationService.getRedirectUri( coreStart.http.basePath.publicBaseUrl ); - // Exchange authorization code for tokens const tokenResult = await requestOAuthAuthorizationCodeToken( tokenUrl, logger, @@ -393,43 +550,40 @@ export const oauthCallbackRoute = ( useBasicAuth ); routeLogger.debug( - `Successfully exchanged authorization code for access token for connectorId: ${oauthState.connectorId}` + `Successfully exchanged authorization code for access token for connectorId: ${stateConnectorId}` ); - // Store tokens - first delete any existing tokens for this connector then create a new token record - const connectorTokenClient = new ConnectorTokenClient({ + const userConnectorTokenClient = new UserConnectorTokenClient({ encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ - includedHiddenTypes: ['connector_token', 'user_connector_token'], + includedHiddenTypes: ['user_connector_token'], }), unsecuredSavedObjectsClient: core.savedObjects.getClient({ - includedHiddenTypes: ['connector_token', 'user_connector_token'], + includedHiddenTypes: ['user_connector_token'], }), logger: routeLogger, }); - await connectorTokenClient.deleteConnectorTokens({ - connectorId: oauthState.connectorId, + + await userConnectorTokenClient.deleteConnectorTokens({ + connectorId: stateConnectorId, tokenType: 'access_token', + profileUid, }); - const formattedToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; - await connectorTokenClient.createWithRefreshToken({ - connectorId: oauthState.connectorId, + const formattedToken = `${capitalize(tokenResult.tokenType)} ${tokenResult.accessToken}`; + await userConnectorTokenClient.createWithRefreshToken({ + connectorId: stateConnectorId, accessToken: formattedToken, refreshToken: tokenResult.refreshToken, expiresIn: tokenResult.expiresIn, refreshTokenExpiresIn: tokenResult.refreshTokenExpiresIn, tokenType: 'access_token', + profileUid, }); - // 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(), - }, + return respondWithSuccess(res, { + connectorId: stateConnectorId, + returnUrl: kibanaReturnUrl, }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -437,18 +591,70 @@ export const oauthCallbackRoute = ( 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, - }), + return respondWithError(res, { + details: GENERIC_OAUTH_ERROR, + connectorId: stateConnectorId, + returnUrl: kibanaReturnUrl, }); } }) ) ); }; + +/** + * Companion JS for the OAuth callback HTML page. + * + * Reads `data-broadcast` and `data-auto-close` attributes from `` and + * executes BroadcastChannel messaging and auto-close logic. Served as a + * separate route so it satisfies Kibana's `script-src 'self'` CSP. + */ +const OAUTH_CALLBACK_SCRIPT_BODY = `(() => { + const { broadcast, autoClose } = document.body.dataset; + + if (broadcast) { + try { + const message = JSON.parse(broadcast); + const channel = new BroadcastChannel(${JSON.stringify(OAUTH_BROADCAST_CHANNEL_NAME)}); + channel.postMessage(message); + channel.close(); + } catch (_) { + // BroadcastChannel may not be supported in all browsers + } + } + + if (autoClose === 'true') { + setTimeout(() => { + window.close(); + setTimeout(() => { + const fallback = document.querySelector('.auto-close-message'); + if (fallback) { + fallback.style.display = 'block'; + } + }, 100); + }, ${AUTO_CLOSE_DELAY_SECONDS * 1000}); + } +})(); +`; + +export const oauthCallbackScriptRoute = (router: IRouter) => { + router.get( + { + path: OAUTH_CALLBACK_SCRIPT_PATH, + security: DEFAULT_ACTION_ROUTE_SECURITY, + options: { + access: 'public', + }, + validate: {}, + }, + (_context, _req, res) => { + return res.ok({ + headers: { + 'content-type': 'application/javascript', + 'cache-control': 'public, max-age=86400', + }, + body: OAUTH_CALLBACK_SCRIPT_BODY, + }); + } + ); +}; diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_disconnect.test.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_disconnect.test.ts new file mode 100644 index 0000000000000..90382de9357f1 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_disconnect.test.ts @@ -0,0 +1,277 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('./verify_access_and_context', () => ({ + verifyAccessAndContext: jest.fn(), +})); +jest.mock('../lib/user_connector_token_client'); + +import { httpServiceMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { oauthDisconnectRoute } from './oauth_disconnect'; +import { UserConnectorTokenClient } from '../lib/user_connector_token_client'; + +const MockUserConnectorTokenClient = UserConnectorTokenClient as jest.MockedClass< + typeof UserConnectorTokenClient +>; + +const mockLogger = loggingSystemMock.create().get(); + +const mockConnectorTokenClientInstance = { + deleteConnectorTokens: jest.fn(), +}; + +const mockEncryptedSavedObjectsClient = { + getClient: jest.fn().mockReturnValue({}), +}; + +const mockActionsClient = { + get: jest.fn(), +}; + +const createMockCoreSetup = () => ({ + getStartServices: jest.fn().mockResolvedValue([ + {}, + { + encryptedSavedObjects: mockEncryptedSavedObjectsClient, + }, + ]), +}); + +const createMockContext = ( + currentUser: { profile_uid?: string } | null = { profile_uid: 'test-profile-uid' } +) => ({ + core: Promise.resolve({ + security: { + authc: { + getCurrentUser: jest.fn().mockReturnValue(currentUser), + }, + }, + savedObjects: { + getClient: jest.fn().mockReturnValue({}), + }, + }), + actions: Promise.resolve({ + getActionsClient: jest.fn().mockReturnValue(mockActionsClient), + }), +}); + +describe('oauthDisconnectRoute', () => { + let router: ReturnType; + + beforeEach(() => { + jest.resetAllMocks(); + router = httpServiceMock.createRouter(); + (verifyAccessAndContext as jest.Mock).mockImplementation((_license, handler) => handler); + + (mockLogger.get as jest.Mock).mockReturnValue(mockLogger); + mockEncryptedSavedObjectsClient.getClient.mockReturnValue({}); + + MockUserConnectorTokenClient.mockImplementation( + () => mockConnectorTokenClientInstance as never + ); + }); + + const registerRoute = (coreSetup = createMockCoreSetup()) => { + const licenseState = licenseStateMock.create(); + oauthDisconnectRoute(router, licenseState, mockLogger, coreSetup as never); + return router.post.mock.calls[0]; + }; + + it('registers a POST route at the correct path', () => { + registerRoute(); + + const [config] = router.post.mock.calls[0]; + expect(config.path).toBe('/internal/actions/connector/{connectorId}/_oauth_disconnect'); + }); + + it('returns unauthorized when no current user', async () => { + const [, handler] = registerRoute(); + const context = createMockContext(null); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.unauthorized).toHaveBeenCalledWith({ + body: { + message: 'User should be authenticated to disconnect OAuth authorization.', + }, + }); + expect(mockConnectorTokenClientInstance.deleteConnectorTokens).not.toHaveBeenCalled(); + }); + + it('returns error when profile UID is missing', async () => { + const [, handler] = registerRoute(); + const context = createMockContext({}); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.customError).toHaveBeenCalledWith({ + statusCode: 500, + body: { + message: 'Unable to retrieve Kibana user profile ID.', + }, + }); + expect(mockConnectorTokenClientInstance.deleteConnectorTokens).not.toHaveBeenCalled(); + }); + + it('returns 204 on successful disconnect', async () => { + mockActionsClient.get.mockResolvedValue({ id: 'connector-1' }); + mockConnectorTokenClientInstance.deleteConnectorTokens.mockResolvedValue(undefined); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(res.noContent).toHaveBeenCalled(); + }); + + it('verifies the connector exists before deleting tokens', async () => { + mockActionsClient.get.mockResolvedValue({ id: 'connector-1' }); + mockConnectorTokenClientInstance.deleteConnectorTokens.mockResolvedValue(undefined); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(mockActionsClient.get).toHaveBeenCalledWith({ id: 'connector-1' }); + }); + + it('deletes connector tokens for the given connector ID', async () => { + mockActionsClient.get.mockResolvedValue({ id: 'connector-1' }); + mockConnectorTokenClientInstance.deleteConnectorTokens.mockResolvedValue(undefined); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(mockConnectorTokenClientInstance.deleteConnectorTokens).toHaveBeenCalledWith({ + connectorId: 'connector-1', + profileUid: 'test-profile-uid', + }); + }); + + it('logs a message on successful disconnect', async () => { + mockActionsClient.get.mockResolvedValue({ id: 'connector-1' }); + mockConnectorTokenClientInstance.deleteConnectorTokens.mockResolvedValue(undefined); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(context, req, res); + + expect(mockLogger.info).toHaveBeenCalledWith('OAuth tokens deleted for connector: connector-1'); + }); + + it('propagates the error when the connector is not found', async () => { + mockActionsClient.get.mockRejectedValue(new Error('Not found')); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'nonexistent' }, + }); + const res = httpServerMock.createResponseFactory(); + + await expect(handler(context, req, res)).rejects.toThrow('Not found'); + + expect(mockConnectorTokenClientInstance.deleteConnectorTokens).not.toHaveBeenCalled(); + }); + + it('propagates the error when token deletion fails', async () => { + mockActionsClient.get.mockResolvedValue({ id: 'connector-1' }); + mockConnectorTokenClientInstance.deleteConnectorTokens.mockRejectedValue( + new Error('Deletion failed') + ); + + const [, handler] = registerRoute(); + const context = createMockContext(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + }); + const res = httpServerMock.createResponseFactory(); + + await expect(handler(context, req, res)).rejects.toThrow('Deletion failed'); + }); + + it('creates UserConnectorTokenClient with the correct saved objects clients', async () => { + const mockEncryptedClient = { getDecryptedAsInternalUser: jest.fn() }; + const mockUnsecuredClient = { find: jest.fn() }; + + mockEncryptedSavedObjectsClient.getClient.mockReturnValue(mockEncryptedClient); + + const mockContext = { + core: Promise.resolve({ + security: { + authc: { + getCurrentUser: jest.fn().mockReturnValue({ profile_uid: 'test-profile-uid' }), + }, + }, + savedObjects: { + getClient: jest.fn().mockReturnValue(mockUnsecuredClient), + }, + }), + actions: Promise.resolve({ + getActionsClient: jest.fn().mockReturnValue(mockActionsClient), + }), + }; + + mockActionsClient.get.mockResolvedValue({ id: 'connector-1' }); + mockConnectorTokenClientInstance.deleteConnectorTokens.mockResolvedValue(undefined); + + const [, handler] = registerRoute(); + const req = httpServerMock.createKibanaRequest({ + params: { connectorId: 'connector-1' }, + }); + const res = httpServerMock.createResponseFactory(); + + await handler(mockContext, req, res); + + expect(mockEncryptedSavedObjectsClient.getClient).toHaveBeenCalledWith({ + includedHiddenTypes: ['user_connector_token'], + }); + expect(MockUserConnectorTokenClient).toHaveBeenCalledWith({ + encryptedSavedObjectsClient: mockEncryptedClient, + unsecuredSavedObjectsClient: mockUnsecuredClient, + logger: mockLogger, + }); + }); + + it('calls verifyAccessAndContext with the license state', () => { + const licenseState = licenseStateMock.create(); + oauthDisconnectRoute(router, licenseState, mockLogger, createMockCoreSetup() as never); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/routes/oauth_disconnect.ts b/x-pack/platform/plugins/shared/actions/server/routes/oauth_disconnect.ts new file mode 100644 index 0000000000000..cde86b93f4d91 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/routes/oauth_disconnect.ts @@ -0,0 +1,106 @@ +/* + * 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 { CoreSetup, IRouter, Logger } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import type { ILicenseState } from '../lib'; +import { INTERNAL_BASE_ACTION_API_PATH, type DisconnectOAuthPathParams } from '../../common'; +import { disconnectOAuthPathParamsSchema } from '../../common/routes/connector/apis/oauth'; +import type { ActionsRequestHandlerContext } from '../types'; +import type { ActionsPluginsStart } from '../plugin'; +import { DEFAULT_ACTION_ROUTE_SECURITY } from './constants'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { UserConnectorTokenClient } from '../lib/user_connector_token_client'; + +export const oauthDisconnectRoute = ( + router: IRouter, + licenseState: ILicenseState, + logger: Logger, + coreSetup: CoreSetup +) => { + router.post( + { + path: `${INTERNAL_BASE_ACTION_API_PATH}/connector/{connectorId}/_oauth_disconnect`, + security: DEFAULT_ACTION_ROUTE_SECURITY, + options: { + access: 'internal', + summary: i18n.translate('xpack.actions.oauthDisconnect.routeSummary', { + defaultMessage: 'Disconnect OAuth authorization for a connector', + }), + description: i18n.translate('xpack.actions.oauthDisconnect.routeDescription', { + defaultMessage: + 'Removes all stored OAuth access and refresh tokens for the specified connector, effectively disconnecting it from the authorization server.', + }), + tags: ['oas-tag:connectors'], + }, + validate: { + request: { + params: disconnectOAuthPathParamsSchema, + }, + response: { + 204: { + description: i18n.translate('xpack.actions.oauthDisconnect.response204Description', { + defaultMessage: 'Connector successfully disconnected from OAuth authorization.', + }), + }, + 404: { + description: i18n.translate('xpack.actions.oauthDisconnect.response404Description', { + defaultMessage: 'Connector not found.', + }), + }, + }, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const { connectorId }: DisconnectOAuthPathParams = req.params; + const routeLogger = logger.get('oauth_disconnect'); + + const { security } = await context.core; + const currentUser = security.authc.getCurrentUser(); + if (!currentUser) { + return res.unauthorized({ + body: { + message: 'User should be authenticated to disconnect OAuth authorization.', + }, + }); + } + const { profile_uid: profileUid } = currentUser; + if (!profileUid) { + return res.customError({ + statusCode: 500, + body: { + message: 'Unable to retrieve Kibana user profile ID.', + }, + }); + } + + // Verify the connector exists and the user has access via the actions client + const actionsClient = (await context.actions).getActionsClient(); + await actionsClient.get({ id: connectorId }); + + const core = await context.core; + const [, { encryptedSavedObjects }] = await coreSetup.getStartServices(); + const userConnectorTokenClient = new UserConnectorTokenClient({ + encryptedSavedObjectsClient: encryptedSavedObjects.getClient({ + includedHiddenTypes: ['user_connector_token'], + }), + unsecuredSavedObjectsClient: core.savedObjects.getClient({ + includedHiddenTypes: ['user_connector_token'], + }), + logger: routeLogger, + }); + + await userConnectorTokenClient.deleteConnectorTokens({ connectorId, profileUid }); + + routeLogger.info(`OAuth tokens deleted for connector: ${connectorId}`); + + return res.noContent(); + }) + ) + ); +}; 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 index 952a8213ccb7f..72881aaca6ad7 100644 --- 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 @@ -12,7 +12,7 @@ export const rawOAuthStateSchema = schema.object({ codeVerifier: schema.string(), connectorId: schema.string(), scope: schema.maybe(schema.string()), - kibanaReturnUrl: schema.string(), // in case of OAuth success, redirect to this URL + kibanaReturnUrl: schema.maybe(schema.string()), // when set, redirect to this URL on OAuth success/error; otherwise show callback page spaceId: schema.string(), // the space where the connector exists createdAt: schema.string(), expiresAt: schema.string(), 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 deleted file mode 100644 index 3394035437910..0000000000000 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/hooks/use_oauth_authorize.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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/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 f65f54fd46f3d..73084c6a286c7 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,8 +15,6 @@ 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; @@ -25,9 +23,6 @@ interface Props { showButtons: boolean; disabled: boolean; onClickSave: () => void; - connector: ActionConnector; - onAuthorize?: () => void; - isAuthorizing?: boolean; } const FlyoutFooterComponent: React.FC = ({ @@ -37,9 +32,6 @@ const FlyoutFooterComponent: React.FC = ({ showButtons, disabled, onClickSave, - connector, - onAuthorize, - isAuthorizing, }) => { return ( @@ -53,29 +45,6 @@ const FlyoutFooterComponent: React.FC = ({ - {usesOAuthAuthorizationCode(connector) && onAuthorize && ( - - - {isAuthorizing ? ( - - ) : ( - - )} - - - )} {showButtons && ( = ({ const { docLinks, application: { capabilities }, - notifications: { toasts }, } = useKibana().services; const isMounted = useRef(false); @@ -132,7 +130,6 @@ 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; @@ -241,36 +238,6 @@ 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; @@ -413,9 +380,6 @@ 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 b103d44ad4aaa..cf38849819545 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 @@ -23,6 +23,7 @@ import { EuiPageTemplate, useEuiTheme, EuiIcon, + EuiConfirmModal, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; @@ -31,6 +32,11 @@ import { getConnectorCompatibility } from '@kbn/actions-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { checkActionTypeEnabled } from '@kbn/alerts-ui-shared/src/check_action_type_enabled'; import { ACTION_TYPE_SOURCES } from '@kbn/actions-types'; +import { + useConnectorOAuthConnect, + OAuthRedirectMode, + useConnectorOAuthDisconnect, +} from '@kbn/response-ops-oauth-hooks'; import { loadActionTypes, deleteActions } from '../../../lib/action_connector_api'; import { hasDeleteActionsCapability, @@ -38,6 +44,7 @@ import { hasExecuteActionsCapability, } from '../../../lib/capabilities'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; +import { usesOAuthAuthorizationCode } from '../../../lib/check_oauth_auth_code'; import type { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types'; import { EditConnectorTabs } from '../../../../types'; @@ -125,28 +132,6 @@ 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 { @@ -387,6 +372,7 @@ const ActionsConnectorsList = ({ return ( + {usesOAuthAuthorizationCode(item) && } onDelete([item])} /> {showFixButton && ( @@ -681,6 +667,167 @@ const RunOperation: React.FunctionComponent<{ ); }; +const OAuthOperations: React.FunctionComponent<{ + item: ActionConnectorTableItem; +}> = ({ item }) => { + const { + notifications: { toasts }, + } = useKibana().services; + + const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); + + const { connect, cancelConnect, isConnecting, isAwaitingCallback } = useConnectorOAuthConnect({ + connectorId: item.id, + redirectMode: OAuthRedirectMode.NewTab, + onSuccess: () => { + 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.' } + ), + }); + }, + onError: (error) => { + toasts.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.oauthAuthorizationErrorTitle', + { defaultMessage: 'Authorization failed' } + ), + text: error.message, + }); + }, + }); + + const { disconnect, isDisconnecting } = useConnectorOAuthDisconnect({ + connectorId: item.id, + onSuccess: () => { + toasts.addSuccess({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.oauthDisconnectSuccessTitle', + { defaultMessage: 'Disconnected' } + ), + text: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.oauthDisconnectSuccessMessage', + { defaultMessage: 'Your connector has been disconnected from OAuth.' } + ), + }); + }, + onError: (error) => { + toasts.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.oauthDisconnectErrorTitle', + { defaultMessage: 'Disconnect failed' } + ), + text: error.message, + }); + }, + }); + + return ( + <> + + {isAwaitingCallback ? ( + + + + ) : ( + + + + )} + + + + setShowDisconnectConfirm(true)} + iconType="linkSlash" + /> + + + {showDisconnectConfirm && ( + setShowDisconnectConfirm(false)} + onConfirm={() => { + setShowDisconnectConfirm(false); + disconnect(); + }} + cancelButtonText={i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.disconnectConfirmCancelButton', + { defaultMessage: 'Cancel' } + )} + confirmButtonText={i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.disconnectConfirmButton', + { defaultMessage: 'Disconnect' } + )} + buttonColor="danger" + > + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.disconnectConfirmMessage', + { + defaultMessage: 'You will need to re-authorize to use this connector again.', + } + )} + + )} + + ); +}; + const NoPermissionPrompt: React.FunctionComponent<{}> = () => (