From 30925167e3997a70ef29c6e513c223a6ab2230d9 Mon Sep 17 00:00:00 2001 From: Lorena Balan Date: Mon, 23 Feb 2026 18:22:05 +0100 Subject: [PATCH] Merge and fix conflicts --- ...th_authorization_code_access_token.test.ts | 368 ++++++++++++++++++ .../server/lib/oauth_state_cleanup_task.ts | 5 +- 2 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.test.ts diff --git a/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.test.ts b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.test.ts new file mode 100644 index 0000000000000..b3428afe99d02 --- /dev/null +++ b/x-pack/platform/plugins/shared/actions/server/lib/get_oauth_authorization_code_access_token.test.ts @@ -0,0 +1,368 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import sinon from 'sinon'; +import type { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { connectorTokenClientMock } from './connector_token_client.mock'; +import { getOAuthAuthorizationCodeAccessToken } from './get_oauth_authorization_code_access_token'; +import { requestOAuthRefreshToken } from './request_oauth_refresh_token'; + +jest.mock('./request_oauth_refresh_token', () => ({ + requestOAuthRefreshToken: jest.fn(), +})); + +const NOW = new Date('2024-01-15T12:00:00.000Z'); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +// A valid stored token: access token expires 1h from now, refresh token expires in 7 days +const validToken = { + id: 'token-1', + connectorId: 'connector-1', + tokenType: 'access_token', + token: 'stored-access-token', + createdAt: new Date('2024-01-15T10:00:00.000Z').toISOString(), + expiresAt: new Date('2024-01-15T13:00:00.000Z').toISOString(), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresAt: new Date('2024-01-22T12:00:00.000Z').toISOString(), +}; + +// Same token but with an access token that expired 1h ago +const expiredToken = { + ...validToken, + expiresAt: new Date('2024-01-15T11:00:00.000Z').toISOString(), +}; + +const refreshResponse = { + tokenType: 'Bearer', + accessToken: 'new-access-token', + expiresIn: 3600, + refreshToken: 'new-refresh-token', + refreshTokenExpiresIn: 604800, +}; + +const baseOpts = { + connectorId: 'connector-1', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'my-client-id', + tokenUrl: 'https://auth.example.com/oauth/token', + }, + secrets: { + clientSecret: 'my-client-secret', + }, + }, + connectorTokenClient, +}; + +let clock: sinon.SinonFakeTimers; + +describe('getOAuthAuthorizationCodeAccessToken', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(NOW); + }); + beforeEach(() => { + clock.reset(); + jest.resetAllMocks(); + }); + afterAll(() => clock.restore()); + + describe('credential validation', () => { + it('returns null and warns when clientId is missing', async () => { + const result = await getOAuthAuthorizationCodeAccessToken({ + ...baseOpts, + credentials: { + ...baseOpts.credentials, + config: { ...baseOpts.credentials.config, clientId: '' }, + }, + }); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Missing required fields for requesting OAuth Authorization Code access token' + ); + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + }); + + it('returns null and warns when clientSecret is missing', async () => { + const result = await getOAuthAuthorizationCodeAccessToken({ + ...baseOpts, + credentials: { ...baseOpts.credentials, secrets: { clientSecret: '' } }, + }); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Missing required fields for requesting OAuth Authorization Code access token' + ); + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + }); + }); + + describe('stored token retrieval', () => { + it('returns null and warns when the token fetch reports errors', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: true, connectorToken: null }); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Errors fetching connector token for connectorId: connector-1' + ); + }); + + it('returns null and warns when no token is stored (user has not authorized yet)', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: false, connectorToken: null }); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'No access token found for connectorId: connector-1. User must complete OAuth authorization flow.' + ); + }); + + it('returns the stored token without refreshing when it has not expired', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: validToken, + }); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBe('stored-access-token'); + expect(requestOAuthRefreshToken).not.toHaveBeenCalled(); + }); + + it('treats a token with no expiresAt as never-expiring', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...validToken, expiresAt: undefined }, + }); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBe('stored-access-token'); + expect(requestOAuthRefreshToken).not.toHaveBeenCalled(); + }); + }); + + describe('token refresh', () => { + it('returns null and warns when access token is expired but no refresh token is stored', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { ...expiredToken, refreshToken: undefined }, + }); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Access token expired and no refresh token available for connectorId: connector-1. User must re-authorize.' + ); + }); + + it('returns null and warns when the refresh token itself is expired', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + ...expiredToken, + refreshTokenExpiresAt: new Date('2024-01-15T11:00:00.000Z').toISOString(), + }, + }); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Refresh token expired for connectorId: connector-1. User must re-authorize.' + ); + }); + + it('returns the refreshed token formatted as "tokenType accessToken"', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBe('Bearer new-access-token'); + }); + + it('calls requestOAuthRefreshToken with correct arguments including scope', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + await getOAuthAuthorizationCodeAccessToken({ ...baseOpts, scope: 'openid profile' }); + + expect(requestOAuthRefreshToken).toHaveBeenCalledWith( + 'https://auth.example.com/oauth/token', + logger, + { + refreshToken: 'stored-refresh-token', + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + scope: 'openid profile', + }, + configurationUtilities, + true // useBasicAuth defaults to true + ); + }); + + it('spreads additionalFields into the refresh request body', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + await getOAuthAuthorizationCodeAccessToken({ + ...baseOpts, + credentials: { + ...baseOpts.credentials, + config: { + ...baseOpts.credentials.config, + additionalFields: { tenant_id: 'abc123', custom_flag: true }, + }, + }, + }); + + expect(requestOAuthRefreshToken).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ tenant_id: 'abc123', custom_flag: true }), + expect.any(Object), + expect.any(Boolean) + ); + }); + + it('passes useBasicAuth: false when explicitly configured', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + await getOAuthAuthorizationCodeAccessToken({ + ...baseOpts, + credentials: { + ...baseOpts.credentials, + config: { ...baseOpts.credentials.config, useBasicAuth: false }, + }, + }); + + expect(requestOAuthRefreshToken).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.any(Object), + expect.any(Object), + false + ); + }); + + it('persists the refreshed token and the new refresh token from the response', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(connectorTokenClient.updateWithRefreshToken).toHaveBeenCalledWith({ + id: 'token-1', + token: 'Bearer new-access-token', + refreshToken: 'new-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 604800, + tokenType: 'access_token', + }); + }); + + it('falls back to the existing refresh token when the response omits one', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce({ + ...refreshResponse, + refreshToken: undefined, + }); + + await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(connectorTokenClient.updateWithRefreshToken).toHaveBeenCalledWith( + expect.objectContaining({ refreshToken: 'stored-refresh-token' }) + ); + }); + }); + + describe('forceRefresh', () => { + it('bypasses the expiry check and refreshes a still-valid token', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: validToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + + const result = await getOAuthAuthorizationCodeAccessToken({ + ...baseOpts, + forceRefresh: true, + }); + + expect(result).toBe('Bearer new-access-token'); + expect(requestOAuthRefreshToken).toHaveBeenCalledTimes(1); + }); + }); + + describe('error handling', () => { + it('returns null and logs an error when requestOAuthRefreshToken throws', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockRejectedValueOnce( + new Error('token endpoint unreachable') + ); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to refresh access token for connectorId: connector-1. Error: token endpoint unreachable' + ); + }); + + it('returns null and logs an error when persisting the refreshed token fails', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: expiredToken, + }); + (requestOAuthRefreshToken as jest.Mock).mockResolvedValueOnce(refreshResponse); + connectorTokenClient.updateWithRefreshToken.mockRejectedValueOnce( + new Error('DB write failed') + ); + + const result = await getOAuthAuthorizationCodeAccessToken(baseOpts); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to refresh access token for connectorId: connector-1. Error: DB write failed' + ); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_cleanup_task.ts b/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_cleanup_task.ts index efa829ae982ed..005ac576c08c4 100644 --- a/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_cleanup_task.ts +++ b/x-pack/platform/plugins/shared/actions/server/lib/oauth_state_cleanup_task.ts @@ -14,6 +14,7 @@ import type { } from '@kbn/task-manager-plugin/server'; import type { ActionsPluginsStart } from '../plugin'; import { OAuthStateClient } from './oauth_state_client'; +import { OAUTH_STATE_SAVED_OBJECT_TYPE } from '../constants/saved_objects'; export const OAUTH_STATE_CLEANUP_TASK_TYPE = 'actions:oauth_state_cleanup'; export const OAUTH_STATE_CLEANUP_TASK_ID = `Actions-${OAUTH_STATE_CLEANUP_TASK_TYPE}`; @@ -64,7 +65,9 @@ function registerOAuthStateCleanupTask( try { const [coreStart, { encryptedSavedObjects }] = await core.getStartServices(); - const unsecuredSavedObjectsClient = coreStart.savedObjects.createInternalRepository(); + const unsecuredSavedObjectsClient = coreStart.savedObjects.createInternalRepository([ + OAUTH_STATE_SAVED_OBJECT_TYPE, + ]); const encryptedSavedObjectsClient = encryptedSavedObjects.getClient({ includedHiddenTypes: ['oauth_state'], });