Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@ export {
generateCosmosDBApiRequestHeaders,
getSAMLRequestId,
createUiamSessionTokens,
createUiamOAuthAccessToken,
} from './utils';
91 changes: 91 additions & 0 deletions src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('UiamAPIKeys', () => {
grantApiKey: jest.fn(),
revokeApiKey: jest.fn(),
convertApiKeys: jest.fn(),
exchangeOAuthToken: jest.fn(),
};

uiamApiKeys = new UiamAPIKeys({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ export class AuthenticationService {
config: {
authc: config.authc,
accessAgreement: config.accessAgreement,
uiam: config.uiam,
},
getCurrentUser,
featureUsageService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export interface AuthenticatorOptions {
featureUsageService: SecurityFeatureUsageServiceStart;
userProfileService: UserProfileServiceStartInternal;
getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null;
config: Pick<ConfigType, 'authc' | 'accessAgreement'>;
config: Pick<ConfigType, 'authc' | 'accessAgreement' | 'uiam'>;
basePath: IBasePath;
license: SecurityLicense;
loggers: LoggerFactory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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, {
Expand Down
Loading
Loading