diff --git a/src/platform/packages/private/kbn-mock-idp-utils/src/index.ts b/src/platform/packages/private/kbn-mock-idp-utils/src/index.ts index 219f05521c1bb..a2aa9b6f05d5d 100644 --- a/src/platform/packages/private/kbn-mock-idp-utils/src/index.ts +++ b/src/platform/packages/private/kbn-mock-idp-utils/src/index.ts @@ -46,4 +46,5 @@ export { generateCosmosDBApiRequestHeaders, getSAMLRequestId, createUiamSessionTokens, + createUiamOAuthAccessToken, } from './utils'; diff --git a/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts b/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts index 7d491aceca305..1365fb85ffbb0 100644 --- a/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts +++ b/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts @@ -404,6 +404,97 @@ export async function createUiamSessionTokens({ }; } +/** + * Creates a UIAM OAuth access token that can be used to test the OAuth token exchange flow. + * + * Unlike {@link createUiamSessionTokens}, this creates a token with `typ: 'oauth-access-token'` + * that includes OAuth-specific claims (audience, scope, client_id, connection_id). + */ +export async function createUiamOAuthAccessToken({ + username, + organizationId, + projectType, + roles, + audience, + fullName, + email, + accessTokenLifetimeSec = 3600, +}: { + username: string; + organizationId: string; + projectType: string; + roles: string[]; + audience: string; + fullName?: string; + email?: string; + accessTokenLifetimeSec?: number; +}) { + const iat = Math.floor(Date.now() / 1000); + + const givenName = fullName ? fullName.split(' ')[0] : 'Test'; + const familyName = fullName ? fullName.split(' ').slice(1).join(' ') : 'User'; + + const userSeedResult = await seedTestUser({ + userId: username, + organizationId, + roleId: 'cloud-role-id', + projectType, + applicationRoles: roles, + email, + firstName: givenName, + lastName: familyName, + }); + if (!userSeedResult.success) { + throw userSeedResult.response; + } + + const accessTokenBody = Buffer.from( + JSON.stringify({ + typ: 'oauth-access-token', + var: 'oauth', + iss: 'elastic-cloud', + sjt: 'user', + + oid: organizationId, + sub: username, + given_name: givenName, + family_name: familyName, + email, + + aud: audience, + scope: 'all', + client_id: 'test-oauth-client', + connection_id: 'test-oauth-connection', + + ras: { + platform: [], + organization: [], + user: [], + project: [ + { + role_id: 'cloud-role-id', + organization_id: organizationId, + project_type: projectType, + application_roles: roles, + project_scope: { scope: 'all' }, + }, + ], + }, + + nbf: iat, + exp: iat + accessTokenLifetimeSec, + iat, + jti: randomBytes(16).toString('hex'), + }) + ).toString('base64url'); + + const tokenHeader = Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'HS256' })).toString( + 'base64url' + ); + + return prepareJwtForUiam(`${tokenHeader}.${accessTokenBody}`); +} + function prepareJwtForUiam(unsignedJwt: string): string { const signedAccessToken = signJwt(unsignedJwt); return wrapSignedJwt(signedAccessToken); diff --git a/x-pack/platform/plugins/shared/agent_builder/server/routes/mcp.ts b/x-pack/platform/plugins/shared/agent_builder/server/routes/mcp.ts index beced025ebbbd..ffd30393a93b4 100644 --- a/x-pack/platform/plugins/shared/agent_builder/server/routes/mcp.ts +++ b/x-pack/platform/plugins/shared/agent_builder/server/routes/mcp.ts @@ -64,7 +64,7 @@ export function registerMCPRoutes({ router, getInternalServices, logger }: Route > This endpoint is designed for MCP clients (Claude Desktop, Cursor, VS Code, etc.) and should not be used directly via REST APIs. Use MCP Inspector or native MCP clients instead. To learn more, refer to the [MCP documentation](https://www.elastic.co/docs/explore-analyze/ai-features/agent-builder/mcp-server).`, options: { - tags: ['mcp', 'oas-tag:agent builder'], + tags: ['mcp', 'oas-tag:agent builder', 'security:acceptUiamOAuth'], xsrfRequired: false, availability: { since: '9.2.0', diff --git a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts index d09524d9dba15..e43ee80a1adb9 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/api_keys/uiam/uiam_api_keys.test.ts @@ -47,6 +47,7 @@ describe('UiamAPIKeys', () => { grantApiKey: jest.fn(), revokeApiKey: jest.fn(), convertApiKeys: jest.fn(), + exchangeOAuthToken: jest.fn(), }; uiamApiKeys = new UiamAPIKeys({ diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts b/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts index 12b5f65797fc7..b9b177d06d68d 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authentication_service.ts @@ -399,6 +399,7 @@ export class AuthenticationService { config: { authc: config.authc, accessAgreement: config.accessAgreement, + uiam: config.uiam, }, getCurrentUser, featureUsageService, diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts index 9c68a55f26ba9..596e3898a983e 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -95,7 +95,7 @@ export interface AuthenticatorOptions { featureUsageService: SecurityFeatureUsageServiceStart; userProfileService: UserProfileServiceStartInternal; getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; - config: Pick; + config: Pick; basePath: IBasePath; license: SecurityLicense; loggers: LoggerFactory; diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/http.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/http.test.ts index 3b850a30cbb16..67e56377730b3 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/http.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/http.test.ts @@ -13,9 +13,10 @@ import { elasticsearchServiceMock, httpServerMock } from '@kbn/core/server/mocks import type { MockAuthenticationProviderOptions } from './base.mock'; import { mockAuthenticationProviderOptions } from './base.mock'; import { HTTPAuthenticationProvider } from './http'; +import { ES_CLIENT_AUTHENTICATION_HEADER } from '../../../common/constants'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; -import { ROUTE_TAG_ACCEPT_JWT } from '../../routes/tags'; +import { ROUTE_TAG_ACCEPT_JWT, ROUTE_TAG_ACCEPT_UIAM_OAUTH } from '../../routes/tags'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -285,6 +286,183 @@ describe('HTTPAuthenticationProvider', () => { }); }); + describe('UIAM OAuth authentication', () => { + let mockOptionsWithUiam: MockAuthenticationProviderOptions; + + beforeEach(() => { + mockOptionsWithUiam = mockAuthenticationProviderOptions({ name: 'http', uiam: true }); + }); + + it('exchanges UIAM OAuth token and authenticates successfully on tagged route.', async () => { + const header = 'Bearer essu_oauth_access_token'; + const user = mockAuthenticatedUser(); + + mockOptionsWithUiam.uiam!.exchangeOAuthToken.mockResolvedValue('essu_ephemeral_token'); + + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: header }, + routeTags: [ROUTE_TAG_ACCEPT_UIAM_OAUTH], + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); + mockOptionsWithUiam.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const provider = new HTTPAuthenticationProvider(mockOptionsWithUiam, { + supportedSchemes: new Set(['bearer']), + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: { type: 'http', name: 'http' } }, + { + authHeaders: { + authorization: 'Bearer essu_ephemeral_token', + [ES_CLIENT_AUTHENTICATION_HEADER]: 'some-shared-secret', + }, + } + ) + ); + + expect(mockOptionsWithUiam.uiam!.exchangeOAuthToken).toHaveBeenCalledWith( + 'essu_oauth_access_token' + ); + + expect(mockOptionsWithUiam.client.asScoped).toHaveBeenCalledWith({ + headers: { + ...request.headers, + authorization: 'Bearer essu_ephemeral_token', + [ES_CLIENT_AUTHENTICATION_HEADER]: 'some-shared-secret', + }, + }); + }); + + it('fails authentication when UIAM token exchange fails.', async () => { + const header = 'Bearer essu_oauth_access_token'; + const exchangeError = new Error('UIAM service unavailable'); + + mockOptionsWithUiam.uiam!.exchangeOAuthToken.mockRejectedValue(exchangeError); + + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: header }, + routeTags: [ROUTE_TAG_ACCEPT_UIAM_OAUTH], + }); + + const provider = new HTTPAuthenticationProvider(mockOptionsWithUiam, { + supportedSchemes: new Set(['bearer']), + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(exchangeError) + ); + }); + + it('does not intercept essu_ tokens on non-tagged routes (falls through to ES).', async () => { + const header = 'Bearer essu_some_token'; + const user = mockAuthenticatedUser(); + + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: header }, + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); + mockOptionsWithUiam.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const provider = new HTTPAuthenticationProvider(mockOptionsWithUiam, { + supportedSchemes: new Set(['bearer']), + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: { type: 'http', name: 'http' } }, + { authHeaders: { authorization: header } } + ) + ); + + expect(mockOptionsWithUiam.uiam!.exchangeOAuthToken).not.toHaveBeenCalled(); + }); + + it('logs a warning when essu_ token is used on a non-tagged route.', async () => { + const header = 'Bearer essu_some_token'; + const user = mockAuthenticatedUser(); + + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: header }, + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); + mockOptionsWithUiam.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const provider = new HTTPAuthenticationProvider(mockOptionsWithUiam, { + supportedSchemes: new Set(['bearer']), + }); + + await provider.authenticate(request); + + expect(mockOptionsWithUiam.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Detected UIAM OAuth token on a non-MCP endpoint') + ); + }); + + it('does not intercept essu_ tokens when UIAM is not enabled.', async () => { + const header = 'Bearer essu_some_token'; + const user = mockAuthenticatedUser(); + + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: header }, + routeTags: [ROUTE_TAG_ACCEPT_UIAM_OAUTH], + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); + + const optionsWithoutUiam = mockAuthenticationProviderOptions({ name: 'http' }); + optionsWithoutUiam.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const provider = new HTTPAuthenticationProvider(optionsWithoutUiam, { + supportedSchemes: new Set(['bearer']), + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: { type: 'http', name: 'http' } }, + { authHeaders: { authorization: header } } + ) + ); + + expect(optionsWithoutUiam.client.asScoped).toHaveBeenCalledWith(request); + }); + + it('does not intercept non-essu_ Bearer tokens on tagged routes.', async () => { + const header = 'Bearer regular_jwt_token'; + const user = mockAuthenticatedUser(); + + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: header }, + routeTags: [ROUTE_TAG_ACCEPT_UIAM_OAUTH], + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); + mockOptionsWithUiam.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const provider = new HTTPAuthenticationProvider(mockOptionsWithUiam, { + supportedSchemes: new Set(['bearer']), + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded( + { ...user, authentication_provider: { type: 'http', name: 'http' } }, + { authHeaders: { authorization: header } } + ) + ); + + expect(mockOptionsWithUiam.uiam!.exchangeOAuthToken).not.toHaveBeenCalled(); + }); + }); + describe('`logout` method', () => { it('does not handle logout', async () => { const provider = new HTTPAuthenticationProvider(mockOptions, { diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/http.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/http.ts index dd9bc03498efb..883f5df8ccbe2 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/http.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/http.ts @@ -6,12 +6,12 @@ */ import type { KibanaRequest } from '@kbn/core/server'; -import { HTTPAuthorizationHeader } from '@kbn/core-security-server'; +import { HTTPAuthorizationHeader, isUiamCredential } from '@kbn/core-security-server'; import type { AuthenticationProviderOptions } from './base'; import { BaseAuthenticationProvider } from './base'; import { getDetailedErrorMessage } from '../../errors'; -import { ROUTE_TAG_ACCEPT_JWT } from '../../routes/tags'; +import { ROUTE_TAG_ACCEPT_JWT, ROUTE_TAG_ACCEPT_UIAM_OAUTH } from '../../routes/tags'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -91,6 +91,23 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } + if ( + this.options.uiam && + authorizationHeader.scheme.toLowerCase() === 'bearer' && + isUiamCredential(authorizationHeader) + ) { + if (request.route.options.tags.includes(ROUTE_TAG_ACCEPT_UIAM_OAUTH)) { + return this.authenticateViaUiamOAuth(request, authorizationHeader); + } + + this.logger.warn( + `Detected UIAM OAuth token on a non-MCP endpoint: ` + + `${request.route.method.toUpperCase()} ${request.route.path}. ` + + `OAuth tokens are only accepted on routes tagged with "${ROUTE_TAG_ACCEPT_UIAM_OAUTH}". ` + + `This may indicate a misconfigured MCP client or token misuse.` + ); + } + try { const user = await this.getUser(request); this.logger.debug( @@ -146,4 +163,32 @@ export class HTTPAuthenticationProvider extends BaseAuthenticationProvider { public getHTTPAuthenticationScheme() { return null; } + + /** + * Exchanges a UIAM OAuth access token for an ephemeral token via the UIAM service, verifies + * the audience, and resolves the user via Elasticsearch using the ephemeral token. + */ + private async authenticateViaUiamOAuth( + request: KibanaRequest, + authorizationHeader: HTTPAuthorizationHeader + ): Promise { + try { + const ephemeralToken = await this.options.uiam!.exchangeOAuthToken( + authorizationHeader.credentials + ); + + const authHeaders = this.options.uiam!.getAuthenticationHeaders(ephemeralToken); + + const user = await this.getUser(request, authHeaders); + + this.logger.debug('Request authenticated via UIAM OAuth token exchange.'); + + return AuthenticationResult.succeeded(user, { authHeaders }); + } catch (err) { + this.logger.error( + `Failed to authenticate via UIAM OAuth token exchange: ${getDetailedErrorMessage(err)}` + ); + return AuthenticationResult.failed(err); + } + } } diff --git a/x-pack/platform/plugins/shared/security/server/plugin.test.ts b/x-pack/platform/plugins/shared/security/server/plugin.test.ts index 7bad4adbc8cec..7f4bb50f3fd0d 100644 --- a/x-pack/platform/plugins/shared/security/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/security/server/plugin.test.ts @@ -54,13 +54,6 @@ describe('Security Plugin', () => { security: { operator_privileges: { enabled: false, available: false } }, } as Awaited>); - mockCoreSetup.http.getServerInfo.mockReturnValue({ - hostname: 'localhost', - name: 'kibana', - port: 80, - protocol: 'https', - }); - mockSetupDependencies = { licensing: { license$: of({ getUnavailableReason: jest.fn() }), @@ -71,6 +64,12 @@ describe('Security Plugin', () => { } as unknown as PluginSetupDependencies; mockCoreStart = coreMock.createStart(); + mockCoreStart.http.getServerInfo.mockReturnValue({ + hostname: 'localhost', + name: 'kibana', + port: 80, + protocol: 'https', + }); mockCoreSetup.getStartServices.mockResolvedValue([ // @ts-expect-error only mocking the client we use @@ -181,6 +180,13 @@ describe('Security Plugin', () => { describe('start()', () => { it('exposes proper contract', async () => { + mockCoreSetup.http.getServerInfo.mockReturnValue({ + hostname: 'localhost', + name: 'kibana', + port: 80, + protocol: 'https', + }); + await plugin.setup(mockCoreSetup, mockSetupDependencies); expect(plugin.start(mockCoreStart, mockStartDependencies)).toMatchInlineSnapshot(` Object { diff --git a/x-pack/platform/plugins/shared/security/server/plugin.ts b/x-pack/platform/plugins/shared/security/server/plugin.ts index f4af5eca915ce..ee80ea3503035 100644 --- a/x-pack/platform/plugins/shared/security/server/plugin.ts +++ b/x-pack/platform/plugins/shared/security/server/plugin.ts @@ -422,6 +422,10 @@ export class SecurityPlugin : undefined; const config = this.getConfig(); + + const { protocol, hostname, port } = core.http.getServerInfo(); + const serverConfig = { protocol, hostname, port, ...config.public }; + this.authenticationStart = this.authenticationService.start({ audit: this.auditSetup!, clusterClient, @@ -432,7 +436,10 @@ export class SecurityPlugin loggers: this.initializerContext.logger, session, uiam: config.uiam?.enabled - ? new UiamService(this.logger.get('uiam'), config.uiam, this.elasticsearchUrl) + ? new UiamService(this.logger.get('uiam'), config.uiam, { + kibanaServerURL: `${serverConfig.protocol}://${serverConfig.hostname}:${serverConfig.port}`, + elasticsearchUrl: this.elasticsearchUrl, + }) : undefined, applicationName: this.authorizationSetup!.applicationName, kibanaFeatures: features.getKibanaFeatures(), diff --git a/x-pack/platform/plugins/shared/security/server/routes/tags.ts b/x-pack/platform/plugins/shared/security/server/routes/tags.ts index a6ffd49d53a52..07c4b238e3742 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/tags.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/tags.ts @@ -31,3 +31,10 @@ export const ROUTE_TAG_AUTH_FLOW = 'security:authFlow'; * JWT as a means of authentication. */ export const ROUTE_TAG_ACCEPT_JWT = 'security:acceptJWT'; + +/** + * If the route is marked with this tag, the HTTP authentication provider will accept UIAM OAuth access tokens for authentication. + * HTTP authentication provider will exchange the UIAM OAuth access token for an ephemeral UIAM token + * via the UIAM service before authenticating with Elasticsearch. + */ +export const ROUTE_TAG_ACCEPT_UIAM_OAUTH = 'security:acceptUiamOAuth'; diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts index d3eb058310277..3eb6f4b2dc340 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.mock.ts @@ -24,6 +24,7 @@ export const uiamServiceMock = { key: 'mock-api-key-value', description: 'mock-api-key-name', }), + exchangeOAuthToken: jest.fn().mockResolvedValue('mock-ephemeral-token'), revokeApiKey: jest.fn().mockResolvedValue(undefined), convertApiKeys: jest.fn().mockResolvedValue({ results: [] }), }), diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts index d78bf27c1e21d..308066220553b 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.test.ts @@ -46,7 +46,10 @@ describe('UiamService', () => { }, { serverless: true } ).uiam, - 'https://es.example.com' + { + kibanaServerURL: 'https://my-project.kb.us-east-1.cloud.es.io:9243', + elasticsearchUrl: 'https://es.example.com', + } ); }); @@ -60,56 +63,76 @@ describe('UiamService', () => { it('fails if UIAM functionality is not enabled', () => { expect( () => - new UiamService(loggingSystemMock.createLogger(), { - enabled: false, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { verificationMode: 'none' }, - }) + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: false, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { verificationMode: 'none' }, + }, + { kibanaServerURL: 'https://kibana.test' } + ) ).toThrowError('UIAM is not enabled.'); }); it('fails if UIAM service URL is not configured', () => { expect( () => - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - sharedSecret: 'secret', - ssl: { verificationMode: 'none' }, - }) + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + sharedSecret: 'secret', + ssl: { verificationMode: 'none' }, + }, + { kibanaServerURL: 'https://kibana.test' } + ) ).toThrowError('UIAM URL is not configured.'); }); it('fails if UIAM service shared secret is not configured', () => { expect( () => - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - ssl: { verificationMode: 'none' }, - }) + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + ssl: { verificationMode: 'none' }, + }, + { kibanaServerURL: 'https://kibana.test' } + ) ).toThrowError('UIAM shared secret is not configured.'); }); it('does not create custom dispatcher for `full` verification without custom TLS settings', () => { agentSpy.mockClear(); - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { verificationMode: 'full' }, - }); + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { verificationMode: 'full' }, + }, + { kibanaServerURL: 'https://kibana.test' } + ); expect(agentSpy).not.toHaveBeenCalled(); }); it('creates a custom dispatcher for `full` verification when custom CAs are needed', () => { agentSpy.mockClear(); - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { verificationMode: 'full', certificateAuthorities: '/some/ca/path' }, - }); + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { verificationMode: 'full', certificateAuthorities: '/some/ca/path' }, + }, + { kibanaServerURL: 'https://kibana.test' } + ); expect(agentSpy).toHaveBeenCalledTimes(1); expect(agentSpy).toHaveBeenCalledWith({ connect: { @@ -120,15 +143,19 @@ describe('UiamService', () => { }); agentSpy.mockClear(); - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { - verificationMode: 'full', - certificateAuthorities: ['/some/ca/path-1', '/some/ca/path-2'], + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { + verificationMode: 'full', + certificateAuthorities: ['/some/ca/path-1', '/some/ca/path-2'], + }, }, - }); + { kibanaServerURL: 'https://kibana.test' } + ); expect(agentSpy).toHaveBeenCalledTimes(1); expect(agentSpy).toHaveBeenCalledWith({ connect: { @@ -144,12 +171,16 @@ describe('UiamService', () => { it('creates a custom dispatcher for `certificate` verification', () => { agentSpy.mockClear(); - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { verificationMode: 'certificate', certificateAuthorities: '/some/ca/path' }, - }); + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { verificationMode: 'certificate', certificateAuthorities: '/some/ca/path' }, + }, + { kibanaServerURL: 'https://kibana.test' } + ); expect(agentSpy).toHaveBeenCalledTimes(1); expect(agentSpy).toHaveBeenCalledWith({ connect: { @@ -161,12 +192,16 @@ describe('UiamService', () => { }); agentSpy.mockClear(); - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { verificationMode: 'certificate' }, - }); + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { verificationMode: 'certificate' }, + }, + { kibanaServerURL: 'https://kibana.test' } + ); expect(agentSpy).toHaveBeenCalledTimes(1); expect(agentSpy).toHaveBeenCalledWith({ connect: { @@ -179,12 +214,16 @@ describe('UiamService', () => { it('creates a custom dispatcher for `none` verification', () => { agentSpy.mockClear(); - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { verificationMode: 'none' }, - }); + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { verificationMode: 'none' }, + }, + { kibanaServerURL: 'https://kibana.test' } + ); expect(agentSpy).toHaveBeenCalledTimes(1); expect(agentSpy).toHaveBeenCalledWith({ connect: { allowPartialTrustChain: true, rejectUnauthorized: false }, @@ -193,16 +232,20 @@ describe('UiamService', () => { it('creates a custom dispatcher with client certificate and key for mTLS', () => { agentSpy.mockClear(); - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { - verificationMode: 'full', - certificate: '/path/to/cert.pem', - key: '/path/to/key.pem', + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { + verificationMode: 'full', + certificate: '/path/to/cert.pem', + key: '/path/to/key.pem', + }, }, - }); + { kibanaServerURL: 'https://kibana.test' } + ); expect(agentSpy).toHaveBeenCalledTimes(1); expect(agentSpy).toHaveBeenCalledWith({ connect: { @@ -216,17 +259,21 @@ describe('UiamService', () => { it('creates a custom dispatcher with mTLS client cert and CAs', () => { agentSpy.mockClear(); - new UiamService(loggingSystemMock.createLogger(), { - enabled: true, - url: 'https://uiam.service', - sharedSecret: 'secret', - ssl: { - verificationMode: 'full', - certificate: '/path/to/cert.pem', - key: '/path/to/key.pem', - certificateAuthorities: '/some/ca/path', + new UiamService( + loggingSystemMock.createLogger(), + { + enabled: true, + url: 'https://uiam.service', + sharedSecret: 'secret', + ssl: { + verificationMode: 'full', + certificate: '/path/to/cert.pem', + key: '/path/to/key.pem', + certificateAuthorities: '/some/ca/path', + }, }, - }); + { kibanaServerURL: 'https://kibana.test' } + ); expect(agentSpy).toHaveBeenCalledTimes(1); expect(agentSpy).toHaveBeenCalledWith({ connect: { @@ -355,6 +402,69 @@ describe('UiamService', () => { }); }); + describe('#exchangeOAuthToken', () => { + it('properly calls UIAM service to exchange an OAuth token for an ephemeral token', async () => { + const mockResponse = { + token: 'essu_ephemeral_token_value', + credentials: { + oauth: { + audience: 'https://my-project.kb.us-east-1.cloud.es.io:9243', + }, + }, + }; + + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + await expect(uiamService.exchangeOAuthToken('essu_oauth_access_token')).resolves.toBe( + 'essu_ephemeral_token_value' + ); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://uiam.service/uiam/api/v1/authentication/_authenticate?include_token=true&audience=https%3A%2F%2Fmy-project.kb.us-east-1.cloud.es.io%3A9243', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [ES_CLIENT_AUTHENTICATION_HEADER]: 'secret', + Authorization: 'Bearer essu_oauth_access_token', + }, + dispatcher: AGENT_MOCK, + } + ); + }); + + it('throws when audience in response does not match expected audience', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + token: 'essu_ephemeral_token_value', + credentials: { + oauth: { audience: 'https://wrong-kibana.example.com' }, + }, + }), + }); + + await expect(uiamService.exchangeOAuthToken('essu_oauth_access_token')).rejects.toThrow( + 'OAuth token audience mismatch' + ); + }); + + it('throws and logs error when UIAM service returns an error', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: { message: 'Invalid token' } }), + headers: new Headers(), + }); + + await expect(uiamService.exchangeOAuthToken('essu_invalid_token')).rejects.toThrow(); + }); + }); + describe('#grantApiKey', () => { it('properly calls UIAM service to grant an API key with Bearer scheme and name', async () => { const mockResponse: GrantUiamApiKeyResponse = { @@ -741,7 +851,8 @@ describe('UiamService', () => { }, }, { serverless: true } - ).uiam + ).uiam, + { kibanaServerURL: 'https://kibana.test' } ); await expect(serviceWithoutUrl.convertApiKeys(['es-api-key'])).rejects.toThrowError( diff --git a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts index 104c7c4e272cc..769156b40bcbe 100644 --- a/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts +++ b/x-pack/platform/plugins/shared/security/server/uiam/uiam_service.ts @@ -132,6 +132,14 @@ export interface UiamServicePublic { params: GrantUiamAPIKeyParams ): Promise; + /** + * Exchanges an OAuth access token for an ephemeral UIAM token. Validates that the audience + * returned by UIAM matches the expected Kibana server audience and throws if there is a mismatch. + * @param accessToken The OAuth access token. + * @returns The ephemeral token. + */ + exchangeOAuthToken(accessToken: string): Promise; + /** * Revokes a UIAM API key by its ID. * @param apiKeyId The ID of the API key to revoke. @@ -148,6 +156,13 @@ export interface UiamServicePublic { convertApiKeys(keys: string[]): Promise; } +interface UiamServiceOptions { + /** The base URL of the Kibana server. */ + kibanaServerURL: string; + /** The URL of the Elasticsearch cluster. */ + elasticsearchUrl?: string; +} + /** * See {@link UiamServicePublic}. */ @@ -155,11 +170,13 @@ export class UiamService implements UiamServicePublic { readonly #logger: Logger; readonly #config: Required; readonly #dispatcher: Agent | undefined; + readonly #kibanaServerURL: string; readonly #elasticsearchUrl?: string; - constructor(logger: Logger, config: UiamConfigType, elasticsearchUrl?: string) { + constructor(logger: Logger, config: UiamConfigType, options: UiamServiceOptions) { this.#logger = logger; - this.#elasticsearchUrl = elasticsearchUrl; + this.#kibanaServerURL = options.kibanaServerURL; + this.#elasticsearchUrl = options.elasticsearchUrl; // Destructure existing config and re-create it again after validation to make TypeScript can infer the proper types. const { enabled, url, sharedSecret, ssl } = config; @@ -254,6 +271,48 @@ export class UiamService implements UiamServicePublic { } } + /** + * See {@link UiamServicePublic.exchangeOAuthToken}. + */ + async exchangeOAuthToken(accessToken: string): Promise { + this.#logger.debug('Attempting to exchange OAuth access token for ephemeral token.'); + + const expectedAudience = this.#kibanaServerURL; + const url = new URL(`${this.#config.url}/uiam/api/v1/authentication/_authenticate`); + url.searchParams.set('include_token', 'true'); + url.searchParams.set('audience', expectedAudience); + + try { + const response = await UiamService.#parseUiamResponse( + await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [ES_CLIENT_AUTHENTICATION_HEADER]: this.#config.sharedSecret, + Authorization: `Bearer ${accessToken}`, + }, + // @ts-expect-error Undici `fetch` supports `dispatcher` option, see https://github.com/nodejs/undici/pull/1411. + dispatcher: this.#dispatcher, + }) + ); + + const audience = response.credentials?.oauth?.audience; + if (audience !== expectedAudience) { + throw Boom.badRequest( + `OAuth token audience mismatch: expected "${expectedAudience}" but got "${audience}".` + ); + } + + return response.token; + } catch (err) { + this.#logger.error( + () => `Failed to exchange OAuth access token: ${getDetailedErrorMessage(err)}` + ); + + throw err; + } + } + /** * See {@link UiamServicePublic.grantApiKey}. */ diff --git a/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_oauth_token_exchange.spec.ts b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_oauth_token_exchange.spec.ts new file mode 100644 index 0000000000000..fdc3fa6a46901 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/test/scout_uiam_local/api/parallel_tests/uiam_oauth_token_exchange.spec.ts @@ -0,0 +1,164 @@ +/* + * 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 { createUiamOAuthAccessToken } from '@kbn/mock-idp-utils'; +import { apiTest, tags } from '@kbn/scout'; +import { expect } from '@kbn/scout/api'; + +import { COMMON_HEADERS } from '../fixtures'; + +const MCP_ENDPOINT = 'api/agent_builder/mcp'; + +apiTest.describe( + '[NON-MKI] UIAM OAuth token exchange on MCP endpoint', + { tag: [...tags.serverless.security.complete] }, + () => { + let oauthAccessToken: string; + + apiTest.beforeAll(async ({ kbnUrl, config: { organizationId, projectType } }) => { + const audience = new URL(kbnUrl.get()).origin; + + oauthAccessToken = await createUiamOAuthAccessToken({ + username: '1234567890', + organizationId: organizationId!, + projectType: projectType!, + roles: ['admin'], + email: 'elastic_admin@elastic.co', + audience, + }); + }); + + apiTest( + 'should exchange OAuth token and authenticate successfully on MCP endpoint', + async ({ apiClient }) => { + // The MCP endpoint is tagged with `security:acceptUiamOAuth`, so the HTTP authentication + // provider should detect the `essu_` Bearer token, exchange it via UIAM for an ephemeral + // token, and authenticate the request. + const response = await apiClient.post(MCP_ENDPOINT, { + headers: { + ...COMMON_HEADERS, + Authorization: `Bearer ${oauthAccessToken}`, + }, + responseType: 'json', + body: { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + id: 1, + }, + }); + + expect(response.statusCode).toBe(200); + } + ); + + apiTest('should reject OAuth token on a non-MCP endpoint', async ({ apiClient }) => { + // Sending an `essu_` OAuth token to a non-tagged endpoint should fall through + // to ES authentication which will reject it (the token is not a valid ES credential). + const response = await apiClient.get('internal/security/me', { + headers: { + ...COMMON_HEADERS, + Authorization: `Bearer ${oauthAccessToken}`, + }, + responseType: 'json', + }); + + expect(response.statusCode).toBe(401); + }); + + apiTest('should reject an invalid OAuth token on MCP endpoint', async ({ apiClient }) => { + const mid = Math.floor(oauthAccessToken.length / 2); + const invalidToken = + oauthAccessToken.slice(0, mid) + 'CORRUPTED' + oauthAccessToken.slice(mid); + + const response = await apiClient.post(MCP_ENDPOINT, { + headers: { + ...COMMON_HEADERS, + Authorization: `Bearer ${invalidToken}`, + }, + responseType: 'json', + body: { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + id: 1, + }, + }); + + expect(response.statusCode).toBe(401); + }); + + apiTest( + 'should reject OAuth token with wrong audience on MCP endpoint', + async ({ apiClient, config: { organizationId, projectType } }) => { + const wrongAudienceToken = await createUiamOAuthAccessToken({ + username: '1234567890', + organizationId: organizationId!, + projectType: projectType!, + roles: ['admin'], + email: 'elastic_admin@elastic.co', + audience: 'https://wrong-kibana.example.com', + }); + + const response = await apiClient.post(MCP_ENDPOINT, { + headers: { + ...COMMON_HEADERS, + Authorization: `Bearer ${wrongAudienceToken}`, + }, + responseType: 'json', + body: { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + id: 1, + }, + }); + + expect(response.statusCode).toBe(400); + } + ); + + apiTest( + 'should reject a non-essu_ Bearer token on MCP endpoint (falls through to ES)', + async ({ apiClient }) => { + // A regular Bearer token (not prefixed with `essu_`) on a tagged route should + // skip the UIAM OAuth branch and fall through to standard ES authentication. + const response = await apiClient.post(MCP_ENDPOINT, { + headers: { + ...COMMON_HEADERS, + Authorization: 'Bearer some-regular-jwt-token', + }, + responseType: 'json', + body: { + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + id: 1, + }, + }); + + expect(response.statusCode).toBe(401); + } + ); + } +);