From 2fd34a02a45429b8e5a7194258e6bf97bf817a42 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 3 Apr 2025 18:23:36 +0200 Subject: [PATCH 1/4] feat(security, authc): implement optional "minimal" authentication mode --- packages/kbn-mock-idp-plugin/server/plugin.ts | 2 +- .../src/capabilities_service.test.ts | 1 + .../src/routes/resolve_capabilities.ts | 1 + .../security_route_config_validator.test.ts | 53 ++++++++++++++- .../src/security_route_config_validator.ts | 13 ++-- .../core_versioned_route.test.ts | 5 +- .../server-internal/src/http_server.test.ts | 45 ++++++++++++- .../http/server-internal/src/http_server.ts | 5 +- .../packages/http/server/src/router/route.ts | 29 +++++++-- .../server/authentication/authenticator.ts | 9 +++ .../server/authentication/providers/base.ts | 64 +++++++++++++++++++ .../server/routes/views/login.test.ts | 7 +- .../security/server/routes/views/login.ts | 1 + .../test_endpoints/server/init_routes.ts | 32 ++++++++++ 14 files changed, 245 insertions(+), 22 deletions(-) diff --git a/packages/kbn-mock-idp-plugin/server/plugin.ts b/packages/kbn-mock-idp-plugin/server/plugin.ts index b0a8d8ebc835f..8a1f2619a2421 100644 --- a/packages/kbn-mock-idp-plugin/server/plugin.ts +++ b/packages/kbn-mock-idp-plugin/server/plugin.ts @@ -262,7 +262,7 @@ export const plugin: PluginInitializer = as }), }, security: { - authc: { enabled: 'optional' }, + authc: { enabled: 'optional', reason: 'Mock IDP plugin for testing' }, authz: { enabled: false, reason: 'Mock IDP plugin for testing' }, }, }, diff --git a/src/core/packages/capabilities/server-internal/src/capabilities_service.test.ts b/src/core/packages/capabilities/server-internal/src/capabilities_service.test.ts index dc14127047b12..b0bcf20b70d6c 100644 --- a/src/core/packages/capabilities/server-internal/src/capabilities_service.test.ts +++ b/src/core/packages/capabilities/server-internal/src/capabilities_service.test.ts @@ -59,6 +59,7 @@ describe('CapabilitiesService', () => { }, authc: { enabled: 'optional', + reason: expect.any(String), }, }, }), diff --git a/src/core/packages/capabilities/server-internal/src/routes/resolve_capabilities.ts b/src/core/packages/capabilities/server-internal/src/routes/resolve_capabilities.ts index 86435ee459456..eed9c368115ab 100644 --- a/src/core/packages/capabilities/server-internal/src/routes/resolve_capabilities.ts +++ b/src/core/packages/capabilities/server-internal/src/routes/resolve_capabilities.ts @@ -24,6 +24,7 @@ export function registerCapabilitiesRoutes(router: IRouter, resolver: Capabiliti }, authc: { enabled: 'optional', + reason: 'This route can be accessed by both authenticated and unauthenticated users', }, }, validate: { diff --git a/src/core/packages/http/router-server-internal/src/security_route_config_validator.test.ts b/src/core/packages/http/router-server-internal/src/security_route_config_validator.test.ts index 7d5d9cd3879c8..8a884543ffc8d 100644 --- a/src/core/packages/http/router-server-internal/src/security_route_config_validator.test.ts +++ b/src/core/packages/http/router-server-internal/src/security_route_config_validator.test.ts @@ -8,7 +8,8 @@ */ import { validRouteSecurity } from './security_route_config_validator'; -import { ReservedPrivilegesSet } from '@kbn/core-http-server'; +import { ReservedPrivilegesSet, type RouteSecurity } from '@kbn/core-http-server'; +import type { DeepPartial } from '@kbn/utility-types'; describe('RouteSecurity validation', () => { it('should pass validation for valid route security with authz enabled and valid required privileges', () => { @@ -19,6 +20,7 @@ describe('RouteSecurity validation', () => { }, authc: { enabled: 'optional', + reason: 'some reason', }, }) ).not.toThrow(); @@ -161,6 +163,40 @@ describe('RouteSecurity validation', () => { ); }); + it('should fail validation when authc is minimal but reason is missing', () => { + const routeSecurity = { + authz: { + requiredPrivileges: ['read'], + }, + authc: { + enabled: 'minimal', + }, + }; + + expect(() => + validRouteSecurity(routeSecurity as DeepPartial) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.reason]: expected value of type [string] but got [undefined]"` + ); + }); + + it('should fail validation when authc is optional but reason is missing', () => { + const routeSecurity = { + authz: { + requiredPrivileges: ['read'], + }, + authc: { + enabled: 'optional', + }, + }; + + expect(() => + validRouteSecurity(routeSecurity as DeepPartial) + ).toThrowErrorMatchingInlineSnapshot( + `"[authc.reason]: expected value of type [string] but got [undefined]"` + ); + }); + it('should fail validation when authc is disabled but reason is missing', () => { const routeSecurity = { authz: { @@ -193,6 +229,20 @@ describe('RouteSecurity validation', () => { ); }); + it('should pass validation when authc is minimal', () => { + expect(() => + validRouteSecurity({ + authz: { + requiredPrivileges: ['read'], + }, + authc: { + enabled: 'minimal', + reason: 'some reason', + }, + }) + ).not.toThrow(); + }); + it('should pass validation when authc is optional', () => { expect(() => validRouteSecurity({ @@ -201,6 +251,7 @@ describe('RouteSecurity validation', () => { }, authc: { enabled: 'optional', + reason: 'some reason', }, }) ).not.toThrow(); diff --git a/src/core/packages/http/router-server-internal/src/security_route_config_validator.ts b/src/core/packages/http/router-server-internal/src/security_route_config_validator.ts index fd6033915c746..3125c746e2ebd 100644 --- a/src/core/packages/http/router-server-internal/src/security_route_config_validator.ts +++ b/src/core/packages/http/router-server-internal/src/security_route_config_validator.ts @@ -148,12 +148,17 @@ const authzSchema = schema.object({ }); const authcSchema = schema.object({ - enabled: schema.oneOf([schema.literal(true), schema.literal('optional'), schema.literal(false)]), + enabled: schema.oneOf([ + schema.literal(true), + schema.literal('optional'), + schema.literal('minimal'), + schema.literal(false), + ]), reason: schema.conditional( schema.siblingRef('enabled'), - schema.literal(false), - schema.string(), - schema.never() + schema.literal(true), + schema.never(), + schema.string() ), }); diff --git a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts index 1e82b9b39b79e..947cfb6b59a50 100644 --- a/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts +++ b/src/core/packages/http/router-server-internal/src/versioned_router/core_versioned_route.test.ts @@ -667,6 +667,7 @@ describe('Versioned route', () => { }, authc: { enabled: 'optional', + reason: 'some reason', }, }; const securityConfig2: RouteSecurity = { @@ -813,6 +814,7 @@ describe('Versioned route', () => { }, authc: { enabled: 'optional', + reason: 'some reason', }, }; @@ -863,6 +865,7 @@ describe('Versioned route', () => { }, authc: { enabled: 'optional', + reason: 'some reason', }, }; @@ -888,7 +891,7 @@ describe('Versioned route', () => { // @ts-expect-error for test purpose const security = route.getSecurity({ headers: { [ELASTIC_HTTP_VERSION_HEADER]: '1' } }); - expect(security.authc).toEqual({ enabled: 'optional' }); + expect(security.authc).toEqual({ enabled: 'optional', reason: 'some reason' }); expect(security.authz).toEqual({ requiredPrivileges: ['foo', 'bar'] }); }); diff --git a/src/core/packages/http/server-internal/src/http_server.test.ts b/src/core/packages/http/server-internal/src/http_server.test.ts index 5afdba1a40b8b..11e49175ee0ca 100644 --- a/src/core/packages/http/server-internal/src/http_server.test.ts +++ b/src/core/packages/http/server-internal/src/http_server.test.ts @@ -2139,7 +2139,7 @@ test('exposes authentication details of incoming request to a route handler', as path: '/foo', validate: false, security: { - authc: { enabled: 'optional' }, + authc: { enabled: 'optional', reason: 'test' }, authz: { enabled: false, reason: 'test' }, }, }, @@ -2181,7 +2181,48 @@ test('exposes authentication details of incoming request to a route handler', as tags: [], timeout: {}, security: { - authc: { enabled: 'optional' }, + authc: { enabled: 'optional', reason: 'test' }, + authz: { enabled: false, reason: 'test' }, + }, + }, + }); +}); + +test('properly treats minimal authentication as required', async () => { + const { registerRouter, registerAuth, server: innerServer } = await server.setup({ config$ }); + + const router = new Router('', logger, enhanceWithContext, routerOptions); + router.get( + { + path: '/', + validate: false, + security: { + authc: { enabled: 'minimal', reason: 'test' }, + authz: { enabled: false, reason: 'test' }, + }, + }, + (context, req, res) => res.ok({ body: req.route }) + ); + + // mocking to have `authRegistered` filed set to true + registerAuth((req, res, auth) => auth.authenticated({ state: { alpha: 'beta' } })); + registerRouter(router); + + await server.start(); + await supertest(innerServer.listener) + .get('/') + .expect(200, { + method: 'get', + path: '/', + routePath: '/', + options: { + authRequired: true, + xsrfRequired: false, + access: 'internal', + tags: [], + timeout: {}, + security: { + authc: { enabled: 'minimal', reason: 'test' }, authz: { enabled: false, reason: 'test' }, }, }, diff --git a/src/core/packages/http/server-internal/src/http_server.ts b/src/core/packages/http/server-internal/src/http_server.ts index a6a0242090a59..988d759a9d2c1 100644 --- a/src/core/packages/http/server-internal/src/http_server.ts +++ b/src/core/packages/http/server-internal/src/http_server.ts @@ -396,11 +396,12 @@ export class HttpServer { } private getAuthOption( - authRequired: RouteConfigOptions['authRequired'] = true + authRequired: RouteConfigOptions['authRequired'] | 'minimal' = true ): undefined | false | { mode: 'required' | 'try' } { if (this.authRegistered === false) return undefined; - if (authRequired === true) { + // Minimal authentication still should go through the authentication handler. + if (authRequired === true || authRequired === 'minimal') { return { mode: 'required' }; } if (authRequired === 'optional') { diff --git a/src/core/packages/http/server/src/router/route.ts b/src/core/packages/http/server/src/router/route.ts index edde890c2c2b3..a7dea5a040f0c 100644 --- a/src/core/packages/http/server/src/router/route.ts +++ b/src/core/packages/http/server/src/router/route.ts @@ -242,12 +242,28 @@ export interface AuthzDisabled { } /** - * Describes the authentication status when authentication is enabled. - * - * - `enabled`: A boolean or string indicating the authentication status. Can be `true` (authentication required) or `'optional'` (authentication is optional). + * Describes the authentication status when authentication is enabled (default). */ export interface AuthcEnabled { - enabled: true | 'optional'; + enabled: true; +} + +/** + * Describes the authentication status when authentication is switched to a minimal mode (only existence of credentials + * is checked). Requires an explicit reason explaining why full authentication can be deferred to Elasticsearch. + */ +export interface AuthcMinimal { + enabled: 'minimal'; + reason: string; +} + +/** + * Describes the authentication status when authentication is optional. Requires an explicit reason explaining why + * authentication is optional. + */ +export interface AuthcOptional { + enabled: 'optional'; + reason: string; } /** @@ -262,9 +278,10 @@ export interface AuthcDisabled { } /** - * Represents the authentication status for a route. It can either be enabled (`AuthcEnabled`) or disabled (`AuthcDisabled`). + * Represents the authentication status for a route. It can either be enabled (`AuthcEnabled`), minimal (`AuthcMinimal`), + * optional (`AuthcOptional`), or disabled (`AuthcDisabled`). */ -export type RouteAuthc = AuthcEnabled | AuthcDisabled; +export type RouteAuthc = AuthcEnabled | AuthcMinimal | AuthcOptional | AuthcDisabled; /** * Represents the authorization status for a route. It can either be enabled (`AuthzEnabled`) or disabled (`AuthzDisabled`). 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 cb281dea512d9..379752f6bfe9c 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -755,6 +755,7 @@ export class Authenticator { * Updates, creates, extends or clears session value based on the received authentication result. * @param request Request instance. * @param provider Provider that produced provided authentication result. + * @param providerInstance Provider instance that produced provided authentication result. * @param authenticationResult Result of the authentication or login attempt. * @param existingSessionValue Value of the existing session if any. */ @@ -788,6 +789,14 @@ export class Authenticator { ); } + // Don't update session if request is "minimally" authenticated. + if (request.route.options.security?.authc?.enabled === 'minimal') { + this.logger.debug( + 'Session should not be changed for requests that require minimal authentication, skipping session update.' + ); + return null; + } + if (!existingSessionValue && !authenticationResult.shouldUpdateState()) { return null; } diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts index 7e9c678d2ba81..9170cc53ba303 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts @@ -53,6 +53,19 @@ export type AuthenticationProviderSpecificOptions = Record; */ export const ELASTIC_CLOUD_SSO_REALM_NAME = 'cloud-saml-kibana'; +/** + * Names of the user properties that aren't available in the "minimal" authentication mode, and should throw an error + * when accessed. + */ +const USER_PROPERTIES_NOT_AVAILABLE_IN_MIN_AUTHC_MODE = new Set([ + 'username', + 'elastic_cloud_user', + 'authentication_realm', + 'lookup_realm', + 'authentication_type', + 'roles', +]); + /** * Base class that all authentication providers should extend. */ @@ -73,6 +86,12 @@ export abstract class BaseAuthenticationProvider { */ protected readonly logger: Logger; + /** + * A proxy for the user object returned in minimally authenticated mode. We cache proxy for each + * provider to avoid creating them for every request, as they are stateless and can be reused. + */ + private minAuthenticationUserProxy?: AuthenticatedUser; + /** * Instantiates AuthenticationProvider. * @param options Provider options object. @@ -137,6 +156,14 @@ export abstract class BaseAuthenticationProvider { * @param [authHeaders] Optional `Headers` dictionary to send with the request. */ protected async getUser(request: KibanaRequest, authHeaders?: Headers) { + // For "minimal" authentication, we don't need to call the `_authenticate` endpoint and can just + // return a static user proxy. The caveat here is that we don't validate credentials, but it + // will be done by the Elasticsearch itself. + if (request.route.options.security?.authc?.enabled === 'minimal') { + this.logger.debug(`Performing "minimal" authentication for request ${request.url.pathname}.`); + return this.getMinAuthenticationUserProxy(); + } + return this.authenticationInfoToAuthenticatedUser( // @ts-expect-error Metadata is defined as Record await this.options.client @@ -161,4 +188,41 @@ export abstract class BaseAuthenticationProvider { authenticationInfo.authentication_realm.name === ELASTIC_CLOUD_SSO_REALM_NAME, } as AuthenticatedUser); } + + private getMinAuthenticationUserProxy() { + if (this.minAuthenticationUserProxy) { + return this.minAuthenticationUserProxy; + } + + // We can retrieve `username` and `elastic_cloud_user` from the session, if there is a need in the future. + // As for `authentication_realm`, `lookup_realm`, `authentication_type` and `roles`, we're considering to + // remove them in the future to make the `AuthenticatedUser` interface lighter. + const minUserStub: Partial = { + enabled: true, + authentication_provider: { type: this.type, name: this.options.name }, + }; + + this.minAuthenticationUserProxy = deepFreeze( + new Proxy(minUserStub as AuthenticatedUser, { + get: (target, prop, receiver) => { + const value = Reflect.get(target, prop, receiver); + if (USER_PROPERTIES_NOT_AVAILABLE_IN_MIN_AUTHC_MODE.has(prop.toString())) { + this.logger.warn( + `Property "${String(prop)}" is not available for minimally authenticated users: ${ + new Error().stack + }` + ); + + // throw new Error( + // `Property "${String(prop)}" is not available for minimally authenticated users.` + // ); + } + + return value; + }, + }) + ); + + return this.minAuthenticationUserProxy; + } } diff --git a/x-pack/platform/plugins/shared/security/server/routes/views/login.test.ts b/x-pack/platform/plugins/shared/security/server/routes/views/login.test.ts index e337dda44d7a3..92d94515e7980 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/views/login.test.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/views/login.test.ts @@ -56,11 +56,8 @@ describe('Login view routes', () => { expect(routeConfig.security).toEqual( expect.objectContaining({ - authc: { enabled: 'optional' }, - authz: { - enabled: false, - reason: expect.any(String), - }, + authc: { enabled: 'optional', reason: expect.any(String) }, + authz: { enabled: false, reason: expect.any(String) }, }) ); diff --git a/x-pack/platform/plugins/shared/security/server/routes/views/login.ts b/x-pack/platform/plugins/shared/security/server/routes/views/login.ts index 47d19826e4224..220934ad78b51 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/views/login.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/views/login.ts @@ -43,6 +43,7 @@ export function defineLoginRoutes({ security: { authc: { enabled: 'optional', + reason: 'This route can be accessed by both authenticated and unauthenticated users.', }, authz: { enabled: false, diff --git a/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts b/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts index 5397100c1813e..1d507d9b3cebc 100644 --- a/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts +++ b/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts @@ -159,6 +159,38 @@ export function initRoutes( } ); + router.get( + { + path: '/authentication/fast/me', + security: { + authz: { + enabled: false, + reason: `This route delegates authorization to Core's security service; there must be an authenticated user for this route to return information`, + }, + authc: { + enabled: 'minimal', + reason: `This route is optimized for performant retrieval of the current user's information`, + }, + }, + validate: false, + options: { + access: 'public', + excludeFromOAS: true, + }, + }, + async (context, request, response) => { + const { security: coreSecurity, elasticsearch } = await context.core; + return response.ok({ + body: { + principal: coreSecurity.authc.getCurrentUser()!, + hasManage: await elasticsearch.client.asCurrentUser.security.hasPrivileges({ + cluster: ['manage'], + }), + }, + }); + } + ); + router.post( { path: '/api_keys/_grant', From a11b5ff7ba20cbb49588c4821661764d9cf8bb30 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 30 Jan 2026 18:16:32 +0100 Subject: [PATCH 2/4] fix: #1 --- .../server/authentication/authenticator.ts | 15 ++-- .../server/authentication/providers/base.ts | 70 ++++++++--------- .../server/authentication/providers/basic.ts | 23 +++--- .../authentication/providers/kerberos.ts | 49 +++++++----- .../server/authentication/providers/saml.ts | 77 ++++++++++++------- .../server/session_management/session.ts | 4 +- 6 files changed, 131 insertions(+), 107 deletions(-) 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 379752f6bfe9c..cc34f5818b12c 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -22,14 +22,11 @@ import type { BaseAuthenticationProvider, } from './providers'; import { - AnonymousAuthenticationProvider, BasicAuthenticationProvider, HTTPAuthenticationProvider, KerberosAuthenticationProvider, OIDCAuthenticationProvider, - PKIAuthenticationProvider, SAMLAuthenticationProvider, - TokenAuthenticationProvider, } from './providers'; import { Tokens } from './tokens'; import type { AuthenticatedUser, AuthenticationProvider, SecurityLicense } from '../../common'; @@ -123,15 +120,15 @@ const providerMap = new Map< new ( options: AuthenticationProviderOptions, providerSpecificOptions?: AuthenticationProviderSpecificOptions - ) => BaseAuthenticationProvider + ) => BaseAuthenticationProvider >([ [BasicAuthenticationProvider.type, BasicAuthenticationProvider], [KerberosAuthenticationProvider.type, KerberosAuthenticationProvider], [SAMLAuthenticationProvider.type, SAMLAuthenticationProvider], - [TokenAuthenticationProvider.type, TokenAuthenticationProvider], - [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], - [PKIAuthenticationProvider.type, PKIAuthenticationProvider], - [AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider], + // [TokenAuthenticationProvider.type, TokenAuthenticationProvider], + // [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], + // [PKIAuthenticationProvider.type, PKIAuthenticationProvider], + // [AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider], ]); /** @@ -440,7 +437,7 @@ export class Authenticator { let authenticationResult = await provider.authenticate( request, - ownsSession ? existingSession.value!.state : null + ownsSession ? existingSession.value! : null ); if (!authenticationResult.notHandled()) { diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts index 9170cc53ba303..e78a38b440223 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts @@ -17,6 +17,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { AuthenticatedUser } from '../../../common'; import type { AuthenticationInfo } from '../../elasticsearch'; +import type { SessionValue } from '../../session_management'; import type { UiamServicePublic } from '../../uiam'; import { AuthenticationResult } from '../authentication_result'; import type { DeauthenticationResult } from '../deauthentication_result'; @@ -58,18 +59,16 @@ export const ELASTIC_CLOUD_SSO_REALM_NAME = 'cloud-saml-kibana'; * when accessed. */ const USER_PROPERTIES_NOT_AVAILABLE_IN_MIN_AUTHC_MODE = new Set([ - 'username', 'elastic_cloud_user', 'authentication_realm', 'lookup_realm', 'authentication_type', - 'roles', ]); /** * Base class that all authentication providers should extend. */ -export abstract class BaseAuthenticationProvider { +export abstract class BaseAuthenticationProvider { /** * Type of the provider. */ @@ -86,12 +85,6 @@ export abstract class BaseAuthenticationProvider { */ protected readonly logger: Logger; - /** - * A proxy for the user object returned in minimally authenticated mode. We cache proxy for each - * provider to avoid creating them for every request, as they are stateless and can be reused. - */ - private minAuthenticationUserProxy?: AuthenticatedUser; - /** * Instantiates AuthenticationProvider. * @param options Provider options object. @@ -131,9 +124,12 @@ export abstract class BaseAuthenticationProvider { * Performs request authentication based on the session created during login or other information * associated with the request (e.g. `Authorization` HTTP header). * @param request Request instance. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ - abstract authenticate(request: KibanaRequest, state?: unknown): Promise; + abstract authenticate( + request: KibanaRequest, + session: SessionValue | null + ): Promise; /** * Invalidates user session associated with the request. @@ -154,14 +150,23 @@ export abstract class BaseAuthenticationProvider { * information of authenticated user. * @param request Request instance. * @param [authHeaders] Optional `Headers` dictionary to send with the request. + * @param [session] Optional session object associated with the provider. */ - protected async getUser(request: KibanaRequest, authHeaders?: Headers) { + protected async getUser( + request: KibanaRequest, + authHeaders?: Headers, + session?: SessionValue + ) { // For "minimal" authentication, we don't need to call the `_authenticate` endpoint and can just // return a static user proxy. The caveat here is that we don't validate credentials, but it // will be done by the Elasticsearch itself. - if (request.route.options.security?.authc?.enabled === 'minimal') { + if ( + session && + session.username && + request.route.options.security?.authc?.enabled === 'minimal' + ) { this.logger.debug(`Performing "minimal" authentication for request ${request.url.pathname}.`); - return this.getMinAuthenticationUserProxy(); + return this.getMinAuthenticationUserProxy(session); } return this.authenticationInfoToAuthenticatedUser( @@ -189,40 +194,35 @@ export abstract class BaseAuthenticationProvider { } as AuthenticatedUser); } - private getMinAuthenticationUserProxy() { - if (this.minAuthenticationUserProxy) { - return this.minAuthenticationUserProxy; - } - - // We can retrieve `username` and `elastic_cloud_user` from the session, if there is a need in the future. - // As for `authentication_realm`, `lookup_realm`, `authentication_type` and `roles`, we're considering to - // remove them in the future to make the `AuthenticatedUser` interface lighter. + private getMinAuthenticationUserProxy(session: SessionValue) { + // We can retrieve only a portion of user properties from the session, and these are relatively safe to use without + // re-validation. However, `elastic_cloud_user`, `authentication_realm`, `lookup_realm`, `authentication_type`, + // and `roles` are not available in this mode, accessing them should throw an error. Currently, audit logs rely on + // `roles` property being present on the user object. We should probably refactor audit logs to avoid using `roles` + // property for minimally authenticated users and then remove this property altogether and throw for its access. const minUserStub: Partial = { enabled: true, - authentication_provider: { type: this.type, name: this.options.name }, + username: session.username, + authentication_provider: session.provider, + profile_uid: session.userProfileId, + // TODO: Currently audit logs rely on `roles` property being present on the user object. + // We should probably refactor audit logs to avoid using `roles` property for minimally + // authenticated users and then remove this property altogether and throw for its access. + roles: [], }; - this.minAuthenticationUserProxy = deepFreeze( + return deepFreeze( new Proxy(minUserStub as AuthenticatedUser, { get: (target, prop, receiver) => { const value = Reflect.get(target, prop, receiver); if (USER_PROPERTIES_NOT_AVAILABLE_IN_MIN_AUTHC_MODE.has(prop.toString())) { - this.logger.warn( - `Property "${String(prop)}" is not available for minimally authenticated users: ${ - new Error().stack - }` + throw new Error( + `Property "${String(prop)}" is not available for minimally authenticated users.` ); - - // throw new Error( - // `Property "${String(prop)}" is not available for minimally authenticated users.` - // ); } - return value; }, }) ); - - return this.minAuthenticationUserProxy; } } diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.ts index 0faf76cefe2e0..b2556dd3b7dad 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.ts @@ -11,6 +11,7 @@ import { HTTPAuthorizationHeader } from '@kbn/core-security-server'; import { BaseAuthenticationProvider } from './base'; import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; import { getDetailedErrorMessage } from '../../errors'; +import type { SessionValue } from '../../session_management'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -49,7 +50,7 @@ function canStartNewSession(request: KibanaRequest) { /** * Provider that supports request authentication via Basic HTTP Authentication. */ -export class BasicAuthenticationProvider extends BaseAuthenticationProvider { +export class BasicAuthenticationProvider extends BaseAuthenticationProvider { /** * Type of the provider. */ @@ -93,9 +94,9 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { /** * Performs request authentication using Basic HTTP Authentication. * @param request Request instance. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ - public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + public async authenticate(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug( `Trying to authenticate user request to ${request.url.pathname}${request.url.search}.` ); @@ -105,11 +106,11 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } - if (state) { - return await this.authenticateViaState(request, state); + if (session) { + return await this.authenticateViaState(request, session); } - // If state isn't present let's redirect user to the login page. + // If session isn't present let's redirect user to the login page. if (canStartNewSession(request)) { this.logger.debug('Redirecting request to Login page.'); const basePath = this.options.basePath.get(request); @@ -152,19 +153,19 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { * Tries to extract authorization header from the state and adds it to the request before * it's forwarded to Elasticsearch backend. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session value previously created by the provider. */ - private async authenticateViaState(request: KibanaRequest, { authorization }: ProviderState) { + private async authenticateViaState(request: KibanaRequest, session: SessionValue) { this.logger.debug('Trying to authenticate via state.'); - if (!authorization) { + if (!session.state.authorization) { this.logger.debug('Authorization header is not found in state.'); return AuthenticationResult.notHandled(); } try { - const authHeaders = { authorization }; - const user = await this.getUser(request, authHeaders); + const authHeaders = { authorization: session.state.authorization }; + const user = await this.getUser(request, authHeaders, session); this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/kerberos.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/kerberos.ts index f4191c94e8119..a0ab9acf48542 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/kerberos.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/kerberos.ts @@ -14,6 +14,7 @@ import { HTTPAuthorizationHeader } from '@kbn/core-security-server'; import { BaseAuthenticationProvider } from './base'; import type { AuthenticationInfo } from '../../elasticsearch'; import { getDetailedErrorMessage, getErrorStatusCode, InvalidGrantError } from '../../errors'; +import type { SessionValue } from '../../session_management'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -44,7 +45,7 @@ function canStartNewSession(request: KibanaRequest) { /** * Provider that supports Kerberos request authentication. */ -export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { +export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { /** * Type of the provider. */ @@ -67,9 +68,9 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { /** * Performs Kerberos request authentication. * @param request Request instance. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ - public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + public async authenticate(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug( `Trying to authenticate user request to ${request.url.pathname}${request.url.search}.` ); @@ -81,13 +82,13 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { } let authenticationResult = AuthenticationResult.notHandled(); - if (state) { - authenticationResult = await this.authenticateViaState(request, state); + if (session) { + authenticationResult = await this.authenticateViaState(request, session); if ( authenticationResult.failed() && Tokens.isAccessTokenExpiredError(authenticationResult.error) ) { - authenticationResult = await this.authenticateViaRefreshToken(request, state); + authenticationResult = await this.authenticateViaRefreshToken(request, session); } } @@ -99,7 +100,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // mechanism negotiation stage, otherwise check with Elasticsearch if we can start it. return authorizationHeader ? await this.authenticateWithNegotiateScheme(request) - : await this.authenticateViaSPNEGO(request, state); + : await this.authenticateViaSPNEGO(request, session); } /** @@ -233,21 +234,21 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * Tries to extract access token from state and adds it to the request before it's * forwarded to Elasticsearch backend. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session object associated with the provider. */ - private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + private async authenticateViaState(request: KibanaRequest, session: SessionValue) { this.logger.debug('Trying to authenticate via state.'); - if (!accessToken) { + if (!session.state.accessToken) { this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } try { const authHeaders = { - authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), + authorization: new HTTPAuthorizationHeader('Bearer', session.state.accessToken).toString(), }; - const user = await this.getUser(request, authHeaders); + const user = await this.getUser(request, authHeaders, session); this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); @@ -264,21 +265,24 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { * token. So we should use refresh token, that is also stored in the state, to extend expired access token and * authenticate user with it. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session object associated with the provider. */ - private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) { + private async authenticateViaRefreshToken( + request: KibanaRequest, + session: SessionValue + ) { this.logger.debug('Trying to refresh access token.'); let refreshTokenResult: RefreshTokenResult | null; try { - refreshTokenResult = await this.options.tokens.refresh(state.refreshToken); + refreshTokenResult = await this.options.tokens.refresh(session.state.refreshToken); } catch (err) { // If refresh token is no longer valid, let's try to renegotiate new tokens using SPNEGO. We // allow this because expired underlying token is an implementation detail and Kibana user // facing session is still valid. if (err instanceof InvalidGrantError) { this.logger.warn('Both access and refresh tokens are expired. Re-authenticating…'); - return this.authenticateViaSPNEGO(request, state); + return this.authenticateViaSPNEGO(request, session); } this.logger.error(`Failed to refresh access token: ${getDetailedErrorMessage(err)}`); @@ -301,9 +305,12 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { /** * Tries to query Elasticsearch and see if we can rely on SPNEGO to authenticate user. * @param request Request instance. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ - private async authenticateViaSPNEGO(request: KibanaRequest, state?: ProviderState | null) { + private async authenticateViaSPNEGO( + request: KibanaRequest, + session?: SessionValue | null + ) { this.logger.debug('Trying to authenticate request via SPNEGO.'); // Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO. @@ -312,7 +319,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { await this.getUser(request, { // We should send a fake SPNEGO token to Elasticsearch to make sure Kerberos realm is included // into authentication chain and adds a `WWW-Authenticate: Negotiate` header to the error - // response. Otherwise it may not be even consulted if request can be authenticated by other + // response. Otherwise, it may not be even consulted if request can be authenticated by other // means (e.g. when anonymous access is enabled in Elasticsearch). authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}`, }); @@ -337,8 +344,8 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If we failed to do SPNEGO and have a session with expired token that belongs to Kerberos // authentication provider then it means Elasticsearch isn't configured to use Kerberos anymore. // In this case we should reply with the `401` error and allow Authenticator to clear the cookie. - // Otherwise give a chance to the next authentication provider to authenticate request. - return state + // Otherwise, give a chance to the next authentication provider to authenticate request. + return session ? AuthenticationResult.failed(Boom.unauthorized()) : AuthenticationResult.notHandled(); } diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts index b2ec1b43ef399..845c30d83f1a6 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts @@ -21,6 +21,7 @@ import { } from '../../../common/constants'; import type { AuthenticationInfo } from '../../elasticsearch'; import { getDetailedErrorMessage, InvalidGrantError } from '../../errors'; +import type { SessionValue } from '../../session_management'; import type { UiamServicePublic } from '../../uiam'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; @@ -101,7 +102,7 @@ const samlRequestIdLimit = 50; /** * Provider that supports SAML request authentication. */ -export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { +export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { /** * Type of the provider. */ @@ -234,9 +235,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { /** * Performs SAML request authentication. * @param request Request instance. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ - public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + public async authenticate(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug( `Trying to authenticate user request to ${request.url.pathname}${request.url.search}` ); @@ -248,27 +249,27 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // It may happen that Kibana is re-configured to use different realm for the same provider name, // we should clear such session and log user out. - if (state && this.realm && state.realm !== this.realm) { - const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + if (session && this.realm && session.state.realm !== this.realm) { + const message = `State based on realm "${session.state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; this.logger.warn(message); return AuthenticationResult.failed(Boom.unauthorized(message)); } let authenticationResult = AuthenticationResult.notHandled(); - if (state) { - authenticationResult = await this.authenticateViaState(request, state); + if (session) { + authenticationResult = await this.authenticateViaState(request, session); if ( authenticationResult.failed() && Tokens.isAccessTokenExpiredError(authenticationResult.error) ) { - authenticationResult = await this.authenticateViaRefreshToken(request, state); + authenticationResult = await this.authenticateViaRefreshToken(request, session); } } // If we couldn't authenticate by means of all methods above, let's try to capture user URL and // initiate SAML handshake, otherwise just return authentication result we have. return authenticationResult.notHandled() && canStartNewSession(request) - ? this.initiateAuthenticationHandshake(request, state) + ? this.initiateAuthenticationHandshake(request, session) : authenticationResult; } @@ -616,21 +617,28 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * Tries to extract access token from state and adds it to the request before it's * forwarded to Elasticsearch backend. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session object associated with the provider. */ - private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + private async authenticateViaState(request: KibanaRequest, session: SessionValue) { this.logger.debug('Trying to authenticate via state.'); - if (!accessToken) { + if (!session.state.accessToken) { this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } - const authHeaders: Record | undefined = this.isUiamToken(accessToken) - ? this.options.uiam.getAuthenticationHeaders(accessToken) - : { authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString() }; + const authHeaders: Record | undefined = this.isUiamToken( + session.state.accessToken + ) + ? this.options.uiam.getAuthenticationHeaders(session.state.accessToken) + : { + authorization: new HTTPAuthorizationHeader( + 'Bearer', + session.state.accessToken + ).toString(), + }; try { - const user = await this.getUser(request, authHeaders); + const user = await this.getUser(request, authHeaders, session); this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); @@ -647,12 +655,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * token. So we should use refresh token, that is also stored in the state, to extend expired access token and * authenticate user with it. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session object associated with the provider. */ - private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) { + private async authenticateViaRefreshToken( + request: KibanaRequest, + session: SessionValue + ) { this.logger.debug('Trying to refresh access token.'); - if (!state.refreshToken) { + if (!session.state.refreshToken) { this.logger.debug('Refresh token is not found in state.'); return AuthenticationResult.notHandled(); } @@ -660,16 +671,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { let refreshTokenResult: RefreshTokenResult; try { - if (this.isUiamToken(state.refreshToken)) { + if (this.isUiamToken(session.state.refreshToken)) { this.logger.debug('SAML provider is in UIAM mode, calling UIAM service to refresh tokens.'); const { accessToken, refreshToken } = await this.options.uiam.refreshSessionTokens( - state.refreshToken + session.state.refreshToken ); const uiamAuthenticatedUser = await this.getUser( request, - this.options.uiam.getAuthenticationHeaders(accessToken)! + this.options.uiam.getAuthenticationHeaders(accessToken)!, + session ); this.logger.debug('SAML provider successfully refreshed tokens via UIAM service.'); @@ -680,7 +692,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { authenticationInfo: uiamAuthenticatedUser, }; } else { - refreshTokenResult = await this.options.tokens.refresh(state.refreshToken); + refreshTokenResult = await this.options.tokens.refresh(session.state.refreshToken); } } catch (err) { // When user has neither valid access nor refresh token, the only way to resolve this issue is to get new @@ -718,7 +730,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { clientAuthentication: this.options.uiam.getClientAuthentication(), }, }), - state: { accessToken, refreshToken, realm: this.realm || state.realm }, + state: { accessToken, refreshToken, realm: this.realm || session.state.realm }, } ); } @@ -727,12 +739,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * Tries to start SAML handshake and eventually receive a token. * @param request Request instance. * @param redirectURL URL to redirect user to after successful SAML handshake. - * @param state Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ private async authenticateViaHandshake( request: KibanaRequest, redirectURL: string, - state?: ProviderState | null + session?: SessionValue | null ) { this.logger.debug('Trying to initiate SAML handshake.'); @@ -761,7 +773,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // Store request id in the state so that we can reuse it once we receive `SAMLResponse`. return AuthenticationResult.redirectTo(redirect, { state: { - requestIdMap: this.updateRequestIdMap(requestId, redirectURL, state?.requestIdMap), + requestIdMap: this.updateRequestIdMap( + requestId, + redirectURL, + session?.state.requestIdMap + ), realm, }, stateCookieOptions: { sameSite: 'None', isSecure: true }, @@ -874,14 +890,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param state Optional state object associated with the provider. */ - private initiateAuthenticationHandshake(request: KibanaRequest, state?: ProviderState | null) { + private initiateAuthenticationHandshake( + request: KibanaRequest, + session?: SessionValue | null + ) { const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER); if (originalURLHash != null) { return this.authenticateViaHandshake( request, `${this.options.getRequestOriginalURL(request)}${originalURLHash}`, - state + session ); } diff --git a/x-pack/platform/plugins/shared/security/server/session_management/session.ts b/x-pack/platform/plugins/shared/security/server/session_management/session.ts index 101e8773d83eb..b40df04403697 100644 --- a/x-pack/platform/plugins/shared/security/server/session_management/session.ts +++ b/x-pack/platform/plugins/shared/security/server/session_management/session.ts @@ -29,7 +29,7 @@ import type { ConfigType } from '../config'; /** * The shape of the value that represents user's session information. */ -export interface SessionValue { +export interface SessionValue { /** * Unique session ID. */ @@ -68,7 +68,7 @@ export interface SessionValue { * Session value that is fed to the authentication provider. The shape is unknown upfront and * entirely determined by the authentication provider that owns the current session. */ - state: unknown; + state: TState; /** * Unique identifier of the user profile, if any. Not all users that have session will have an associated user From 4d761739a16ad08c3ddb00e725ceb4876cd22d7f Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 9 Mar 2026 16:28:15 +0100 Subject: [PATCH 3/4] feat(iteration #2): update all providers to use session instead of state --- packages/kbn-mock-idp-plugin/server/plugin.ts | 18 +- src/core/packages/http/server/index.ts | 2 + .../packages/http/server/src/router/index.ts | 2 + .../register_bootstrap_route.test.ts | 5 +- .../src/bootstrap/register_bootstrap_route.ts | 6 +- .../server-internal/src/routes/status.ts | 6 +- .../server/routes/telemetry_config.ts | 6 +- .../private/banners/server/routes/info.ts | 8 +- .../custom_branding/server/routes/info.ts | 8 +- .../authentication/authenticator.test.ts | 7 +- .../server/authentication/authenticator.ts | 17 +- .../providers/anonymous.test.ts | 20 +- .../authentication/providers/anonymous.ts | 37 +- .../server/authentication/providers/base.ts | 11 +- .../authentication/providers/basic.test.ts | 35 +- .../server/authentication/providers/basic.ts | 17 +- .../authentication/providers/kerberos.test.ts | 58 +- .../authentication/providers/kerberos.ts | 19 +- .../authentication/providers/oidc.test.ts | 150 +++-- .../server/authentication/providers/oidc.ts | 52 +- .../authentication/providers/pki.test.ts | 105 ++-- .../server/authentication/providers/pki.ts | 46 +- .../authentication/providers/saml.test.ts | 578 ++++++++++-------- .../server/authentication/providers/saml.ts | 41 +- .../authentication/providers/token.test.ts | 56 +- .../server/authentication/providers/token.ts | 44 +- .../routes/analytics/record_violations.ts | 20 +- .../server/session_management/session.mock.ts | 6 +- .../anonymous.config.ts | 2 + .../tests/anonymous/login.ts | 37 ++ .../tests/kerberos/kerberos_login.ts | 39 ++ .../oidc/authorization_code_flow/oidc_auth.ts | 54 ++ .../tests/pki/pki_auth.ts | 44 ++ .../tests/saml/saml_login.ts | 39 ++ .../tests/token/login.ts | 46 ++ .../test_endpoints/server/init_routes.ts | 30 +- 36 files changed, 1101 insertions(+), 570 deletions(-) diff --git a/packages/kbn-mock-idp-plugin/server/plugin.ts b/packages/kbn-mock-idp-plugin/server/plugin.ts index 8a1f2619a2421..4f9cdd31182f0 100644 --- a/packages/kbn-mock-idp-plugin/server/plugin.ts +++ b/packages/kbn-mock-idp-plugin/server/plugin.ts @@ -312,8 +312,13 @@ export const plugin: PluginInitializer = as credential: schema.string(), }), }, - options: { authRequired: 'optional' }, - security: { authz: { enabled: false, reason: 'Mock IDP plugin for testing' } }, + security: { + authc: { + enabled: 'optional', + reason: 'Mock IDP plugin for testing UIAM operations', + }, + authz: { enabled: false, reason: 'Mock IDP plugin for testing' }, + }, }, async (context, request, response) => { try { @@ -366,8 +371,13 @@ export const plugin: PluginInitializer = as keys: schema.arrayOf(schema.string(), { minSize: 1 }), }), }, - options: { authRequired: 'optional' }, - security: { authz: { enabled: false, reason: 'Mock IDP plugin for testing' } }, + security: { + authc: { + enabled: 'optional', + reason: 'Mock IDP plugin for testing UIAM operations', + }, + authz: { enabled: false, reason: 'Mock IDP plugin for testing' }, + }, }, async (context, request, response) => { try { diff --git a/src/core/packages/http/server/index.ts b/src/core/packages/http/server/index.ts index 58ad662d22688..75ac4253fe40c 100644 --- a/src/core/packages/http/server/index.ts +++ b/src/core/packages/http/server/index.ts @@ -117,6 +117,8 @@ export type { RouteAuthc, AuthcDisabled, AuthcEnabled, + AuthcMinimal, + AuthcOptional, Privilege, PrivilegeSet, AllRequiredCondition, diff --git a/src/core/packages/http/server/src/router/index.ts b/src/core/packages/http/server/src/router/index.ts index 27fb646733fdf..730c688ec9ed8 100644 --- a/src/core/packages/http/server/src/router/index.ts +++ b/src/core/packages/http/server/src/router/index.ts @@ -62,6 +62,8 @@ export type { RouteAuthc, AuthcDisabled, AuthcEnabled, + AuthcMinimal, + AuthcOptional, RouteSecurity, AllRequiredCondition, AnyRequiredCondition, diff --git a/src/core/packages/rendering/server-internal/src/bootstrap/register_bootstrap_route.test.ts b/src/core/packages/rendering/server-internal/src/bootstrap/register_bootstrap_route.test.ts index 067ec999087e9..f58ffe2db41f9 100644 --- a/src/core/packages/rendering/server-internal/src/bootstrap/register_bootstrap_route.test.ts +++ b/src/core/packages/rendering/server-internal/src/bootstrap/register_bootstrap_route.test.ts @@ -26,11 +26,14 @@ describe('registerBootstrapRoute', () => { expect(router.get).toHaveBeenNthCalledWith( 2, expect.objectContaining({ + security: { + authc: { enabled: 'optional', reason: expect.any(String) }, + authz: { enabled: false, reason: expect.any(String) }, + }, options: { access: 'public', excludeFromRateLimiter: true, tags: ['api'], - authRequired: 'optional', }, }), expect.any(Function) diff --git a/src/core/packages/rendering/server-internal/src/bootstrap/register_bootstrap_route.ts b/src/core/packages/rendering/server-internal/src/bootstrap/register_bootstrap_route.ts index 74802a5f46b20..5264ff78b4c56 100644 --- a/src/core/packages/rendering/server-internal/src/bootstrap/register_bootstrap_route.ts +++ b/src/core/packages/rendering/server-internal/src/bootstrap/register_bootstrap_route.ts @@ -51,13 +51,17 @@ export const registerBootstrapRoute = ({ { path: '/bootstrap-anonymous.js', security: { + authc: { + enabled: 'optional', + reason: + 'Anonymous bootstrap script must be loadable on pages like the login page where the user is not yet authenticated', + }, authz: { enabled: false, reason: 'This route is only used for serving the bootstrap script.', }, }, options: { - authRequired: 'optional', tags: ['api'], access: 'public', excludeFromRateLimiter: true, diff --git a/src/core/packages/status/server-internal/src/routes/status.ts b/src/core/packages/status/server-internal/src/routes/status.ts index c26690406c9ee..47c5385d209e1 100644 --- a/src/core/packages/status/server-internal/src/routes/status.ts +++ b/src/core/packages/status/server-internal/src/routes/status.ts @@ -83,13 +83,17 @@ export const registerStatusRoute = ({ { path: '/api/status', security: { + authc: { + enabled: 'optional', + reason: + 'Status endpoint must be accessible by unauthenticated system users such as k8s readiness probes', + }, authz: { enabled: false, reason: 'Status route should be accessible without authorization.', }, }, options: { - authRequired: 'optional', // The `api` tag ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page. // The `security:acceptJWT` tag allows route to be accessed with JWT credentials. It points to // ROUTE_TAG_ACCEPT_JWT from '@kbn/security-plugin/server' that cannot be imported here directly. diff --git a/src/platform/plugins/shared/telemetry/server/routes/telemetry_config.ts b/src/platform/plugins/shared/telemetry/server/routes/telemetry_config.ts index 1adc47049efba..ff52f5713ca15 100644 --- a/src/platform/plugins/shared/telemetry/server/routes/telemetry_config.ts +++ b/src/platform/plugins/shared/telemetry/server/routes/telemetry_config.ts @@ -99,8 +99,12 @@ export function registerTelemetryConfigRoutes({ .get({ access: 'internal', path: FetchTelemetryConfigRoute, - options: { authRequired: 'optional' }, security: { + authc: { + enabled: 'optional', + reason: + 'Telemetry config must be accessible regardless of authentication state to determine opt-in status', + }, authz: { enabled: false, reason: 'This route is opted out from authorization', diff --git a/x-pack/platform/plugins/private/banners/server/routes/info.ts b/x-pack/platform/plugins/private/banners/server/routes/info.ts index 857f58ba7eca7..51e7f1058b999 100644 --- a/x-pack/platform/plugins/private/banners/server/routes/info.ts +++ b/x-pack/platform/plugins/private/banners/server/routes/info.ts @@ -16,15 +16,17 @@ export const registerInfoRoute = (router: BannersRouter, config: BannersConfigTy { path: '/api/banners/info', security: { + authc: { + enabled: 'optional', + reason: + 'Banner info must be accessible on the login page before the user is authenticated', + }, authz: { enabled: false, reason: 'This route is opted out from authorization', }, }, validate: false, - options: { - authRequired: 'optional', - }, }, async (ctx, req, res) => { const allowed = isValidLicense((await ctx.licensing).license); diff --git a/x-pack/platform/plugins/private/custom_branding/server/routes/info.ts b/x-pack/platform/plugins/private/custom_branding/server/routes/info.ts index 4b5ebc515d1f0..3652ad182ab5c 100644 --- a/x-pack/platform/plugins/private/custom_branding/server/routes/info.ts +++ b/x-pack/platform/plugins/private/custom_branding/server/routes/info.ts @@ -14,6 +14,11 @@ export const registerInfoRoute = (router: CustomBrandingRouter) => { { path: '/api/custom_branding/info', security: { + authc: { + enabled: 'optional', + reason: + 'Custom branding info must be accessible on the login page before the user is authenticated', + }, authz: { enabled: false, reason: @@ -21,9 +26,6 @@ export const registerInfoRoute = (router: CustomBrandingRouter) => { }, }, validate: false, - options: { - authRequired: 'optional', - }, }, async (ctx, req, res) => { const allowed = isValidLicense((await ctx.licensing).license); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts index f770235ba02b2..76e30ecf119bd 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.test.ts @@ -963,7 +963,7 @@ describe('Authenticator', () => { expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledWith( request, loginAttemptValue, - mockSessVal.state + { ...mockSessVal, provider: { type: 'saml', name: 'saml2' } } ); // Presence of the session has precedence over order. @@ -2795,10 +2795,7 @@ describe('Authenticator', () => { expect(mockOptions.session.extend).not.toHaveBeenCalled(); expect(mockOptions.session.invalidate).not.toHaveBeenCalled(); expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); - expect(mockBasicAuthenticationProvider.authenticate).toBeCalledWith( - request, - mockSessVal.state - ); + expect(mockBasicAuthenticationProvider.authenticate).toBeCalledWith(request, mockSessVal); expect(auditLogger.log).not.toHaveBeenCalled(); }); 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 cc34f5818b12c..79b6dff82c4d8 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/authenticator.ts @@ -22,11 +22,14 @@ import type { BaseAuthenticationProvider, } from './providers'; import { + AnonymousAuthenticationProvider, BasicAuthenticationProvider, HTTPAuthenticationProvider, KerberosAuthenticationProvider, OIDCAuthenticationProvider, + PKIAuthenticationProvider, SAMLAuthenticationProvider, + TokenAuthenticationProvider, } from './providers'; import { Tokens } from './tokens'; import type { AuthenticatedUser, AuthenticationProvider, SecurityLicense } from '../../common'; @@ -125,10 +128,10 @@ const providerMap = new Map< [BasicAuthenticationProvider.type, BasicAuthenticationProvider], [KerberosAuthenticationProvider.type, KerberosAuthenticationProvider], [SAMLAuthenticationProvider.type, SAMLAuthenticationProvider], - // [TokenAuthenticationProvider.type, TokenAuthenticationProvider], - // [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], - // [PKIAuthenticationProvider.type, PKIAuthenticationProvider], - // [AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider], + [TokenAuthenticationProvider.type, TokenAuthenticationProvider], + [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], + [PKIAuthenticationProvider.type, PKIAuthenticationProvider], + [AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider], ]); /** @@ -347,7 +350,7 @@ export class Authenticator { const authenticationResult = await provider.login( request, attempt.value, - ownsSession ? existingSessionValue!.state : null + ownsSession ? existingSessionValue! : null ); securityTelemetry.recordLoginDuration(performance.now() - startTime, { @@ -560,7 +563,7 @@ export class Authenticator { // We can ignore `undefined` value here since it's ruled out on the previous step, if provider isn't // available then `getSessionValue` should have returned `null`. const provider = this.providers.get(existingSessionValue.provider.name)!; - const authenticationResult = await provider.authenticate(request, existingSessionValue.state); + const authenticationResult = await provider.authenticate(request, existingSessionValue); if (!authenticationResult.notHandled()) { const sessionUpdateResult = await this.updateSessionValue(request, { provider: existingSessionValue.provider, @@ -593,7 +596,7 @@ export class Authenticator { // hence, we can't assume that this provider exists, so we have to check it. const provider = this.providers.get(suggestedProviderName); if (provider) { - return provider.logout(request, sessionValue?.state ?? null); + return provider.logout(request, sessionValue ?? null); } } else { // In case logout is called and we cannot figure out what provider is supposed to handle it, diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/anonymous.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/anonymous.test.ts index 7e80d1255ab1b..935f2d5edea77 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/anonymous.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/anonymous.test.ts @@ -15,6 +15,7 @@ import { AnonymousAuthenticationProvider } from './anonymous'; import { mockAuthenticationProviderOptions } from './base.mock'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; +import { sessionMock } from '../../session_management/session.mock'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { BasicHTTPAuthorizationHeaderCredentials } from '../http_authentication'; @@ -148,7 +149,7 @@ describe('AnonymousAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: { authorization: originalAuthorizationHeader }, }); - await expect(provider.authenticate(request, {})).resolves.toEqual( + await expect(provider.authenticate(request, sessionMock.createValue())).resolves.toEqual( AuthenticationResult.notHandled() ); @@ -163,7 +164,7 @@ describe('AnonymousAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, {})).resolves.toEqual( + await expect(provider.authenticate(request, sessionMock.createValue())).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) ); @@ -177,7 +178,7 @@ describe('AnonymousAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, {})).resolves.toEqual( + await expect(provider.authenticate(request, sessionMock.createValue())).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) ); @@ -237,7 +238,9 @@ describe('AnonymousAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, {})).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue()) + ).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) ); @@ -247,14 +250,19 @@ describe('AnonymousAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('does not handle logout if state is not present', async () => { + it('does not handle logout if session is not present', async () => { await expect(provider.logout(httpServerMock.createKibanaRequest())).resolves.toEqual( DeauthenticationResult.notHandled() ); }); it('always redirects to the logged out page.', async () => { - await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( + await expect( + provider.logout( + httpServerMock.createKibanaRequest(), + sessionMock.createValue({ state: {} }) + ) + ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/anonymous.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/anonymous.ts index 5f4e6f0a5abe1..e1228e7c63eaa 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/anonymous.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/anonymous.ts @@ -11,6 +11,7 @@ import { HTTPAuthorizationHeader } from '@kbn/core-security-server'; import type { AuthenticationProviderOptions } from './base'; import { BaseAuthenticationProvider } from './base'; import { getDetailedErrorMessage, getErrorStatusCode } from '../../errors'; +import type { SessionValue } from '../../session_management'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -141,19 +142,19 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider /** * Performs initial login request. * @param request Request instance. - * @param state Optional state value previously stored by the provider. + * @param session Optional session object associated with the provider. */ - public async login(request: KibanaRequest, state?: unknown) { + public async login(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug('Trying to perform a login.'); - return this.authenticateViaAuthorizationHeader(request, state); + return this.authenticateViaAuthorizationHeader(request, session); } /** * Performs request authentication. * @param request Request instance. - * @param state Optional state value previously stored by the provider. + * @param session Optional session object associated with the provider. */ - public async authenticate(request: KibanaRequest, state?: unknown) { + public async authenticate(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug( `Trying to authenticate user request to ${request.url.pathname}${request.url.search}.` ); @@ -163,8 +164,8 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider return AuthenticationResult.notHandled(); } - if (state || canStartNewSession(request)) { - return this.authenticateViaAuthorizationHeader(request, state); + if (session || canStartNewSession(request)) { + return this.authenticateViaAuthorizationHeader(request, session); } return AuthenticationResult.notHandled(); @@ -173,16 +174,16 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider /** * Redirects user to the logged out page. * @param request Request instance. - * @param state Optional state value previously stored by the provider. + * @param [session] Optional session object associated with the provider. */ - public async logout(request: KibanaRequest, state?: unknown) { + public async logout(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug( `Logout is initiated by request to ${request.url.pathname}${request.url.search}.` ); - // Having a `null` state means that provider was specifically called to do a logout, but when + // Having a `null` session means that provider was specifically called to do a logout, but when // session isn't defined then provider is just being probed whether or not it can perform logout. - if (state === undefined) { + if (session === undefined) { return DeauthenticationResult.notHandled(); } @@ -200,19 +201,25 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider /** * Tries to authenticate user request via configured credentials encoded into `Authorization` header. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session previously stored by the provider. */ - private async authenticateViaAuthorizationHeader(request: KibanaRequest, state?: unknown) { + private async authenticateViaAuthorizationHeader( + request: KibanaRequest, + session?: SessionValue | null + ) { const authHeaders = this.httpAuthorizationHeader ? { authorization: this.httpAuthorizationHeader.toString() } : ({} as Record); try { - const user = await this.getUser(request, authHeaders); + const user = await this.getUser(request, authHeaders, session ?? undefined); this.logger.debug( `Request to ${request.url.pathname}${request.url.search} has been authenticated.` ); // Create session only if it doesn't exist yet, otherwise keep it unchanged. - return AuthenticationResult.succeeded(user, { authHeaders, state: state ? undefined : {} }); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: session ? undefined : {}, + }); } catch (err) { const errorMessage = getDetailedErrorMessage(err); if (getErrorStatusCode(err) === 401) { diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts index e78a38b440223..0c60d690017c6 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.ts @@ -110,12 +110,12 @@ export abstract class BaseAuthenticationProvider { * this method if it doesn't support initial login request. * @param request Request instance. * @param loginAttempt Login attempt associated with the provider. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ async login( request: KibanaRequest, loginAttempt: unknown, - state?: unknown + session?: SessionValue | null ): Promise { return AuthenticationResult.notHandled(); } @@ -134,9 +134,12 @@ export abstract class BaseAuthenticationProvider { /** * Invalidates user session associated with the request. * @param request Request instance. - * @param [state] Optional state object associated with the provider that needs to be invalidated. + * @param [session] Optional session object associated with the provider. */ - abstract logout(request: KibanaRequest, state?: unknown): Promise; + abstract logout( + request: KibanaRequest, + session?: SessionValue | null + ): Promise; /** * Returns HTTP authentication scheme that provider uses within `Authorization` HTTP header that diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.test.ts index f9663e3fdab0d..a4454ec3c84fb 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.test.ts @@ -14,6 +14,7 @@ import { mockAuthenticationProviderOptions } from './base.mock'; import { BasicAuthenticationProvider } from './basic'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; +import { sessionMock } from '../../session_management/session.mock'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -124,7 +125,10 @@ describe('BasicAuthenticationProvider', () => { it('does not handle authentication if state exists, but authorization property is missing.', async () => { await expect( - provider.authenticate(httpServerMock.createKibanaRequest(), {}) + provider.authenticate( + httpServerMock.createKibanaRequest(), + sessionMock.createValue({ state: {} }) + ) ).resolves.toEqual(AuthenticationResult.notHandled()); }); @@ -144,9 +148,9 @@ describe('BasicAuthenticationProvider', () => { const authorization = generateAuthorizationHeader('user', 'password'); const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); - await expect(provider.authenticate(request, { authorization })).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect( + provider.authenticate(request, sessionMock.createValue({ state: { authorization } })) + ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(request.headers.authorization).toBe(authorization); @@ -161,9 +165,9 @@ describe('BasicAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, { authorization })).resolves.toEqual( - AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) - ); + await expect( + provider.authenticate(request, sessionMock.createValue({ state: { authorization } })) + ).resolves.toEqual(AuthenticationResult.succeeded(user, { authHeaders: { authorization } })); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); }); @@ -181,9 +185,9 @@ describe('BasicAuthenticationProvider', () => { ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, { authorization })).resolves.toEqual( - AuthenticationResult.failed(authenticationError) - ); + await expect( + provider.authenticate(request, sessionMock.createValue({ state: { authorization } })) + ).resolves.toEqual(AuthenticationResult.failed(authenticationError)); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); @@ -192,16 +196,19 @@ describe('BasicAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('does not handle logout if state is not present', async () => { + it('does not handle logout if session is not present', async () => { await expect(provider.logout(httpServerMock.createKibanaRequest())).resolves.toEqual( DeauthenticationResult.notHandled() ); }); it('redirects to the logged out URL.', async () => { - await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( - DeauthenticationResult.redirectTo('/some-logged-out-page') - ); + await expect( + provider.logout( + httpServerMock.createKibanaRequest(), + sessionMock.createValue({ state: {} }) + ) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/some-logged-out-page')); await expect(provider.logout(httpServerMock.createKibanaRequest(), null)).resolves.toEqual( DeauthenticationResult.redirectTo('/some-logged-out-page') diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.ts index b2556dd3b7dad..7b5ed3158fcd0 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/basic.ts @@ -60,13 +60,8 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider | null) { this.logger.debug(`Trying to log user out via ${request.url.pathname}${request.url.search}.`); - // Having a `null` state means that provider was specifically called to do a logout, but when + // Having a `null` session means that provider was specifically called to do a logout, but when // session isn't defined then provider is just being probed whether or not it can perform logout. - if (state === undefined) { + if (session === undefined) { return DeauthenticationResult.notHandled(); } @@ -163,8 +158,8 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', }; - - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.asInternalUser.security.getToken).not.toHaveBeenCalled(); @@ -310,9 +310,9 @@ describe('KerberosAuthenticationProvider', () => { InvalidGrantError.expiredOrInvalidRefreshToken() ); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) - ); + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(AuthenticationResult.failed(Boom.unauthorized())); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); @@ -351,7 +351,9 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: { type: 'kerberos', name: 'kerberos' } }, { authHeaders: { authorization } } @@ -380,7 +382,9 @@ describe('KerberosAuthenticationProvider', () => { authenticationInfo: user, }); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: { type: 'kerberos', name: 'kerberos' } }, { @@ -410,9 +414,9 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Bearer ${tokenPair.accessToken}` }, @@ -442,7 +446,10 @@ describe('KerberosAuthenticationProvider', () => { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token', }; - await expect(provider.authenticate(nonAjaxRequest, nonAjaxTokenPair)).resolves.toEqual( + + await expect( + provider.authenticate(nonAjaxRequest, sessionMock.createValue({ state: nonAjaxTokenPair })) + ).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) @@ -453,7 +460,9 @@ describe('KerberosAuthenticationProvider', () => { accessToken: 'expired-token', refreshToken: 'ajax-some-valid-refresh-token', }; - await expect(provider.authenticate(ajaxRequest, ajaxTokenPair)).resolves.toEqual( + await expect( + provider.authenticate(ajaxRequest, sessionMock.createValue({ state: ajaxTokenPair })) + ).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) @@ -465,7 +474,10 @@ describe('KerberosAuthenticationProvider', () => { refreshToken: 'optional-some-valid-refresh-token', }; await expect( - provider.authenticate(optionalAuthRequest, optionalAuthTokenPair) + provider.authenticate( + optionalAuthRequest, + sessionMock.createValue({ state: optionalAuthTokenPair }) + ) ).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, @@ -480,7 +492,7 @@ describe('KerberosAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `notHandled` if state is not presented.', async () => { + it('returns `notHandled` if session is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); @@ -488,7 +500,7 @@ describe('KerberosAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); - it('redirects to logged out view if state is `null`.', async () => { + it('redirects to logged out view if session is `null`.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request, null)).resolves.toEqual( @@ -505,9 +517,9 @@ describe('KerberosAuthenticationProvider', () => { const failureReason = new Error('failed to delete token'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - await expect(provider.logout(request, tokenPair)).resolves.toEqual( - DeauthenticationResult.failed(failureReason) - ); + await expect( + provider.logout(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); @@ -522,9 +534,9 @@ describe('KerberosAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - await expect(provider.logout(request, tokenPair)).resolves.toEqual( - DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) - ); + await expect( + provider.logout(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/kerberos.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/kerberos.ts index a0ab9acf48542..e7bcf19daabe8 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/kerberos.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/kerberos.ts @@ -106,21 +106,21 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider

| null) { this.logger.debug(`Trying to log user out via ${request.url.pathname}${request.url.search}.`); - // Having a `null` state means that provider was specifically called to do a logout, but when + // Having a `null` session means that provider was specifically called to do a logout, but when // session isn't defined then provider is just being probed whether or not it can perform logout. - if (state === undefined) { + if (session === undefined) { this.logger.debug('There is no access token invalidate.'); return DeauthenticationResult.notHandled(); } - if (state) { + if (session?.state) { try { - await this.options.tokens.invalidate(state); + await this.options.tokens.invalidate(session.state); } catch (err) { this.logger.debug( () => `Failed invalidating access and/or refresh tokens: ${getDetailedErrorMessage(err)}` @@ -244,10 +244,11 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider

{ }); await expect( - provider.login(request, attempt, { - state: 'statevalue', - nonce: 'noncevalue', - redirectURL: '/base-path/some-path', - realm: 'oidc1', - }) + provider.login( + request, + attempt, + sessionMock.createValue({ + state: { + state: 'statevalue', + nonce: 'noncevalue', + redirectURL: '/base-path/some-path', + realm: 'oidc1', + }, + }) + ) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/some-path', { userProfileGrant: { type: 'accessToken', accessToken: 'some-token' }, @@ -295,7 +302,13 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { redirectURL: '/base-path/some-path', realm: 'oidc1' }) + provider.login( + request, + attempt, + sessionMock.createValue({ + state: { redirectURL: '/base-path/some-path', realm: 'oidc1' }, + }) + ) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -311,11 +324,13 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { - state: 'statevalue', - nonce: 'noncevalue', - realm: 'oidc1', - }) + provider.login( + request, + attempt, + sessionMock.createValue({ + state: { state: 'statevalue', nonce: 'noncevalue', realm: 'oidc1' }, + }) + ) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -330,7 +345,7 @@ describe('OIDCAuthenticationProvider', () => { it('fails if session state is not presented.', async () => { const { request, attempt } = getMocks(); - await expect(provider.login(request, attempt, {} as any)).resolves.toEqual( + await expect(provider.login(request, attempt, sessionMock.createValue())).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( 'Response session state does not have corresponding state or nonce parameters or redirect URL.' @@ -353,12 +368,18 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( - provider.login(request, attempt, { - state: 'statevalue', - nonce: 'noncevalue', - redirectURL: '/base-path/some-path', - realm: 'oidc1', - }) + provider.login( + request, + attempt, + sessionMock.createValue({ + state: { + state: 'statevalue', + nonce: 'noncevalue', + redirectURL: '/base-path/some-path', + realm: 'oidc1', + }, + }) + ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -377,7 +398,13 @@ describe('OIDCAuthenticationProvider', () => { it('fails if realm from state is different from the realm provider is configured with.', async () => { const { request, attempt } = getMocks(); - await expect(provider.login(request, attempt, { realm: 'other-realm' })).resolves.toEqual( + await expect( + provider.login( + request, + attempt, + sessionMock.createValue({ state: { realm: 'other-realm' } }) + ) + ).resolves.toEqual( AuthenticationResult.failed( Boom.unauthorized( 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".' @@ -511,7 +538,10 @@ describe('OIDCAuthenticationProvider', () => { const authorization = `Bearer ${tokenPair.accessToken}`; await expect( - provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + provider.authenticate( + request, + sessionMock.createValue({ state: { ...tokenPair, realm: 'oidc1' } }) + ) ).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization } }) ); @@ -540,11 +570,16 @@ describe('OIDCAuthenticationProvider', () => { }); await expect( - provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - realm: 'oidc1', - }) + provider.authenticate( + request, + sessionMock.createValue({ + state: { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + realm: 'oidc1', + }, + }) + ) ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); @@ -565,7 +600,10 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); await expect( - provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + provider.authenticate( + request, + sessionMock.createValue({ state: { ...tokenPair, realm: 'oidc1' } }) + ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); @@ -588,7 +626,10 @@ describe('OIDCAuthenticationProvider', () => { }); await expect( - provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + provider.authenticate( + request, + sessionMock.createValue({ state: { ...tokenPair, realm: 'oidc1' } }) + ) ).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization: 'Bearer new-access-token' }, @@ -622,7 +663,10 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); await expect( - provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + provider.authenticate( + request, + sessionMock.createValue({ state: { ...tokenPair, realm: 'oidc1' } }) + ) ).resolves.toEqual(AuthenticationResult.failed(refreshFailureReason as any)); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); @@ -650,7 +694,10 @@ describe('OIDCAuthenticationProvider', () => { ); await expect( - provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + provider.authenticate( + request, + sessionMock.createValue({ state: { ...tokenPair, realm: 'oidc1' } }) + ) ).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc', @@ -689,7 +736,10 @@ describe('OIDCAuthenticationProvider', () => { ); await expect( - provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + provider.authenticate( + request, + sessionMock.createValue({ state: { ...tokenPair, realm: 'oidc1' } }) + ) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -722,7 +772,10 @@ describe('OIDCAuthenticationProvider', () => { ); await expect( - provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + provider.authenticate( + request, + sessionMock.createValue({ state: { ...tokenPair, realm: 'oidc1' } }) + ) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -741,7 +794,9 @@ describe('OIDCAuthenticationProvider', () => { it('fails if realm from state is different from the realm provider is configured with.', async () => { const request = httpServerMock.createKibanaRequest(); - await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: { realm: 'other-realm' } })) + ).resolves.toEqual( AuthenticationResult.failed( Boom.unauthorized( 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".' @@ -752,27 +807,25 @@ describe('OIDCAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `notHandled` if state is not presented.', async () => { + it('returns `notHandled` if session is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - await expect(provider.logout(request, undefined as any)).resolves.toEqual( - DeauthenticationResult.notHandled() - ); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); - it('redirects to logged out view if state is `null` or does not include access token.', async () => { + it('redirects to logged out view if session is `null` or does not include access token.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request, null)).resolves.toEqual( DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - await expect(provider.logout(request, { nonce: 'x', realm: 'oidc1' })).resolves.toEqual( - DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) - ); + await expect( + provider.logout(request, sessionMock.createValue({ state: { nonce: 'x', realm: 'oidc1' } })) + ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); @@ -791,7 +844,10 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( - provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + provider.logout( + request, + sessionMock.createValue({ state: { accessToken, refreshToken, realm: 'oidc1' } }) + ) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -810,7 +866,10 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + provider.logout( + request, + sessionMock.createValue({ state: { accessToken, refreshToken, realm: 'oidc1' } }) + ) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -831,7 +890,10 @@ describe('OIDCAuthenticationProvider', () => { }); await expect( - provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + provider.logout( + request, + sessionMock.createValue({ state: { accessToken, refreshToken, realm: 'oidc1' } }) + ) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/logout&id_token_hint=thehint') ); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/oidc.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/oidc.ts index 3a40b7f17ea92..aa3563b29d9ab 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/oidc.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/oidc.ts @@ -20,6 +20,7 @@ import { } from '../../../common/constants'; import type { AuthenticationInfo } from '../../elasticsearch'; import { getDetailedErrorMessage, InvalidGrantError } from '../../errors'; +import type { SessionValue } from '../../session_management'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -87,7 +88,7 @@ function canStartNewSession(request: KibanaRequest) { /** * Provider that supports authentication using an OpenID Connect realm in Elasticsearch. */ -export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { +export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { /** * Type of the provider. */ @@ -118,15 +119,17 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * Performs OpenID Connect request authentication. * @param request Request instance. * @param attempt Login attempt description. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ public async login( request: KibanaRequest, attempt: ProviderLoginAttempt, - state?: ProviderState | null + session?: SessionValue | null ) { this.logger.debug('Trying to perform a login.'); + const state = session?.state; + // It may happen that Kibana is re-configured to use different realm for the same provider name, // we should clear such session an log user out. if (state?.realm && state.realm !== this.realm) { @@ -170,9 +173,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { /** * Performs OpenID Connect request authentication. * @param request Request instance. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ - public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + public async authenticate(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug( `Trying to authenticate user request to ${request.url.pathname}${request.url.search}.` ); @@ -184,20 +187,20 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // It may happen that Kibana is re-configured to use different realm for the same provider name, // we should clear such session an log user out. - if (state?.realm && state.realm !== this.realm) { - const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + if (session?.state?.realm && session?.state.realm !== this.realm) { + const message = `State based on realm "${session.state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; this.logger.warn(message); return AuthenticationResult.failed(Boom.unauthorized(message)); } let authenticationResult = AuthenticationResult.notHandled(); - if (state) { - authenticationResult = await this.authenticateViaState(request, state); + if (session) { + authenticationResult = await this.authenticateViaState(request, session); if ( authenticationResult.failed() && Tokens.isAccessTokenExpiredError(authenticationResult.error) ) { - authenticationResult = await this.authenticateViaRefreshToken(request, state); + authenticationResult = await this.authenticateViaRefreshToken(request, session.state); } } @@ -337,21 +340,21 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * Tries to extract an elasticsearch access token from state and adds it to the request before it's * forwarded to Elasticsearch backend. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session value previously stored by the provider. */ - private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + private async authenticateViaState(request: KibanaRequest, session: SessionValue) { this.logger.debug('Trying to authenticate via state.'); - if (!accessToken) { + if (!session.state?.accessToken) { this.logger.debug('Elasticsearch access token is not found in state.'); return AuthenticationResult.notHandled(); } + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', session.state.accessToken).toString(), + }; try { - const authHeaders = { - authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), - }; - const user = await this.getUser(request, authHeaders); + const user = await this.getUser(request, authHeaders, session); this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); @@ -419,19 +422,19 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * Invalidates an elasticsearch access token and refresh token that were originally created as a successful response * to an OpenID Connect based authentication. This does not handle OP initiated Single Logout * @param request Request instance. - * @param state State value previously stored by the provider. + * @param [session] Optional session object associated with the provider. */ - public async logout(request: KibanaRequest, state?: ProviderState | null) { + public async logout(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug(`Trying to log user out via ${request.url.pathname}${request.url.search}.`); - // Having a `null` state means that provider was specifically called to do a logout, but when + // Having a `null` session means that provider was specifically called to do a logout, but when // session isn't defined then provider is just being probed whether or not it can perform logout. - if (state === undefined) { + if (session === undefined) { this.logger.debug('There is no elasticsearch access token to invalidate.'); return DeauthenticationResult.notHandled(); } - if (state?.accessToken) { + if (session?.state?.accessToken) { try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/logout`. @@ -440,7 +443,10 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const { redirect } = (await this.options.client.asInternalUser.transport.request({ method: 'POST', path: '/_security/oidc/logout', - body: { token: state.accessToken, refresh_token: state.refreshToken }, + body: { + token: session.state.accessToken, + refresh_token: session.state.refreshToken, + }, })) as any; this.logger.debug('User session has been successfully invalidated.'); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/pki.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/pki.test.ts index e5b862f579965..a6125417111ff 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/pki.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/pki.test.ts @@ -22,6 +22,7 @@ import { mockAuthenticationProviderOptions } from './base.mock'; import { PKIAuthenticationProvider } from './pki'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; +import { sessionMock } from '../../session_management/session.mock'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -409,12 +410,11 @@ describe('PKIAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest({ headers: { authorization: 'Bearer some-token' }, }); - const state = { - accessToken: 'some-valid-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }; + const sessionValue = sessionMock.createValue({ + state: { accessToken: 'some-valid-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + }); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.notHandled() ); @@ -454,9 +454,11 @@ describe('PKIAuthenticationProvider', () => { const { socket } = getMockSocket({ authorized: true }); const request = httpServerMock.createKibanaRequest({ socket }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + const sessionValue = sessionMock.createValue({ + state: { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + }); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed(new Error('Peer certificate is not available')) ); @@ -466,15 +468,17 @@ describe('PKIAuthenticationProvider', () => { it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => { const { socket } = getMockSocket(); const request = httpServerMock.createKibanaRequest({ socket }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + const sessionValue = sessionMock.createValue({ + state: { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + }); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized()) ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, + accessToken: sessionValue.state.accessToken, }); }); @@ -482,15 +486,17 @@ describe('PKIAuthenticationProvider', () => { const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD'); const { socket } = getMockSocket({ peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + const sessionValue = sessionMock.createValue({ + state: { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + }); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized()) ); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, + accessToken: sessionValue.state.accessToken, }); }); @@ -499,14 +505,16 @@ describe('PKIAuthenticationProvider', () => { const peerCertificate = getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']); const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket }); - const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '3A:9A:C5:DD' }; + const sessionValue = sessionMock.createValue({ + state: { accessToken: 'existing-token', peerCertificateFingerprint256: '3A:9A:C5:DD' }, + }); mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ authentication: user, access_token: 'access-token', }); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: { type: 'pki', name: 'pki' } }, { @@ -519,7 +527,7 @@ describe('PKIAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, + accessToken: sessionValue.state.accessToken, }); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -556,11 +564,10 @@ describe('PKIAuthenticationProvider', () => { peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), }).socket, }); - const nonAjaxState = { - accessToken: 'existing-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }; - await expect(provider.authenticate(nonAjaxRequest, nonAjaxState)).resolves.toEqual( + const nonAjaxSessionValue = sessionMock.createValue({ + state: { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + }); + await expect(provider.authenticate(nonAjaxRequest, nonAjaxSessionValue)).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Bearer access-token' }, state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, @@ -574,11 +581,10 @@ describe('PKIAuthenticationProvider', () => { peerCertificate: getMockPeerCertificate(['3A:7A:C2:DD', '3B:8B:D3:EE']), }).socket, }); - const ajaxState = { - accessToken: 'existing-token', - peerCertificateFingerprint256: '3A:7A:C2:DD', - }; - await expect(provider.authenticate(ajaxRequest, ajaxState)).resolves.toEqual( + const ajaxSessionValue = sessionMock.createValue({ + state: { accessToken: 'existing-token', peerCertificateFingerprint256: '3A:7A:C2:DD' }, + }); + await expect(provider.authenticate(ajaxRequest, ajaxSessionValue)).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Bearer access-token' }, state: { accessToken: 'access-token', peerCertificateFingerprint256: '3A:7A:C2:DD' }, @@ -592,11 +598,12 @@ describe('PKIAuthenticationProvider', () => { peerCertificate: getMockPeerCertificate(['4A:7A:C2:DD', '3B:8B:D3:EE']), }).socket, }); - const optionalAuthState = { - accessToken: 'existing-token', - peerCertificateFingerprint256: '4A:7A:C2:DD', - }; - await expect(provider.authenticate(optionalAuthRequest, optionalAuthState)).resolves.toEqual( + const optionalAuthSessionValue = sessionMock.createValue({ + state: { accessToken: 'existing-token', peerCertificateFingerprint256: '4A:7A:C2:DD' }, + }); + await expect( + provider.authenticate(optionalAuthRequest, optionalAuthSessionValue) + ).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Bearer access-token' }, state: { accessToken: 'access-token', peerCertificateFingerprint256: '4A:7A:C2:DD' }, @@ -643,7 +650,9 @@ describe('PKIAuthenticationProvider', () => { it('fails with 401 if existing token is expired, but certificate is not present.', async () => { const { socket } = getMockSocket(); const request = httpServerMock.createKibanaRequest({ socket }); - const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + const sessionValue = sessionMock.createValue({ + state: { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + }); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( @@ -651,7 +660,7 @@ describe('PKIAuthenticationProvider', () => { ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized()) ); @@ -662,8 +671,12 @@ describe('PKIAuthenticationProvider', () => { it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const peerCertificate = getMockPeerCertificate(state.peerCertificateFingerprint256); + const sessionValue = sessionMock.createValue({ + state: { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + }); + const peerCertificate = getMockPeerCertificate( + sessionValue.state.peerCertificateFingerprint256 + ); const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); @@ -671,10 +684,10 @@ describe('PKIAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: { type: 'pki', name: 'pki' } }, - { authHeaders: { authorization: `Bearer ${state.accessToken}` } } + { authHeaders: { authorization: `Bearer ${sessionValue.state.accessToken}` } } ) ); @@ -686,8 +699,12 @@ describe('PKIAuthenticationProvider', () => { }); it('fails if token from the state is rejected because of unknown reason.', async () => { - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const peerCertificate = getMockPeerCertificate(state.peerCertificateFingerprint256); + const sessionValue = sessionMock.createValue({ + state: { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }, + }); + const peerCertificate = getMockPeerCertificate( + sessionValue.state.peerCertificateFingerprint256 + ); const { socket } = getMockSocket({ authorized: true, peerCertificate }); const request = httpServerMock.createKibanaRequest({ socket, headers: {} }); @@ -699,7 +716,7 @@ describe('PKIAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed(failureReason) ); @@ -708,7 +725,7 @@ describe('PKIAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `notHandled` if state is not presented.', async () => { + it('returns `notHandled` if session is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); @@ -716,7 +733,7 @@ describe('PKIAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); - it('redirects to logged out view if state is `null`.', async () => { + it('redirects to logged out view if session is `null`.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request, null)).resolves.toEqual( @@ -733,7 +750,7 @@ describe('PKIAuthenticationProvider', () => { const failureReason = new Error('failed to delete token'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - await expect(provider.logout(request, state)).resolves.toEqual( + await expect(provider.logout(request, sessionMock.createValue({ state }))).resolves.toEqual( DeauthenticationResult.failed(failureReason) ); @@ -747,7 +764,7 @@ describe('PKIAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - await expect(provider.logout(request, state)).resolves.toEqual( + await expect(provider.logout(request, sessionMock.createValue({ state }))).resolves.toEqual( DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/pki.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/pki.ts index c99fd5a44b773..9ba66c90c80e4 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/pki.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/pki.ts @@ -14,6 +14,7 @@ import { HTTPAuthorizationHeader } from '@kbn/core-security-server'; import { BaseAuthenticationProvider } from './base'; import type { AuthenticationInfo } from '../../elasticsearch'; import { getDetailedErrorMessage } from '../../errors'; +import type { SessionValue } from '../../session_management'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -91,7 +92,7 @@ function stringifyCertificate(peerCertificate: DetailedPeerCertificate) { /** * Provider that supports PKI request authentication. */ -export class PKIAuthenticationProvider extends BaseAuthenticationProvider { +export class PKIAuthenticationProvider extends BaseAuthenticationProvider { /** * Type of the provider. */ @@ -109,9 +110,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { /** * Performs PKI request authentication. * @param request Request instance. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ - public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + public async authenticate(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug( `Trying to authenticate user request to ${request.url.pathname}${request.url.search}.` ); @@ -122,8 +123,8 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { } let authenticationResult = AuthenticationResult.notHandled(); - if (state) { - authenticationResult = await this.authenticateViaState(request, state); + if (session?.state) { + authenticationResult = await this.authenticateViaState(request, session); // If access token expired or doesn't match to the certificate fingerprint we should try to get // a new one in exchange to peer certificate chain. Since we know that we had a valid session @@ -134,7 +135,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { (authenticationResult.failed() && Tokens.isAccessTokenExpiredError(authenticationResult.error)); if (invalidAccessToken) { - authenticationResult = await this.authenticateViaPeerCertificate(request, state); + authenticationResult = await this.authenticateViaPeerCertificate(request, session.state); // If we have an active session that we couldn't use to authenticate user and at the same time // we couldn't use peer's certificate to establish a new one, then we should respond with 401 // and force authenticator to clear the session. @@ -148,28 +149,28 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { // to start a new session, and if so try to authenticate request using its peer certificate chain, // otherwise just return authentication result we have. return authenticationResult.notHandled() && canStartNewSession(request) - ? await this.authenticateViaPeerCertificate(request, state) + ? await this.authenticateViaPeerCertificate(request, session?.state) : authenticationResult; } /** * Invalidates access token retrieved in exchange for peer certificate chain if it exists. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param [session] Optional session object associated with the provider. */ - public async logout(request: KibanaRequest, state?: ProviderState | null) { + public async logout(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug(`Trying to log user out via ${request.url.pathname}${request.url.search}.`); - // Having a `null` state means that provider was specifically called to do a logout, but when + // Having a `null` session means that provider was specifically called to do a logout, but when // session isn't defined then provider is just being probed whether or not it can perform logout. - if (state === undefined) { + if (session === undefined) { this.logger.debug('There is no access token to invalidate.'); return DeauthenticationResult.notHandled(); } - if (state) { + if (session?.state) { try { - await this.options.tokens.invalidate({ accessToken: state.accessToken }); + await this.options.tokens.invalidate({ accessToken: session.state.accessToken }); } catch (err) { this.logger.debug( () => `Failed invalidating access token: ${getDetailedErrorMessage(err)}` @@ -193,12 +194,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { * Tries to extract access token from state and adds it to the request before it's * forwarded to Elasticsearch backend. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session value previously stored by the provider. */ - private async authenticateViaState( - request: KibanaRequest, - { accessToken, peerCertificateFingerprint256 }: ProviderState - ) { + private async authenticateViaState(request: KibanaRequest, session: SessionValue) { this.logger.debug('Trying to authenticate via state.'); // If peer is authorized, but its certificate isn't available, that likely means the connection @@ -215,14 +213,14 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { if ( !request.socket.authorized || peerCertificate === null || - (peerCertificate as any).fingerprint256 !== peerCertificateFingerprint256 + (peerCertificate as any).fingerprint256 !== session.state.peerCertificateFingerprint256 ) { this.logger.debug( 'Peer certificate is not present or its fingerprint does not match to the one associated with the access token. Invalidating access token...' ); try { - await this.options.tokens.invalidate({ accessToken }); + await this.options.tokens.invalidate({ accessToken: session.state.accessToken }); } catch (err) { this.logger.error(`Failed to invalidate access token: ${getDetailedErrorMessage(err)}`); return AuthenticationResult.failed(err); @@ -233,11 +231,11 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', session.state.accessToken).toString(), + }; try { - const authHeaders = { - authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), - }; - const user = await this.getUser(request, authHeaders); + const user = await this.getUser(request, authHeaders, session); this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts index be6a1b66020d4..0ef6b578ed92c 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts @@ -22,6 +22,7 @@ import { import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { InvalidGrantError } from '../../errors'; import { securityMock } from '../../mocks'; +import { sessionMock } from '../../session_management/session.mock'; import { mockSamlResponses } from '../__fixtures__/mock_saml_responses'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -66,14 +67,16 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: mockSAMLSet1.samlResponse, }, - { - requestIdMap: { - [mockSAMLSet1.requestId]: { - redirectURL: '/test-base-path/some-path#some-app', + sessionMock.createValue({ + state: { + requestIdMap: { + [mockSAMLSet1.requestId]: { + redirectURL: '/test-base-path/some-path#some-app', + }, }, + realm: 'test-realm', }, - realm: 'test-realm', - } + }) ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { @@ -120,12 +123,14 @@ describe('SAMLAuthenticationProvider', () => { samlResponse: mockSAMLSet1.samlResponse, relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, }, - { - requestIdMap: { - [mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path#some-app' }, + sessionMock.createValue({ + state: { + requestIdMap: { + [mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path#some-app' }, + }, + realm: 'test-realm', }, - realm: 'test-realm', - } + }) ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { @@ -160,7 +165,7 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }, - {} as any + sessionMock.createValue({ state: {} }) ) ).resolves.toEqual( AuthenticationResult.failed( @@ -185,7 +190,7 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }, - { realm: 'other-realm' } + sessionMock.createValue({ state: { realm: 'other-realm' } }) ) ).resolves.toEqual( AuthenticationResult.failed( @@ -216,10 +221,12 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: mockSAMLSet1.samlResponse, }, - { - requestIdMap: { [mockSAMLSet1.requestId]: { redirectURL: '' } }, - realm: 'test-realm', - } + sessionMock.createValue({ + state: { + requestIdMap: { [mockSAMLSet1.requestId]: { redirectURL: '' } }, + realm: 'test-realm', + }, + }) ) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { @@ -266,10 +273,12 @@ describe('SAMLAuthenticationProvider', () => { samlResponse: mockSAMLSet1.samlResponse, relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`, }, - { - requestIdMap: { [mockSAMLSet1.requestId]: { redirectURL: '' } }, - realm: 'test-realm', - } + sessionMock.createValue({ + state: { + requestIdMap: { [mockSAMLSet1.requestId]: { redirectURL: '' } }, + realm: 'test-realm', + }, + }) ) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { @@ -343,12 +352,14 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: mockSAMLSet1.samlResponse, }, - { - requestIdMap: { - [mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path' }, + sessionMock.createValue({ + state: { + requestIdMap: { + [mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path' }, + }, + realm: 'test-realm', }, - realm: 'test-realm', - } + }) ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -391,10 +402,12 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: mockSamlResponses.set25.samlResponse, }, - { - requestIdMap, - realm: 'test-realm', - } + sessionMock.createValue({ + state: { + requestIdMap, + realm: 'test-realm', + }, + }) ) ).resolves.toEqual( AuthenticationResult.redirectTo('/path25', { @@ -437,7 +450,7 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginInitiatedByUser, redirectURL: '/path51', }, - { requestIdMap } + sessionMock.createValue({ state: { requestIdMap } }) ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -673,11 +686,13 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }, - { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - realm: 'test-realm', - } + sessionMock.createValue({ + state: { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', + }, + }) ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -709,11 +724,13 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }, - { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - realm: 'test-realm', - } + sessionMock.createValue({ + state: { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', + }, + }) ) ).resolves.toEqual(AuthenticationResult.notHandled()); @@ -727,12 +744,14 @@ describe('SAMLAuthenticationProvider', () => { it('fails if fails to invalidate existing access/refresh tokens.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'existing-valid-token', + refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ username: 'user', @@ -751,7 +770,7 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }, - state + sessionValue ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -764,8 +783,8 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, + accessToken: sessionValue.state.accessToken, + refreshToken: sessionValue.state.refreshToken, }); }); @@ -789,12 +808,14 @@ describe('SAMLAuthenticationProvider', () => { ] as Array<[string, any]>) { it(`redirects to the home page if ${description}.`, async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + realm: 'test-realm', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; // The first call is made using tokens from existing session. mockScopedClusterClient.asCurrentUser.security.authenticate.mockImplementationOnce( @@ -816,7 +837,7 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }, - state + sessionValue ) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/', { @@ -839,19 +860,21 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, + accessToken: sessionValue.state.accessToken, + refreshToken: sessionValue.state.refreshToken, }); }); it(`redirects to the URL from relay state if ${description}.`, async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + realm: 'test-realm', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; // The first call is made using tokens from existing session. mockScopedClusterClient.asCurrentUser.security.authenticate.mockImplementationOnce( @@ -879,7 +902,7 @@ describe('SAMLAuthenticationProvider', () => { samlResponse: 'saml-response-xml', relayState: '/mock-server-basepath/app/some-app#some-deep-link', }, - state + sessionValue ) ).resolves.toEqual( AuthenticationResult.redirectTo('/mock-server-basepath/app/some-app#some-deep-link', { @@ -902,8 +925,8 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, + accessToken: sessionValue.state.accessToken, + refreshToken: sessionValue.state.refreshToken, }); }); } @@ -1033,7 +1056,7 @@ describe('SAMLAuthenticationProvider', () => { type: SAMLLogin.LoginInitiatedByUser, redirectURL: '/test-base-path/some-path#some-fragment', }, - { realm: 'test-realm' } + sessionMock.createValue({ state: { realm: 'test-realm' } }) ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -1118,11 +1141,16 @@ describe('SAMLAuthenticationProvider', () => { }); await expect( - provider.authenticate(request, { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - realm: 'test-realm', - }) + provider.authenticate( + request, + sessionMock.createValue({ + state: { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', + }, + }) + ) ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); @@ -1190,14 +1218,16 @@ describe('SAMLAuthenticationProvider', () => { it('succeeds if state contains a valid token.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization } }) ); @@ -1208,19 +1238,21 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from the state is rejected because of unknown reason.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; const failureReason = new errors.ResponseError( securityMock.createApiResponse({ statusCode: 500, body: {} }) ); mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue(failureReason); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed(failureReason) ); @@ -1231,11 +1263,13 @@ describe('SAMLAuthenticationProvider', () => { it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { const request = httpServerMock.createKibanaRequest(); - const state = { - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - realm: 'test-realm', - }; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'expired-token', + refreshToken: 'valid-refresh-token', + realm: 'test-realm', + }, + }); mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -1247,7 +1281,7 @@ describe('SAMLAuthenticationProvider', () => { authenticationInfo: mockUser, }); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization: 'Bearer new-access-token' }, state: { @@ -1259,19 +1293,21 @@ describe('SAMLAuthenticationProvider', () => { ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(sessionValue.state.refreshToken); expect(request.headers).not.toHaveProperty('authorization'); }); it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'expired-token', - refreshToken: 'invalid-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'expired-token', + refreshToken: 'invalid-refresh-token', + realm: 'test-realm', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -1283,12 +1319,12 @@ describe('SAMLAuthenticationProvider', () => { }; mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed(refreshFailureReason as any) ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(sessionValue.state.refreshToken); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); @@ -1297,12 +1333,14 @@ describe('SAMLAuthenticationProvider', () => { it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); - const state = { - accessToken: 'expired-token', - refreshToken: 'expired-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'expired-token', + refreshToken: 'expired-refresh-token', + realm: 'test-realm', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -1312,7 +1350,7 @@ describe('SAMLAuthenticationProvider', () => { InvalidGrantError.expiredOrInvalidRefreshToken() ); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( 'Your session has expired because your refresh token is no longer valid. Please log in again.' @@ -1321,7 +1359,7 @@ describe('SAMLAuthenticationProvider', () => { ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(sessionValue.state.refreshToken); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { 'kbn-xsrf': 'xsrf', authorization }, @@ -1332,12 +1370,14 @@ describe('SAMLAuthenticationProvider', () => { it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} }); - const state = { - accessToken: 'expired-token', - refreshToken: 'expired-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'expired-token', + refreshToken: 'expired-refresh-token', + realm: 'test-realm', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -1347,7 +1387,7 @@ describe('SAMLAuthenticationProvider', () => { InvalidGrantError.expiredOrInvalidRefreshToken() ); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( 'Your session has expired because your refresh token is no longer valid. Please log in again.' @@ -1356,7 +1396,7 @@ describe('SAMLAuthenticationProvider', () => { ); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(sessionValue.state.refreshToken); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); @@ -1368,12 +1408,13 @@ describe('SAMLAuthenticationProvider', () => { '/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml' ); const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); - const state = { - accessToken: 'expired-token', - refreshToken: 'expired-refresh-token', - realm: 'test-realm', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'expired-token', + refreshToken: 'expired-refresh-token', + realm: 'test-realm', + }, + }); mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -1383,7 +1424,7 @@ describe('SAMLAuthenticationProvider', () => { InvalidGrantError.expiredOrInvalidRefreshToken() ); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml', { state: null } @@ -1396,9 +1437,11 @@ describe('SAMLAuthenticationProvider', () => { ]); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(sessionValue.state.refreshToken); - expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: { authorization: `Bearer ${sessionValue.state.accessToken}` }, + }); expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); @@ -1412,7 +1455,9 @@ describe('SAMLAuthenticationProvider', () => { realm: 'test-realm', }); - await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: { realm: 'other-realm' } })) + ).resolves.toEqual( AuthenticationResult.failed( Boom.unauthorized( 'State based on realm "other-realm", but provider with the name "saml" is configured to use realm "test-realm".' @@ -1423,7 +1468,7 @@ describe('SAMLAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `notHandled` if state is not presented or does not include access token.', async () => { + it('returns `notHandled` if session is not presented or does not include access token.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); @@ -1431,15 +1476,15 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); - it('redirects to logged out view if state is `null` or does not include access token.', async () => { + it('redirects to logged out view if session is `null` or does not include access token.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request, null)).resolves.toEqual( DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) ); - await expect(provider.logout(request, { somethingElse: 'x' } as any)).resolves.toEqual( - DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)) - ); + await expect( + provider.logout(request, sessionMock.createValue({ state: { somethingElse: 'x' } as any })) + ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled(); }); @@ -1455,11 +1500,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: 'test-realm' }, + }) + ) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -1501,11 +1547,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: 'test-realm' }, + }) + ) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -1526,11 +1573,12 @@ describe('SAMLAuthenticationProvider', () => { }); await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: 'test-realm' }, + }) + ) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -1551,11 +1599,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: 'test-realm' }, + }) + ) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -1572,11 +1621,16 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { - accessToken: 'x-saml-token', - refreshToken: 'x-saml-refresh-token', - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { + accessToken: 'x-saml-token', + refreshToken: 'x-saml-refresh-token', + realm: 'test-realm', + }, + }) + ) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1); @@ -1649,11 +1703,12 @@ describe('SAMLAuthenticationProvider', () => { }); await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: 'test-realm' }, + }) + ) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); @@ -1669,11 +1724,16 @@ describe('SAMLAuthenticationProvider', () => { }); await expect( - provider.logout(request, { - accessToken: 'x-saml-token', - refreshToken: 'x-saml-refresh-token', - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { + accessToken: 'x-saml-token', + refreshToken: 'x-saml-refresh-token', + realm: 'test-realm', + }, + }) + ) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); @@ -1776,12 +1836,14 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: mockSAMLSet1.samlResponse }, - { - requestIdMap: { - [mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path#some-app' }, + sessionMock.createValue({ + state: { + requestIdMap: { + [mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path#some-app' }, + }, + realm: ELASTIC_CLOUD_SSO_REALM_NAME, }, - realm: ELASTIC_CLOUD_SSO_REALM_NAME, - } + }) ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { @@ -1815,14 +1877,16 @@ describe('SAMLAuthenticationProvider', () => { describe('`authenticate` method', () => { it('properly constructs authentication headers when UIAM is enabled.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'essu_dev_some-valid-token', - refreshToken: 'essu_dev_some-valid-refresh-token', - realm: ELASTIC_CLOUD_SSO_REALM_NAME, - }; - const authorization = `Bearer ${state.accessToken}`; - - await expect(provider.authenticate(request, state)).resolves.toEqual( + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'essu_dev_some-valid-token', + refreshToken: 'essu_dev_some-valid-refresh-token', + realm: ELASTIC_CLOUD_SSO_REALM_NAME, + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; + + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization, [ES_CLIENT_AUTHENTICATION_HEADER]: 'some-shared-secret' }, }) @@ -1859,11 +1923,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.uiam?.invalidateSessionTokens.mockRejectedValue(failureReason); await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: ELASTIC_CLOUD_SSO_REALM_NAME, - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: ELASTIC_CLOUD_SSO_REALM_NAME }, + }) + ) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.uiam?.invalidateSessionTokens).toHaveBeenCalledTimes(1); @@ -1881,11 +1946,12 @@ describe('SAMLAuthenticationProvider', () => { const refreshToken = 'essu_dev_x-saml-refresh-token'; await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: 'test-realm' }, + }) + ) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.uiam?.invalidateSessionTokens).toHaveBeenCalledTimes(1); @@ -1901,11 +1967,13 @@ describe('SAMLAuthenticationProvider', () => { describe('refresh token handling', () => { it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { const request = httpServerMock.createKibanaRequest(); - const state = { - accessToken: 'essu_dev_expired-token', - refreshToken: 'essu_dev_valid-refresh-token', - realm: 'cloud-saml-kibana', - }; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'essu_dev_expired-token', + refreshToken: 'essu_dev_valid-refresh-token', + realm: 'cloud-saml-kibana', + }, + }); mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValueOnce( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -1921,7 +1989,7 @@ describe('SAMLAuthenticationProvider', () => { value: 'some-secret', }); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization: 'Bearer essu_dev_new-access-token' }, userProfileGrant: { @@ -1938,7 +2006,9 @@ describe('SAMLAuthenticationProvider', () => { ); expect(mockOptions.uiam?.refreshSessionTokens).toHaveBeenCalledTimes(1); - expect(mockOptions.uiam?.refreshSessionTokens).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.uiam?.refreshSessionTokens).toHaveBeenCalledWith( + sessionValue.state.refreshToken + ); expect(request.headers).not.toHaveProperty('authorization'); expect(request.headers).not.toHaveProperty(ES_CLIENT_AUTHENTICATION_HEADER); @@ -1946,12 +2016,14 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from the state is expired, refresh attempt failed, and displays error from UIAM', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'essu_dev_expired-token', - refreshToken: 'essu_dev_invalid-refresh-token', - realm: 'cloud-saml-kibana', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'essu_dev_expired-token', + refreshToken: 'essu_dev_invalid-refresh-token', + realm: 'cloud-saml-kibana', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -1960,12 +2032,14 @@ describe('SAMLAuthenticationProvider', () => { const refreshFailureReason = new Boom.Boom('Authentication failed'); mockOptions.uiam?.refreshSessionTokens.mockRejectedValue(refreshFailureReason); - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(refreshFailureReason as any) + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( + AuthenticationResult.failed(refreshFailureReason) ); expect(mockOptions.uiam?.refreshSessionTokens).toHaveBeenCalledTimes(1); - expect(mockOptions.uiam?.refreshSessionTokens).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.uiam?.refreshSessionTokens).toHaveBeenCalledWith( + sessionValue.state.refreshToken + ); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization, [ES_CLIENT_AUTHENTICATION_HEADER]: 'some-shared-secret' }, @@ -2008,12 +2082,14 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: mockSAMLSet1.samlResponse }, - { - requestIdMap: { - [mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path#some-app' }, + sessionMock.createValue({ + state: { + requestIdMap: { + [mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path#some-app' }, + }, + realm: ELASTIC_CLOUD_SSO_REALM_NAME, }, - realm: ELASTIC_CLOUD_SSO_REALM_NAME, - } + }) ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { @@ -2043,14 +2119,16 @@ describe('SAMLAuthenticationProvider', () => { describe('`authenticate` method', () => { it('properly constructs authentication headers only with ES native access token when UIAM is enabled.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'x_essu_dev_some-valid-token', - refreshToken: 'x_essu_dev_some-valid-refresh-token', - realm: ELASTIC_CLOUD_SSO_REALM_NAME, - }; - const authorization = `Bearer ${state.accessToken}`; - - await expect(provider.authenticate(request, state)).resolves.toEqual( + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'x_essu_dev_some-valid-token', + refreshToken: 'x_essu_dev_some-valid-refresh-token', + realm: ELASTIC_CLOUD_SSO_REALM_NAME, + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; + + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization } }) ); @@ -2083,11 +2161,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockRejectedValue(failureReason); await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: ELASTIC_CLOUD_SSO_REALM_NAME, - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: ELASTIC_CLOUD_SSO_REALM_NAME }, + }) + ) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.uiam?.invalidateSessionTokens).not.toHaveBeenCalled(); @@ -2108,11 +2187,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.asInternalUser.transport.request.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { - accessToken, - refreshToken, - realm: 'test-realm', - }) + provider.logout( + request, + sessionMock.createValue({ + state: { accessToken, refreshToken, realm: 'test-realm' }, + }) + ) ).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))); expect(mockOptions.uiam?.invalidateSessionTokens).not.toHaveBeenCalled(); @@ -2129,11 +2209,13 @@ describe('SAMLAuthenticationProvider', () => { describe('refresh token handling', () => { it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => { const request = httpServerMock.createKibanaRequest(); - const state = { - accessToken: 'x_essu_dev_expired-token', - refreshToken: 'x_essu_dev_valid-refresh-token', - realm: 'cloud-saml-kibana', - }; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'x_essu_dev_expired-token', + refreshToken: 'x_essu_dev_valid-refresh-token', + realm: 'cloud-saml-kibana', + }, + }); mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValueOnce( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -2145,7 +2227,7 @@ describe('SAMLAuthenticationProvider', () => { authenticationInfo: mockUser, }); - await expect(provider.authenticate(request, state)).resolves.toEqual( + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( AuthenticationResult.succeeded(mockUser, { authHeaders: { authorization: 'Bearer x_essu_dev_new-access-token' }, state: { @@ -2159,7 +2241,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.uiam?.refreshSessionTokens).not.toHaveBeenCalled(); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(sessionValue.state.refreshToken); expect(request.headers).not.toHaveProperty('authorization'); expect(request.headers).not.toHaveProperty(ES_CLIENT_AUTHENTICATION_HEADER); @@ -2167,12 +2249,14 @@ describe('SAMLAuthenticationProvider', () => { it('fails if token from the state is expired, refresh attempt failed, and displays error from UIAM', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); - const state = { - accessToken: 'x_essu_dev_expired-token', - refreshToken: 'x_essu_dev_invalid-refresh-token', - realm: 'cloud-saml-kibana', - }; - const authorization = `Bearer ${state.accessToken}`; + const sessionValue = sessionMock.createValue({ + state: { + accessToken: 'x_essu_dev_expired-token', + refreshToken: 'x_essu_dev_invalid-refresh-token', + realm: 'cloud-saml-kibana', + }, + }); + const authorization = `Bearer ${sessionValue.state.accessToken}`; mockScopedClusterClient.asCurrentUser.security.authenticate.mockRejectedValue( new errors.ResponseError(securityMock.createApiResponse({ statusCode: 401, body: {} })) @@ -2181,14 +2265,14 @@ describe('SAMLAuthenticationProvider', () => { const refreshFailureReason = new Boom.Boom('Authentication failed'); mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(refreshFailureReason as any) + await expect(provider.authenticate(request, sessionValue)).resolves.toEqual( + AuthenticationResult.failed(refreshFailureReason) ); expect(mockOptions.uiam?.refreshSessionTokens).not.toHaveBeenCalled(); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(sessionValue.state.refreshToken); expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ headers: { authorization } }); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts index 845c30d83f1a6..7ea4c4543f8d7 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts @@ -166,15 +166,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider | null ) { this.logger.debug('Trying to perform a login.'); + const state = session?.state; + // It may happen that Kibana is re-configured to use different realm for the same provider name, // we should clear such session and log user out. if (state && this.realm && state.realm !== this.realm) { @@ -189,12 +191,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider | null) { this.logger.debug(`Trying to log user out via ${request.url.pathname}${request.url.search}.`); + const state = session?.state; + // Normally when there is no active session in Kibana, `logout` method shouldn't do anything // and user will eventually be redirected to the home page to log in. But when SAML SLO is // supported there are two special cases that we need to handle even if there is no active @@ -296,7 +300,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider) { this.logger.debug('Trying to authenticate via state.'); - if (!session.state.accessToken) { + + const { accessToken } = session.state; + if (!accessToken) { this.logger.debug('Access token is not found in state.'); return AuthenticationResult.notHandled(); } - const authHeaders: Record | undefined = this.isUiamToken( - session.state.accessToken - ) - ? this.options.uiam.getAuthenticationHeaders(session.state.accessToken) - : { - authorization: new HTTPAuthorizationHeader( - 'Bearer', - session.state.accessToken - ).toString(), - }; + const authHeaders: Record | undefined = this.isUiamToken(accessToken) + ? this.options.uiam.getAuthenticationHeaders(accessToken) + : { authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString() }; try { const user = await this.getUser(request, authHeaders, session); @@ -888,7 +887,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { }); await expect( - provider.authenticate(request, { accessToken: 'foo', refreshToken: 'bar' }) + provider.authenticate( + request, + sessionMock.createValue({ + state: { accessToken: 'foo', refreshToken: 'bar' }, + }) + ) ).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); @@ -171,7 +177,9 @@ describe('TokenAuthenticationProvider', () => { mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: { type: 'token', name: 'token' } }, { authHeaders: { authorization } } @@ -200,7 +208,9 @@ describe('TokenAuthenticationProvider', () => { authenticationInfo: user, }); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: { type: 'token', name: 'token' } }, { @@ -230,9 +240,9 @@ describe('TokenAuthenticationProvider', () => { ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(authenticationError) - ); + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(AuthenticationResult.failed(authenticationError)); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); @@ -255,9 +265,9 @@ describe('TokenAuthenticationProvider', () => { ); mockOptions.tokens.refresh.mockRejectedValue(refreshError); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(refreshError) - ); + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(AuthenticationResult.failed(refreshError)); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); @@ -282,7 +292,9 @@ describe('TokenAuthenticationProvider', () => { InvalidGrantError.expiredOrInvalidRefreshToken() ); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual( AuthenticationResult.redirectTo( '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fsome-path', { state: null } @@ -315,7 +327,9 @@ describe('TokenAuthenticationProvider', () => { InvalidGrantError.expiredOrInvalidRefreshToken() ); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( 'Your session has expired because your refresh token is no longer valid. Please log in again.' @@ -351,7 +365,9 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.refresh.mockRejectedValue( InvalidGrantError.expiredOrInvalidRefreshToken() ); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( 'Your session has expired because your refresh token is no longer valid. Please log in again.' @@ -369,7 +385,7 @@ describe('TokenAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `notHandled` if state is not presented.', async () => { + it('returns `notHandled` if session is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); @@ -377,7 +393,7 @@ describe('TokenAuthenticationProvider', () => { expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); }); - it('redirects to the logged out URL if state is `null`.', async () => { + it('redirects to the logged out URL if session is `null`.', async () => { await expect(provider.logout(httpServerMock.createKibanaRequest(), null)).resolves.toEqual( DeauthenticationResult.redirectTo('/some-logged-out-page') ); @@ -392,9 +408,9 @@ describe('TokenAuthenticationProvider', () => { const failureReason = new Error('failed to delete token'); mockOptions.tokens.invalidate.mockRejectedValue(failureReason); - await expect(provider.logout(request, tokenPair)).resolves.toEqual( - DeauthenticationResult.failed(failureReason) - ); + await expect( + provider.logout(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); @@ -406,9 +422,9 @@ describe('TokenAuthenticationProvider', () => { mockOptions.tokens.invalidate.mockResolvedValue(undefined); - await expect(provider.logout(request, tokenPair)).resolves.toEqual( - DeauthenticationResult.redirectTo('/some-logged-out-page') - ); + await expect( + provider.logout(request, sessionMock.createValue({ state: tokenPair })) + ).resolves.toEqual(DeauthenticationResult.redirectTo('/some-logged-out-page')); expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/token.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/token.ts index 8c63da1cb1c1e..24e7fab32d896 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/token.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/token.ts @@ -14,6 +14,7 @@ import { BaseAuthenticationProvider } from './base'; import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants'; import type { AuthenticationInfo } from '../../elasticsearch'; import { getDetailedErrorMessage, InvalidGrantError } from '../../errors'; +import type { SessionValue } from '../../session_management'; import { AuthenticationResult } from '../authentication_result'; import { canRedirectRequest } from '../can_redirect_request'; import { DeauthenticationResult } from '../deauthentication_result'; @@ -46,7 +47,7 @@ function canStartNewSession(request: KibanaRequest) { /** * Provider that supports token-based request authentication. */ -export class TokenAuthenticationProvider extends BaseAuthenticationProvider { +export class TokenAuthenticationProvider extends BaseAuthenticationProvider { /** * Type of the provider. */ @@ -56,13 +57,8 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * Performs initial login request using username and password. * @param request Request instance. * @param attempt User credentials. - * @param [state] Optional state object associated with the provider. */ - public async login( - request: KibanaRequest, - { username, password }: ProviderLoginAttempt, - state?: ProviderState | null - ) { + public async login(request: KibanaRequest, { username, password }: ProviderLoginAttempt) { this.logger.debug('Trying to perform a login.'); try { @@ -100,9 +96,9 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { /** * Performs token-based request authentication * @param request Request instance. - * @param [state] Optional state object associated with the provider. + * @param [session] Optional session object associated with the provider. */ - public async authenticate(request: KibanaRequest, state?: ProviderState | null) { + public async authenticate(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug( `Trying to authenticate user request to ${request.url.pathname}${request.url.search}.` ); @@ -113,13 +109,13 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { } let authenticationResult = AuthenticationResult.notHandled(); - if (state) { - authenticationResult = await this.authenticateViaState(request, state); + if (session?.state) { + authenticationResult = await this.authenticateViaState(request, session); if ( authenticationResult.failed() && Tokens.isAccessTokenExpiredError(authenticationResult.error) ) { - authenticationResult = await this.authenticateViaRefreshToken(request, state); + authenticationResult = await this.authenticateViaRefreshToken(request, session.state); } } @@ -136,22 +132,22 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { /** * Redirects user to the login page preserving query string parameters. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param [session] Optional session object associated with the provider. */ - public async logout(request: KibanaRequest, state?: ProviderState | null) { + public async logout(request: KibanaRequest, session?: SessionValue | null) { this.logger.debug(`Trying to log user out via ${request.url.pathname}${request.url.search}.`); - // Having a `null` state means that provider was specifically called to do a logout, but when + // Having a `null` session means that provider was specifically called to do a logout, but when // session isn't defined then provider is just being probed whether or not it can perform logout. - if (state === undefined) { + if (session === undefined) { this.logger.debug('There are no access and refresh tokens to invalidate.'); return DeauthenticationResult.notHandled(); } this.logger.debug('Token-based logout has been initiated by the user.'); - if (state) { + if (session?.state) { try { - await this.options.tokens.invalidate(state); + await this.options.tokens.invalidate(session.state); } catch (err) { this.logger.debug( `Failed invalidating user's access token: ${getDetailedErrorMessage(err)}` @@ -175,16 +171,16 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { * Tries to extract authorization header from the state and adds it to the request before * it's forwarded to Elasticsearch backend. * @param request Request instance. - * @param state State value previously stored by the provider. + * @param session Session value previously stored by the provider. */ - private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) { + private async authenticateViaState(request: KibanaRequest, session: SessionValue) { this.logger.debug('Trying to authenticate via state.'); + const authHeaders = { + authorization: new HTTPAuthorizationHeader('Bearer', session.state.accessToken).toString(), + }; try { - const authHeaders = { - authorization: new HTTPAuthorizationHeader('Bearer', accessToken).toString(), - }; - const user = await this.getUser(request, authHeaders); + const user = await this.getUser(request, authHeaders, session); this.logger.debug('Request has been authenticated via state.'); return AuthenticationResult.succeeded(user, { authHeaders }); diff --git a/x-pack/platform/plugins/shared/security/server/routes/analytics/record_violations.ts b/x-pack/platform/plugins/shared/security/server/routes/analytics/record_violations.ts index bec224a6d3eeb..6e78e60059f2d 100644 --- a/x-pack/platform/plugins/shared/security/server/routes/analytics/record_violations.ts +++ b/x-pack/platform/plugins/shared/security/server/routes/analytics/record_violations.ts @@ -136,6 +136,18 @@ export function defineRecordViolations({ router, analyticsService }: RouteDefini { path: '/internal/security/analytics/_record_violations', security: { + /** + * Browsers will stop sending reports for the duration of the browser session and without + * further retries once this endpoint has returned a single 403. This would effectively + * prevent us from capture any reports. To work around this behaviour we optionally + * authenticate users but silently ignore any reports that have been received from + * unauthenticated users. + */ + authc: { + enabled: 'optional', + reason: + 'Browsers stop sending violation reports after receiving a 403, so authentication must be optional to avoid losing reports from unauthenticated users', + }, authz: { enabled: false, reason: @@ -156,14 +168,6 @@ export function defineRecordViolations({ router, analyticsService }: RouteDefini ]), }, options: { - /** - * Browsers will stop sending reports for the duration of the browser session and without - * further retries once this endpoint has returned a single 403. This would effectively - * prevent us from capture any reports. To work around this behaviour we optionally - * authenticate users but silently ignore any reports that have been received from - * unauthenticated users. - */ - authRequired: 'optional', /** * This endpoint is called by the browser in the background so `kbn-xsrf` header is not sent. */ diff --git a/x-pack/platform/plugins/shared/security/server/session_management/session.mock.ts b/x-pack/platform/plugins/shared/security/server/session_management/session.mock.ts index 75bbd3075f67e..78bbb751ba18d 100644 --- a/x-pack/platform/plugins/shared/security/server/session_management/session.mock.ts +++ b/x-pack/platform/plugins/shared/security/server/session_management/session.mock.ts @@ -22,7 +22,9 @@ export const sessionMock = { invalidate: jest.fn(), }), - createValue: (sessionValue: Partial = {}): SessionValue => ({ + createValue: ( + sessionValue: Partial> = {} + ): SessionValue => ({ sid: 'some-long-sid', username: mockAuthenticatedUser().username, userProfileId: 'uid', @@ -30,7 +32,7 @@ export const sessionMock = { idleTimeoutExpiration: null, lifespanExpiration: null, createdAt: 1234567890, - state: undefined, + state: undefined as TState, metadata: { index: sessionIndexMock.createValue(sessionValue.metadata?.index) }, ...sessionValue, }), diff --git a/x-pack/platform/test/security_api_integration/anonymous.config.ts b/x-pack/platform/test/security_api_integration/anonymous.config.ts index 4b1e4c800be32..0c68b648ac131 100644 --- a/x-pack/platform/test/security_api_integration/anonymous.config.ts +++ b/x-pack/platform/test/security_api_integration/anonymous.config.ts @@ -16,6 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ); const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + const testEndpointsPlugin = resolve(__dirname, '../security_functional/plugins/test_endpoints'); const auditLogPath = resolve(__dirname, './plugins/audit_log/anonymous.log'); return { @@ -37,6 +38,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...xPackAPITestsConfig.get('kbnTestServer'), serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${testEndpointsPlugin}`, `--xpack.security.authc.selector.enabled=false`, `--xpack.security.authc.providers=${JSON.stringify({ anonymous: { diff --git a/x-pack/platform/test/security_api_integration/tests/anonymous/login.ts b/x-pack/platform/test/security_api_integration/tests/anonymous/login.ts index 9133d4c799a2b..4ce960d22653b 100644 --- a/x-pack/platform/test/security_api_integration/tests/anonymous/login.ts +++ b/x-pack/platform/test/security_api_integration/tests/anonymous/login.ts @@ -268,5 +268,42 @@ export default function ({ getService }: FtrProviderContext) { expect(auditEvents[1].kibana.authentication_provider).to.be('anonymous1'); }); }); + + it('should support minimal authentication', async () => { + // Anonymous provider automatically authenticates when accessing a page. + const loginResponse = await supertest.get('/security/account').expect(200); + + const cookies = loginResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = parseCookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + // Access the minimal and default auth endpoint with the session cookie. + const minimalResponse = await supertest + .get('/authentication/fast/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + const defaultResponse = await supertest + .get('/internal/security/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(minimalResponse.body.principal.username).to.eql(defaultResponse.body.username); + expect(minimalResponse.body.principal.username).to.eql('anonymous_user'); + + expect(minimalResponse.body.principal.authentication_provider).to.eql( + defaultResponse.body.authentication_provider + ); + expect(minimalResponse.body.principal.authentication_provider).to.eql({ + type: 'anonymous', + name: 'anonymous1', + }); + + // In minimal authentication mode, unlike when in default authentication mode, we don't call ES Authenticate API, + // so we don't have `authentication_realm` information available. + expect(minimalResponse.body.principal).to.not.have.property('authentication_realm'); + expect(defaultResponse.body).to.have.property('authentication_realm'); + }); }); } diff --git a/x-pack/platform/test/security_api_integration/tests/kerberos/kerberos_login.ts b/x-pack/platform/test/security_api_integration/tests/kerberos/kerberos_login.ts index 83c954f816d46..cac51b31eec7e 100644 --- a/x-pack/platform/test/security_api_integration/tests/kerberos/kerberos_login.ts +++ b/x-pack/platform/test/security_api_integration/tests/kerberos/kerberos_login.ts @@ -559,5 +559,44 @@ export default function ({ getService }: FtrProviderContext) { expect(auditEvents[0].kibana.authentication_provider).to.be('kerberos'); }); }); + + it('should support minimal authentication', async () => { + const response = await supertest + .get('/security/account') + .set('Authorization', `Negotiate ${spnegoToken}`) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = parseCookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + // Access the minimal and default auth endpoint with the session cookie. + const minimalResponse = await supertest + .get('/authentication/fast/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + const defaultResponse = await supertest + .get('/internal/security/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(minimalResponse.body.principal.username).to.eql(defaultResponse.body.username); + expect(minimalResponse.body.principal.username).to.eql('tester@TEST.ELASTIC.CO'); + + expect(minimalResponse.body.principal.authentication_provider).to.eql( + defaultResponse.body.authentication_provider + ); + expect(minimalResponse.body.principal.authentication_provider).to.eql({ + type: 'kerberos', + name: 'kerberos', + }); + + // In minimal authentication mode, unlike when in default authentication mode, we don't call ES Authenticate API, + // so we don't have `authentication_realm` information available. + expect(minimalResponse.body.principal).to.not.have.property('authentication_realm'); + expect(defaultResponse.body).to.have.property('authentication_realm'); + }); }); } diff --git a/x-pack/platform/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts b/x-pack/platform/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts index 774f633e0d3c3..3b9b98c27b6aa 100644 --- a/x-pack/platform/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts +++ b/x-pack/platform/test/security_api_integration/tests/oidc/authorization_code_flow/oidc_auth.ts @@ -753,5 +753,59 @@ export default function ({ getService }: FtrProviderContext) { expect(auditEvents[0].kibana.authentication_provider).to.be('oidc'); }); }); + + it('should support minimal authentication', async () => { + // Initiate OIDC handshake. + const handshakeResponse = await supertest + .get('/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad') + .expect(302); + + const handshakeCookie = parseCookie(handshakeResponse.headers['set-cookie'][0])!; + const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location); + + // Set the nonce in the mock OIDC Provider. + await supertest + .post('/api/oidc_provider/setup') + .set('kbn-xsrf', 'xxx') + .send({ nonce: stateAndNonce.nonce }) + .expect(200); + + // Complete the OIDC callback. + const oidcAuthenticationResponse = await supertest + .get(`/api/security/oidc/callback?code=code1&state=${stateAndNonce.state}`) + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + const cookies = oidcAuthenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = parseCookie(cookies[0])!; + + // Access the minimal and default auth endpoint with the session cookie. + const minimalResponse = await supertest + .get('/authentication/fast/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + const defaultResponse = await supertest + .get('/internal/security/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(minimalResponse.body.principal.username).to.eql(defaultResponse.body.username); + expect(minimalResponse.body.principal.username).to.eql('user1'); + + expect(minimalResponse.body.principal.authentication_provider).to.eql( + defaultResponse.body.authentication_provider + ); + expect(minimalResponse.body.principal.authentication_provider).to.eql({ + type: 'oidc', + name: 'oidc', + }); + + // In minimal authentication mode, unlike when in default authentication mode, we don't call ES Authenticate API, + // so we don't have `authentication_realm` information available. + expect(minimalResponse.body.principal).to.not.have.property('authentication_realm'); + expect(defaultResponse.body).to.have.property('authentication_realm'); + }); }); } diff --git a/x-pack/platform/test/security_api_integration/tests/pki/pki_auth.ts b/x-pack/platform/test/security_api_integration/tests/pki/pki_auth.ts index 3d68d0977511e..9aa7aa2fdf22e 100644 --- a/x-pack/platform/test/security_api_integration/tests/pki/pki_auth.ts +++ b/x-pack/platform/test/security_api_integration/tests/pki/pki_auth.ts @@ -542,5 +542,49 @@ export default function ({ getService }: FtrProviderContext) { expect(auditEvents[0].kibana.authentication_provider).to.be('pki'); }); }); + + it('should support minimal authentication', async () => { + const response = await supertest + .get('/security/account') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = parseCookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + // Access the minimal and default auth endpoint with the session cookie. + const minimalResponse = await supertest + .get('/authentication/fast/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + const defaultResponse = await supertest + .get('/internal/security/me') + .ca(CA_CERT) + .pfx(FIRST_CLIENT_CERT) + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(minimalResponse.body.principal.username).to.eql(defaultResponse.body.username); + expect(minimalResponse.body.principal.username).to.eql('first_client'); + + expect(minimalResponse.body.principal.authentication_provider).to.eql( + defaultResponse.body.authentication_provider + ); + expect(minimalResponse.body.principal.authentication_provider).to.eql({ + type: 'pki', + name: 'pki', + }); + + // In minimal authentication mode, unlike when in default authentication mode, we don't call ES Authenticate API, + // so we don't have `authentication_realm` information available. + expect(minimalResponse.body.principal).to.not.have.property('authentication_realm'); + expect(defaultResponse.body).to.have.property('authentication_realm'); + }); }); } diff --git a/x-pack/platform/test/security_api_integration/tests/saml/saml_login.ts b/x-pack/platform/test/security_api_integration/tests/saml/saml_login.ts index d3cec3762335f..adb27a2b8dc1e 100644 --- a/x-pack/platform/test/security_api_integration/tests/saml/saml_login.ts +++ b/x-pack/platform/test/security_api_integration/tests/saml/saml_login.ts @@ -978,5 +978,44 @@ export default function ({ getService }: FtrProviderContext) { expect(authFlow500ResponseText).to.contain('

Unauthenticated

'); }); }); + + it('should support minimal authentication', async () => { + // Authenticate via IdP initiated SAML login. + const samlAuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .send({ SAMLResponse: await createSAMLResponse() }) + .expect(302); + + const cookies = samlAuthenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = parseCookie(cookies[0])!; + + // Access the minimal and default auth endpoint with the session cookie. + const minimalResponse = await supertest + .get('/authentication/fast/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + const defaultResponse = await supertest + .get('/internal/security/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(minimalResponse.body.principal.username).to.eql(defaultResponse.body.username); + expect(minimalResponse.body.principal.username).to.eql('a@b.c'); + + expect(minimalResponse.body.principal.authentication_provider).to.eql( + defaultResponse.body.authentication_provider + ); + expect(minimalResponse.body.principal.authentication_provider).to.eql({ + type: 'saml', + name: 'saml', + }); + + // In minimal authentication mode, unlike when in default authentication mode, we don't call ES Authenticate API, + // so we don't have `authentication_realm` information available. + expect(minimalResponse.body.principal).to.not.have.property('authentication_realm'); + expect(defaultResponse.body).to.have.property('authentication_realm'); + }); }); } diff --git a/x-pack/platform/test/security_api_integration/tests/token/login.ts b/x-pack/platform/test/security_api_integration/tests/token/login.ts index a05e78c1c1309..b67f459985844 100644 --- a/x-pack/platform/test/security_api_integration/tests/token/login.ts +++ b/x-pack/platform/test/security_api_integration/tests/token/login.ts @@ -7,6 +7,8 @@ import { parse as parseCookie } from 'tough-cookie'; +import expect from '@kbn/expect'; + import type { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { @@ -132,5 +134,49 @@ export default function ({ getService }: FtrProviderContext) { throw new Error('Session cookie was set despite invalid login'); } }); + + it('should support minimal authentication', async () => { + const loginResponse = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'true') + .send({ + providerType: 'token', + providerName: 'token', + currentURL: '/', + params: { username: 'elastic', password: 'changeme' }, + }) + .expect(200); + + const sessionCookie = extractSessionCookie(loginResponse); + if (!sessionCookie) { + throw new Error('No session cookie set'); + } + + // Access the minimal and default auth endpoint with the session cookie. + const minimalResponse = await supertest + .get('/authentication/fast/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + const defaultResponse = await supertest + .get('/internal/security/me') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(minimalResponse.body.principal.username).to.eql(defaultResponse.body.username); + expect(minimalResponse.body.principal.username).to.eql('elastic'); + + expect(minimalResponse.body.principal.authentication_provider).to.eql( + defaultResponse.body.authentication_provider + ); + expect(minimalResponse.body.principal.authentication_provider).to.eql({ + type: 'token', + name: 'token', + }); + + // In minimal authentication mode, unlike when in default authentication mode, we don't call ES Authenticate API, + // so we don't have `authentication_realm` information available. + expect(minimalResponse.body.principal).to.not.have.property('authentication_realm'); + expect(defaultResponse.body).to.have.property('authentication_realm'); + }); }); } diff --git a/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts b/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts index 1d507d9b3cebc..607fef9f55024 100644 --- a/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts +++ b/x-pack/platform/test/security_functional/plugins/test_endpoints/server/init_routes.ts @@ -476,7 +476,11 @@ export function initRoutes( body: schema.object({ apiKey: schema.maybe(schema.string()) }), }, security: { - authc: { enabled: 'optional' }, + authc: { + enabled: 'optional', + reason: + 'UIAM test endpoints may use explicit API key credentials instead of session auth', + }, authz: { enabled: false, reason: 'Mock IDP plugin for testing' }, }, }, @@ -505,7 +509,11 @@ export function initRoutes( body: schema.object({ apiKey: schema.maybe(schema.string()) }), }, security: { - authc: { enabled: 'optional' }, + authc: { + enabled: 'optional', + reason: + 'UIAM test endpoints may use explicit API key credentials instead of session auth', + }, authz: { enabled: false, reason: 'Mock IDP plugin for testing' }, }, }, @@ -543,7 +551,11 @@ export function initRoutes( }), }, security: { - authc: { enabled: 'optional' }, + authc: { + enabled: 'optional', + reason: + 'UIAM test endpoints may use explicit API key credentials instead of session auth', + }, authz: { enabled: false, reason: 'Test endpoint for UIAM API key operations' }, }, }, @@ -603,7 +615,11 @@ export function initRoutes( }), }, security: { - authc: { enabled: 'optional' }, + authc: { + enabled: 'optional', + reason: + 'UIAM test endpoints may use explicit API key credentials instead of session auth', + }, authz: { enabled: false, reason: 'Test endpoint for UIAM API key operations' }, }, }, @@ -658,7 +674,11 @@ export function initRoutes( }), }, security: { - authc: { enabled: 'optional' }, + authc: { + enabled: 'optional', + reason: + 'UIAM test endpoints may use explicit API key credentials instead of session auth', + }, authz: { enabled: false, reason: 'Test endpoint for UIAM API key operations' }, }, }, From 015b5764be0d5eb695f2cac500c51ce60eafdf48 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 13 Mar 2026 17:29:18 +0100 Subject: [PATCH 4/4] fix(review): add unit tests for user proxy --- .../authentication/providers/base.test.ts | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 x-pack/platform/plugins/shared/security/server/authentication/providers/base.test.ts diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/base.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.test.ts new file mode 100644 index 0000000000000..2873532711cf1 --- /dev/null +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/base.test.ts @@ -0,0 +1,233 @@ +/* + * 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 { elasticsearchServiceMock, httpServerMock } from '@kbn/core/server/mocks'; + +import { BaseAuthenticationProvider } from './base'; +import { + mockAuthenticationProviderOptions, + type MockAuthenticationProviderOptions, +} from './base.mock'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { sessionMock } from '../../session_management/session.mock'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; + +// Concrete subclass to test protected methods on the abstract BaseAuthenticationProvider. +class TestAuthenticationProvider extends BaseAuthenticationProvider { + static readonly type = 'test'; + + async authenticate() { + return AuthenticationResult.notHandled(); + } + + async logout() { + return DeauthenticationResult.notHandled(); + } + + getHTTPAuthenticationScheme() { + return null; + } + + // Expose the protected `getUser` for testing. + public getUserPublic(...args: Parameters) { + return this.getUser(...args); + } +} + +describe('BaseAuthenticationProvider', () => { + let provider: TestAuthenticationProvider; + let mockOptions: MockAuthenticationProviderOptions; + + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions(); + provider = new TestAuthenticationProvider(mockOptions); + }); + + describe('getUser', () => { + describe('minimal authentication mode', () => { + const createMinimalAuthcRequest = () => + httpServerMock.createKibanaRequest({ + kibanaRouteOptions: { + xsrfRequired: true, + access: 'internal', + security: { + authc: { enabled: 'minimal', reason: 'some reason' }, + authz: { enabled: false, reason: 'some reason' }, + }, + }, + }); + + it('returns a user proxy without calling Elasticsearch when session has a username', async () => { + const session = sessionMock.createValue({ + username: 'testuser', + userProfileId: 'profile-uid-1', + provider: { type: 'test', name: 'test1' }, + }); + + const request = createMinimalAuthcRequest(); + const user = await provider.getUserPublic(request, undefined, session); + + // Should NOT call Elasticsearch _authenticate + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + + // Should return expected properties from session + expect(user.username).toBe('testuser'); + expect(user.profile_uid).toBe('profile-uid-1'); + expect(user.authentication_provider).toEqual({ type: 'test', name: 'test1' }); + expect(user.enabled).toBe(true); + expect(user.roles).toEqual([]); + }); + + it('throws when accessing properties not available in minimal mode', async () => { + const session = sessionMock.createValue({ username: 'testuser' }); + const request = createMinimalAuthcRequest(); + + const user = await provider.getUserPublic(request, undefined, session); + + for (const prop of [ + 'elastic_cloud_user', + 'authentication_realm', + 'lookup_realm', + 'authentication_type', + ]) { + expect(() => (user as any)[prop]).toThrow( + `Property "${prop}" is not available for minimally authenticated users.` + ); + } + }); + + it('returns a frozen user proxy', async () => { + const session = sessionMock.createValue({ username: 'testuser' }); + const request = createMinimalAuthcRequest(); + + const user = await provider.getUserPublic(request, undefined, session); + + expect(() => { + (user as any).username = 'changed'; + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'username' of object '#'"` + ); + }); + + it('falls back to Elasticsearch _authenticate when session has no username', async () => { + const session = sessionMock.createValue({ username: undefined }); + const request = createMinimalAuthcRequest(); + + const esUser = mockAuthenticatedUser(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(esUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const user = await provider.getUserPublic(request, undefined, session); + + expect(mockOptions.client.asScoped).toHaveBeenCalled(); + expect(user.username).toBe(esUser.username); + }); + + it('falls back to Elasticsearch _authenticate when session is undefined', async () => { + const request = createMinimalAuthcRequest(); + + const esUser = mockAuthenticatedUser(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(esUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const user = await provider.getUserPublic(request, undefined, undefined); + + expect(mockOptions.client.asScoped).toHaveBeenCalled(); + expect(user.username).toBe(esUser.username); + }); + }); + + describe('standard authentication mode', () => { + it('calls Elasticsearch _authenticate when authc mode is not minimal', async () => { + const session = sessionMock.createValue({ username: 'testuser' }); + const request = httpServerMock.createKibanaRequest(); + + const esUser = mockAuthenticatedUser(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(esUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const user = await provider.getUserPublic(request, undefined, session); + + expect(mockOptions.client.asScoped).toHaveBeenCalled(); + expect(user.username).toBe(esUser.username); + }); + + it('uses original request when no auth headers are provided', async () => { + const request = httpServerMock.createKibanaRequest(); + + const esUser = mockAuthenticatedUser(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(esUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await provider.getUserPublic(request); + + expect(mockOptions.client.asScoped).toHaveBeenCalledWith(request); + }); + + it('combines request and auth headers when auth headers are provided', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { 'x-custom': 'value' }, + }); + const authHeaders = { authorization: 'Bearer token123' }; + + const esUser = mockAuthenticatedUser(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(esUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await provider.getUserPublic(request, authHeaders); + + expect(mockOptions.client.asScoped).toHaveBeenCalledWith({ + headers: expect.objectContaining({ + 'x-custom': 'value', + authorization: 'Bearer token123', + }), + }); + }); + }); + }); + + describe('authenticationInfoToAuthenticatedUser', () => { + it('sets elastic_cloud_user to true for cloud SSO realm', async () => { + mockOptions.isElasticCloudDeployment.mockReturnValue(true); + provider = new TestAuthenticationProvider(mockOptions); + + const request = httpServerMock.createKibanaRequest(); + const esUser = mockAuthenticatedUser({ + authentication_realm: { name: 'cloud-saml-kibana', type: 'saml' }, + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(esUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const user = await provider.getUserPublic(request); + expect(user.elastic_cloud_user).toBe(true); + }); + + it('sets elastic_cloud_user to false when not on cloud', async () => { + mockOptions.isElasticCloudDeployment.mockReturnValue(false); + + const request = httpServerMock.createKibanaRequest(); + const esUser = mockAuthenticatedUser({ + authentication_realm: { name: 'cloud-saml-kibana', type: 'saml' }, + }); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.asCurrentUser.security.authenticate.mockResponse(esUser); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const user = await provider.getUserPublic(request); + expect(user.elastic_cloud_user).toBe(false); + }); + }); +});