diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index b3b7fd32eae19f4..8f03b1d76025829 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -191,6 +191,18 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { return await driver.get(url); } + /** + * Retrieves the cookie with the given name. Returns null if there is no such cookie. The cookie will be returned as + * a JSON object as described by the WebDriver wire protocol. + * https://www.selenium.dev/selenium/docs/api/javascript/module/selenium-webdriver/lib/webdriver_exports_Options.html + * + * @param {string} cookieName + * @return {Promise} + */ + public async getCookie(cookieName: string) { + return await driver.manage().getCookie(cookieName); + } + /** * Pauses the execution in the browser, similar to setting a breakpoint for debugging. * @return {Promise} diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index a0d63c0a9dd6f37..07e6ab6c72cb944 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -17,3 +17,5 @@ export const UNKNOWN_SPACE = '?'; export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; + +export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint'; diff --git a/x-pack/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts index d253fed97f353e1..6eb428adf2cd5f4 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.test.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.test.ts @@ -20,6 +20,19 @@ describe('#canUserChangePassword', () => { } as AuthenticatedUser) ).toEqual(true); }); + + it(`returns false for users in the ${realm} realm if used for anonymous access`, () => { + expect( + canUserChangePassword({ + username: 'foo', + authentication_provider: { type: 'anonymous', name: 'does not matter' }, + authentication_realm: { + name: 'the realm name', + type: realm, + }, + } as AuthenticatedUser) + ).toEqual(false); + }); }); it(`returns false for all other realms`, () => { diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index d5c8d4e474c601d..c22c5fc4ef0dadf 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -42,5 +42,8 @@ export interface AuthenticatedUser extends User { } export function canUserChangePassword(user: AuthenticatedUser) { - return REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type); + return ( + REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type) && + user.authentication_provider.type !== 'anonymous' + ); } diff --git a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap index 8af75633776e89f..64d456c3c6b0adf 100644 --- a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap @@ -133,45 +133,6 @@ exports[`LoginPage enabled form state renders as expected 1`] = ` /> `; -exports[`LoginPage enabled form state renders as expected when info message is set 1`] = ` - -`; - exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = ` { private readonly validator: LoginValidator; + /** + * Optional provider that was suggested by the `authProviderHint={providerName}` query string parameter. If provider + * doesn't require Kibana native login form then login process is triggered automatically, otherwise Login Selector + * just switches to the Login Form mode. + */ + private readonly suggestedProvider?: LoginSelectorProvider; + constructor(props: Props) { super(props); this.validator = new LoginValidator({ shouldValidate: false }); - const mode = this.showLoginSelector() ? PageMode.Selector : PageMode.Form; + this.suggestedProvider = this.props.authProviderHint + ? this.props.selector.providers.find(({ name }) => name === this.props.authProviderHint) + : undefined; + + // Switch to the Form mode right away if provider from the hint requires it. + const mode = + this.showLoginSelector() && !this.suggestedProvider?.usesLoginForm + ? PageMode.Selector + : PageMode.Form; this.state = { loadingState: { type: LoadingStateType.None }, @@ -94,7 +111,17 @@ export class LoginForm extends Component { }; } + async componentDidMount() { + if (this.suggestedProvider?.usesLoginForm === false) { + await this.loginWithSelector({ provider: this.suggestedProvider, autoLogin: true }); + } + } + public render() { + if (this.isLoadingState(LoadingStateType.AutoLogin)) { + return this.renderAutoLoginOverlay(); + } + return ( {this.renderLoginAssistanceMessage()} @@ -267,7 +294,7 @@ export class LoginForm extends Component { onClick={() => provider.usesLoginForm ? this.onPageModeChange(PageMode.Form) - : this.loginWithSelector(provider.type, provider.name) + : this.loginWithSelector({ provider }) } className={`secLoginCard ${ this.isLoadingState(LoadingStateType.Selector, provider.name) @@ -360,6 +387,32 @@ export class LoginForm extends Component { return null; }; + private renderAutoLoginOverlay = () => { + return ( + + + {this.props.selector.providers.map(() => ( + + ))} + + + + + + + + + + + + + + ); + }; + private setUsernameInputRef(ref: HTMLInputElement) { if (ref) { ref.focus(); @@ -438,9 +491,17 @@ export class LoginForm extends Component { } }; - private loginWithSelector = async (providerType: string, providerName: string) => { + private loginWithSelector = async ({ + provider: { type: providerType, name: providerName }, + autoLogin, + }: { + provider: LoginSelectorProvider; + autoLogin?: boolean; + }) => { this.setState({ - loadingState: { type: LoadingStateType.Selector, providerName }, + loadingState: autoLogin + ? { type: LoadingStateType.AutoLogin } + : { type: LoadingStateType.Selector, providerName }, message: { type: MessageType.None }, }); @@ -466,7 +527,9 @@ export class LoginForm extends Component { } }; - private isLoadingState(type: LoadingStateType.None | LoadingStateType.Form): boolean; + private isLoadingState( + type: LoadingStateType.None | LoadingStateType.Form | LoadingStateType.AutoLogin + ): boolean; private isLoadingState(type: LoadingStateType.Selector, providerName: string): boolean; private isLoadingState(type: LoadingStateType, providerName?: string) { const { loadingState } = this.state; diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index 467b2a7ff990623..7110c8e130ac17b 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { act } from '@testing-library/react'; import { nextTick } from '@kbn/test/jest'; +import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants'; import { LoginState } from '../../../common/login_state'; import { LoginPage } from './login_page'; import { coreMock } from '../../../../../../src/core/public/mocks'; @@ -37,14 +38,12 @@ describe('LoginPage', () => { httpMock.addLoadingCountSource.mockReset(); }; - beforeAll(() => { + beforeEach(() => { Object.defineProperty(window, 'location', { value: { href: 'http://some-host/bar', protocol: 'http' }, writable: true, }); - }); - beforeEach(() => { resetHttpMock(); }); @@ -206,10 +205,10 @@ describe('LoginPage', () => { expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); - it('renders as expected when info message is set', async () => { + it('properly passes query string parameters to the form', async () => { const coreStartMock = coreMock.createStart(); httpMock.get.mockResolvedValue(createLoginState()); - window.location.href = 'http://some-host/bar?msg=SESSION_EXPIRED'; + window.location.href = `http://some-host/bar?msg=SESSION_EXPIRED&${AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER}=basic1`; const wrapper = shallow( { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(LoginForm)).toMatchSnapshot(); + const { authProviderHint, infoMessage } = wrapper.find(LoginForm).props(); + expect(authProviderHint).toBe('basic1'); + expect(infoMessage).toBe('Your session has timed out. Please log in again.'); }); it('renders as expected when loginAssistanceMessage is set', async () => { diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index be152b21e27015a..06469626842848e 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -15,6 +15,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle } from '@elasti import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public'; +import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants'; import { LoginState } from '../../../common/login_state'; import { LoginForm, DisabledLoginForm } from './components'; @@ -212,14 +213,16 @@ export class LoginPage extends Component { ); } + const query = parse(window.location.href, true).query; return ( ); }; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 4a2b86447b7f785..27e6b99d43b077d 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -8,14 +8,16 @@ import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { shallowWithIntl, nextTick, mountWithIntl } from '@kbn/test/jest'; import { SecurityNavControl } from './nav_control_component'; -import { AuthenticatedUser } from '../../common/model'; +import type { AuthenticatedUser } from '../../common/model'; import { EuiPopover, EuiHeaderSectionItemButton } from '@elastic/eui'; import { findTestSubject } from '@kbn/test/jest'; +import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; + describe('SecurityNavControl', () => { it(`renders a loading spinner when the user promise hasn't resolved yet.`, async () => { const props = { - user: new Promise(() => {}) as Promise, + user: new Promise(() => mockAuthenticatedUser()), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([]), @@ -41,7 +43,7 @@ describe('SecurityNavControl', () => { it(`renders an avatar after the user promise resolves.`, async () => { const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise, + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([]), @@ -70,7 +72,7 @@ describe('SecurityNavControl', () => { it(`doesn't render the popover when the user hasn't been loaded yet`, async () => { const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise, + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([]), @@ -92,7 +94,7 @@ describe('SecurityNavControl', () => { it('renders a popover when the avatar is clicked.', async () => { const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise, + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([]), @@ -115,7 +117,7 @@ describe('SecurityNavControl', () => { it('renders a popover with additional user menu links registered by other plugins', async () => { const props = { - user: Promise.resolve({ full_name: 'foo' }) as Promise, + user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), editProfileUrl: '', logoutUrl: '', userMenuLinks$: new BehaviorSubject([ @@ -145,4 +147,37 @@ describe('SecurityNavControl', () => { expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(1); expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); }); + + it('properly renders a popover for anonymous user.', async () => { + const props = { + user: Promise.resolve( + mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'does no matter' }, + }) + ), + editProfileUrl: '', + logoutUrl: '', + userMenuLinks$: new BehaviorSubject([ + { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, + { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, + { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, + ]), + }; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + + expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); + + expect(findTestSubject(wrapper, 'logoutLink').text()).toBe('Log in'); + }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index c22308fa8a43e07..29145e026025580 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -131,12 +131,18 @@ export class SecurityNavControl extends Component { }; const logoutMenuItem = { - name: ( - - ), + name: + authenticatedUser?.authentication_provider.type === 'anonymous' ? ( + + ) : ( + + ), icon: , href: logoutUrl, 'data-test-subj': 'logoutLink', diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index eef45598d1761c2..718415e48572510 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -10,6 +10,7 @@ import { ILegacyClusterClient, IBasePath, } from '../../../../../src/core/server'; +import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../common/constants'; import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticatedUser } from '../../common/model'; import type { AuthenticationProvider } from '../../common/types'; @@ -20,6 +21,7 @@ import type { SecurityFeatureUsageServiceStart } from '../feature_usage'; import type { SessionValue, Session } from '../session_management'; import { + AnonymousAuthenticationProvider, AuthenticationProviderOptions, AuthenticationProviderSpecificOptions, BaseAuthenticationProvider, @@ -86,6 +88,7 @@ const providerMap = new Map< [TokenAuthenticationProvider.type, TokenAuthenticationProvider], [OIDCAuthenticationProvider.type, OIDCAuthenticationProvider], [PKIAuthenticationProvider.type, PKIAuthenticationProvider], + [AnonymousAuthenticationProvider.type, AnonymousAuthenticationProvider], ]); /** @@ -328,19 +331,26 @@ export class Authenticator { assertRequest(request); const existingSessionValue = await this.getSessionValue(request); + const suggestedProviderName = + existingSessionValue?.provider.name ?? + request.url.searchParams.get(AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER); if (this.shouldRedirectToLoginSelector(request, existingSessionValue)) { this.logger.debug('Redirecting request to Login Selector.'); return AuthenticationResult.redirectTo( `${this.options.basePath.serverBasePath}/login?next=${encodeURIComponent( `${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}` - )}` + )}${ + suggestedProviderName && !existingSessionValue + ? `&${AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER}=${encodeURIComponent( + suggestedProviderName + )}` + : '' + }` ); } - for (const [providerName, provider] of this.providerIterator( - existingSessionValue?.provider.name - )) { + for (const [providerName, provider] of this.providerIterator(suggestedProviderName)) { // Check if current session has been set by this provider. const ownsSession = existingSessionValue?.provider.name === providerName && diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts new file mode 100644 index 000000000000000..617fa9aedecc11a --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.test.ts @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { elasticsearchServiceMock, httpServerMock } from '../../../../../../src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { mockAuthenticationProviderOptions } from './base.mock'; + +import { ILegacyClusterClient, ScopeableRequest } from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { + BasicHTTPAuthorizationHeaderCredentials, + HTTPAuthorizationHeader, +} from '../http_authentication'; +import { AnonymousAuthenticationProvider } from './anonymous'; + +function expectAuthenticateCall( + mockClusterClient: jest.Mocked, + scopeableRequest: ScopeableRequest +) { + expect(mockClusterClient.asScoped).toHaveBeenCalledTimes(1); + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(scopeableRequest); + + const mockScopedClusterClient = mockClusterClient.asScoped.mock.results[0].value; + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledTimes(1); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.authenticate'); +} + +describe('AnonymousAuthenticationProvider', () => { + const user = mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'anonymous1' }, + }); + + for (const useBasicCredentials of [true, false]) { + describe(`with ${useBasicCredentials ? '`Basic`' : '`ApiKey`'} credentials`, () => { + let provider: AnonymousAuthenticationProvider; + let mockOptions: ReturnType; + let authorization: string; + beforeEach(() => { + mockOptions = mockAuthenticationProviderOptions({ name: 'anonymous1' }); + + provider = useBasicCredentials + ? new AnonymousAuthenticationProvider(mockOptions, { + credentials: { username: 'user', password: 'pass' }, + }) + : new AnonymousAuthenticationProvider(mockOptions, { + credentials: { username: 'user', apiKey: 'some-apiKey' }, + }); + authorization = useBasicCredentials + ? new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials('user', 'pass').toString() + ).toString() + : new HTTPAuthorizationHeader('ApiKey', 'some-apiKey').toString(); + }); + + describe('`login` method', () => { + it('succeeds if credentials are valid, and creates session and authHeaders', async () => { + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect( + provider.login(httpServerMock.createKibanaRequest({ headers: {} })) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { + authHeaders: { authorization }, + state: {}, + }) + ); + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + }); + + it('fails if user cannot be retrieved during login attempt', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const authenticationError = new Error('Some error'); + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.login(request)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + }); + + describe('`authenticate` method', () => { + it('does not create session for AJAX requests.', async () => { + // Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and + // avoid triggering of redirect logic. + await expect( + provider.authenticate( + httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }), + null + ) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + it('does not create session for request that do not require authentication.', async () => { + await expect( + provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false })) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + it('does not handle authentication via `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe(authorization); + }); + + it('does not handle authentication via `authorization` header even if state exists.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { authorization } }); + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe(authorization); + }); + + it('succeeds for non-AJAX requests if state is available.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + }); + + it('succeeds for AJAX requests if state is available.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.succeeded(user, { authHeaders: { authorization } }) + ); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization, 'kbn-xsrf': 'xsrf' }, + }); + }); + + it('non-AJAX requests can start a new session.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: {}, authHeaders: { authorization } }) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + }); + + it('fails if credentials are not valid.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const authenticationError = new Error('Forbidden'); + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(authenticationError); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.failed(authenticationError) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + + if (!useBasicCredentials) { + it('rewrites `username` it specified in ApiKey credentials', async () => { + const userReturnedFromES = mockAuthenticatedUser({ + username: 'user-created-apikey', + authentication_provider: { type: 'anonymous', name: 'anonymous1' }, + }); + const request = httpServerMock.createKibanaRequest({ headers: {} }); + + const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(userReturnedFromES); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + await expect(provider.authenticate(request, {})).resolves.toEqual( + AuthenticationResult.succeeded( + { ...userReturnedFromES, username: 'user' }, + { authHeaders: { authorization } } + ) + ); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + }); + } + }); + + describe('`logout` method', () => { + it('does not handle logout if state 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( + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + ); + + await expect( + provider.logout(httpServerMock.createKibanaRequest(), null) + ).resolves.toEqual( + DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') + ); + }); + }); + + it('`getHTTPAuthenticationScheme` method', () => { + expect(provider.getHTTPAuthenticationScheme()).toBe( + useBasicCredentials ? 'basic' : 'apikey' + ); + }); + }); + } +}); diff --git a/x-pack/plugins/security/server/authentication/providers/anonymous.ts b/x-pack/plugins/security/server/authentication/providers/anonymous.ts new file mode 100644 index 000000000000000..930f395aaba352a --- /dev/null +++ b/x-pack/plugins/security/server/authentication/providers/anonymous.ts @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest } from '../../../../../../src/core/server'; +import { AuthenticationResult } from '../authentication_result'; +import { canRedirectRequest } from '../can_redirect_request'; +import { DeauthenticationResult } from '../deauthentication_result'; +import { + BasicHTTPAuthorizationHeaderCredentials, + HTTPAuthorizationHeader, +} from '../http_authentication'; +import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base'; + +/** + * Credentials that are based on the username and password. + */ +interface UsernameAndPasswordCredentials { + username: string; + password: string; +} + +/** + * Credentials that are based on the Elasticsearch API key with the optional username override. + */ +interface APIKeyCredentials { + username?: string; + apiKey: string; +} + +/** + * Checks whether current request can initiate a new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and it's not XHR request. + // Technically we can authenticate XHR requests too, but we don't want these to create a new session unintentionally. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + +/** + * Checks whether specified `credentials` define an API key. + * @param credentials + */ +function isAPIKeyCredentials( + credentials: UsernameAndPasswordCredentials | APIKeyCredentials +): credentials is APIKeyCredentials { + const apiKey = ((credentials as unknown) as Record).apiKey; + return typeof apiKey === 'string' && !!apiKey; +} + +/** + * Provider that supports anonymous request authentication. + */ +export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider { + /** + * Type of the provider. + */ + static readonly type = 'anonymous'; + + /** + * Specifies credentials that should be used to authenticate anonymous user. + */ + private readonly credentials: Readonly; + + constructor( + protected readonly options: Readonly, + anonymousOptions?: Readonly<{ + credentials?: Readonly; + }> + ) { + super(options); + + if (!anonymousOptions || !anonymousOptions.credentials) { + throw new Error('Credentials must be specified'); + } + this.credentials = anonymousOptions.credentials; + } + + /** + * Performs initial login request. + * @param request Request instance. + * @param state Optional state value previously stored by the provider. + */ + public async login(request: KibanaRequest, state?: unknown) { + this.logger.debug('Trying to perform a login.'); + + // If authentication succeeded we should return some state to create a session, that will reuse for all subsequent + // requests and anonymous user interactions with the Kibana. + return isAPIKeyCredentials(this.credentials) + ? await this.authenticateViaAPIKey(request, this.credentials, state) + : await this.authenticateViaUsernameAndPassword(request, this.credentials, state); + } + + /** + * Performs request authentication. + * @param request Request instance. + * @param state Optional state value previously stored by the provider. + */ + public async authenticate(request: KibanaRequest, state?: unknown) { + this.logger.debug( + `Trying to authenticate user request to ${request.url.pathname}${request.url.search}.` + ); + + if (HTTPAuthorizationHeader.parseFromRequest(request) != null) { + this.logger.debug('Cannot authenticate requests with `Authorization` header.'); + return AuthenticationResult.notHandled(); + } + + if (state || canStartNewSession(request)) { + return isAPIKeyCredentials(this.credentials) + ? await this.authenticateViaAPIKey(request, this.credentials, state) + : await this.authenticateViaUsernameAndPassword(request, this.credentials, state); + } + + return AuthenticationResult.notHandled(); + } + + /** + * Redirects user to the logged out page. + * @param request Request instance. + * @param state Optional state value previously stored by the provider. + */ + public async logout(request: KibanaRequest, state?: unknown) { + 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 + // session isn't defined then provider is just being probed whether or not it can perform logout. + if (state === undefined) { + return DeauthenticationResult.notHandled(); + } + + return DeauthenticationResult.redirectTo(this.options.urls.loggedOut); + } + + /** + * Returns HTTP authentication scheme (`Basic` or `ApiKey`) that's used within `Authorization` + * HTTP header that provider attaches to all successfully authenticated requests to Elasticsearch. + */ + public getHTTPAuthenticationScheme() { + return isAPIKeyCredentials(this.credentials) ? 'apikey' : 'basic'; + } + + /** + * Tries to authenticate user request via configured username and password. + * @param request Request instance. + * @param credentials Credentials based on the username and password. + * @param state State value previously stored by the provider. + */ + private async authenticateViaUsernameAndPassword( + request: KibanaRequest, + { username, password }: UsernameAndPasswordCredentials, + state?: unknown + ) { + this.logger.debug('Trying to authenticate request via anonymous username and password.'); + + try { + const authHeaders = { + authorization: new HTTPAuthorizationHeader( + 'Basic', + new BasicHTTPAuthorizationHeaderCredentials(username, password).toString() + ).toString(), + }; + + const user = await this.getUser(request, authHeaders); + this.logger.debug( + `Request to ${request.url.pathname}${request.url.search} has been authenticated.` + ); + return AuthenticationResult.succeeded(user, { + authHeaders, + // Create session only if it doesn't exist yet, otherwise keep it unchanged. + state: state ? undefined : {}, + }); + } catch (err) { + this.logger.debug( + `Failed to authenticate request via anonymous username and password: ${err.message}` + ); + return AuthenticationResult.failed(err); + } + } + + /** + * Tries to authenticate user in via configured api key. + * @param request Request instance. + * @param credentials Credentials based on the API key. + * @param state State value previously stored by the provider. + */ + private async authenticateViaAPIKey( + request: KibanaRequest, + { apiKey, username }: APIKeyCredentials, + state?: unknown + ) { + this.logger.debug('Trying to authenticate request via API key.'); + + const authHeaders = { + authorization: new HTTPAuthorizationHeader('ApiKey', apiKey).toString(), + }; + try { + const user = await this.getUser(request, authHeaders); + this.logger.debug( + `Request to ${request.url.pathname}${request.url.search} has been authenticated.` + ); + return AuthenticationResult.succeeded(username ? { ...user, username } : user, { + authHeaders, + // Create session only if it doesn't exist yet, otherwise keep it unchanged. + state: state ? undefined : {}, + }); + } catch (err) { + this.logger.debug(`Failed to authenticate request via API key: ${err.message}`); + return AuthenticationResult.failed(err); + } + } +} diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index 048afb6190d18c8..cfa9e715050669b 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -9,6 +9,7 @@ export { AuthenticationProviderOptions, AuthenticationProviderSpecificOptions, } from './base'; +export { AnonymousAuthenticationProvider } from './anonymous'; export { BasicAuthenticationProvider } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; export { SAMLAuthenticationProvider, SAMLLogin } from './saml'; diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index e75c0d1c4085fe0..6781a203e0647bc 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -28,6 +28,7 @@ describe('config schema', () => { ], }, "providers": Object { + "anonymous": undefined, "basic": Object { "basic": Object { "accessAgreement": undefined, @@ -76,6 +77,7 @@ describe('config schema', () => { ], }, "providers": Object { + "anonymous": undefined, "basic": Object { "basic": Object { "accessAgreement": undefined, @@ -124,6 +126,7 @@ describe('config schema', () => { ], }, "providers": Object { + "anonymous": undefined, "basic": Object { "basic": Object { "accessAgreement": undefined, @@ -863,6 +866,211 @@ describe('config schema', () => { }); }); + describe('`anonymous` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { anonymous: { anonymous1: { enabled: true } } } }, + }) + ).toThrow( + '[authc.providers.1.anonymous.anonymous1.order]: expected value of type [number] but got [undefined]' + ); + }); + + it('requires `credentials`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { anonymous: { anonymous1: { order: 0 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.anonymous.anonymous1.credentials]: expected at least one defined value but got [undefined]" + `); + }); + + it('requires both `username` and `password` in username/password `credentials`', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { anonymous1: { order: 0, credentials: { username: 'some-user' } } }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation: + - [credentials.0.password]: expected value of type [string] but got [undefined] + - [credentials.1.apiKey]: expected value of type [string] but got [undefined]" + `); + + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { anonymous1: { order: 0, credentials: { password: 'some-pass' } } }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "[authc.providers]: types that failed validation: + - [authc.providers.0]: expected value of type [array] but got [Object] + - [authc.providers.1.anonymous.anonymous1.credentials]: types that failed validation: + - [credentials.0.username]: expected value of type [string] but got [undefined] + - [credentials.1.apiKey]: expected value of type [string] but got [undefined]" + `); + }); + + it('can be successfully validated with username/password credentials', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "password": "some-pass", + "username": "some-user", + }, + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 0, + "session": Object { + "idleTimeout": null, + }, + "showInSelector": true, + }, + }, + } + `); + }); + + it('can be successfully validated with API keys credentials', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { apiKey: 'some-API-key' }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "apiKey": "some-API-key", + }, + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 0, + "session": Object { + "idleTimeout": null, + }, + "showInSelector": true, + }, + }, + } + `); + + expect( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { apiKey: 'some-API-key', username: 'some-user' }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "apiKey": "some-API-key", + "username": "some-user", + }, + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 0, + "session": Object { + "idleTimeout": null, + }, + "showInSelector": true, + }, + }, + } + `); + }); + + it('can be successfully validated with session config overrides', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 1, + credentials: { username: 'some-user', password: 'some-pass' }, + session: { idleTimeout: 321, lifespan: 546 }, + }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "anonymous": Object { + "anonymous1": Object { + "credentials": Object { + "password": "some-pass", + "username": "some-user", + }, + "description": "Continue as Guest", + "enabled": true, + "hint": "For anonymous users", + "icon": "globe", + "order": 1, + "session": Object { + "idleTimeout": "PT0.321S", + "lifespan": "PT0.546S", + }, + "showInSelector": true, + }, + }, + } + `); + }); + }); + it('`name` should be unique across all provider types', () => { expect(() => ConfigSchema.validate({ @@ -1623,5 +1831,113 @@ describe('createConfig()', () => { } `); }); + + it('properly handles config for the anonymous provider', async () => { + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + }, + }, + }, + }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": null, + "lifespan": "P30D", + } + `); + + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + }, + }, + }, + }, + session: { idleTimeout: 0, lifespan: null }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": null, + "lifespan": null, + } + `); + + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + session: { idleTimeout: 0, lifespan: null }, + }, + }, + }, + }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": null, + "lifespan": null, + } + `); + + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + session: { idleTimeout: 321, lifespan: 546 }, + }, + }, + }, + }, + session: { idleTimeout: null, lifespan: 0 }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": "PT0.321S", + "lifespan": "PT0.546S", + } + `); + + expect( + createMockConfig({ + authc: { + providers: { + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'some-user', password: 'some-pass' }, + session: { idleTimeout: 321, lifespan: 546 }, + }, + }, + }, + }, + session: { idleTimeout: 123, lifespan: 456 }, + }).session.getExpirationTimeouts({ type: 'anonymous', name: 'anonymous1' }) + ).toMatchInlineSnapshot(` + Object { + "idleTimeout": "PT0.321S", + "lifespan": "PT0.546S", + } + `); + }); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 4da0a8598309a32..b7691431e9a070e 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -51,18 +51,27 @@ function getCommonProviderSchemaProperties(overrides: Partial>>( providerType: string, - overrides?: Partial + overrides?: Partial, + properties?: TProperties ) { return schema.maybe( - schema.recordOf(schema.string(), schema.object(getCommonProviderSchemaProperties(overrides)), { - validate(config) { - if (Object.values(config).filter((provider) => provider.enabled).length > 1) { - return `Only one "${providerType}" provider can be configured.`; - } - }, - }) + schema.recordOf( + schema.string(), + schema.object( + properties + ? { ...getCommonProviderSchemaProperties(overrides), ...properties } + : getCommonProviderSchemaProperties(overrides) + ), + { + validate(config) { + if (Object.values(config).filter((provider) => provider.enabled).length > 1) { + return `Only one "${providerType}" provider can be configured.`; + } + }, + } + ) ); } @@ -120,6 +129,38 @@ const providersConfigSchema = schema.object( schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string() }) ) ), + anonymous: getUniqueProviderSchema( + 'anonymous', + { + description: schema.string({ + defaultValue: i18n.translate('xpack.security.loginAsGuestLabel', { + defaultMessage: 'Continue as Guest', + }), + }), + hint: schema.string({ + defaultValue: i18n.translate('xpack.security.loginAsGuestHintLabel', { + defaultMessage: 'For anonymous users', + }), + }), + icon: schema.string({ defaultValue: 'globe' }), + session: schema.object({ + idleTimeout: schema.nullable(schema.duration()), + lifespan: schema.maybe(schema.oneOf([schema.duration(), schema.literal(null)])), + }), + }, + { + credentials: schema.oneOf([ + schema.object({ + username: schema.string(), + password: schema.string(), + }), + schema.object({ + username: schema.maybe(schema.string()), + apiKey: schema.string(), + }), + ]), + } + ), }, { validate(config) { @@ -196,6 +237,7 @@ export const ConfigSchema = schema.object({ oidc: undefined, pki: undefined, kerberos: undefined, + anonymous: undefined, }, }), oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), @@ -335,6 +377,7 @@ export function createConfig( } function getSessionConfig(session: RawConfigType['session'], providers: ProvidersConfigType) { + const defaultAnonymousSessionLifespan = schema.duration().validate('30d'); return { cleanupInterval: session.cleanupInterval, getExpirationTimeouts({ type, name }: AuthenticationProvider) { @@ -343,9 +386,20 @@ function getSessionConfig(session: RawConfigType['session'], providers: Provider // provider doesn't override session config and we should fall back to the global one instead. const providerSessionConfig = providers[type as keyof ProvidersConfigType]?.[name]?.session; + // We treat anonymous sessions differently since users can create them without realizing it. This may lead to a + // non controllable amount of sessions stored in the session index. To reduce the impact we set a 30 days lifespan + // for the anonymous sessions in case neither global nor provider specific lifespan is configured explicitly. + // We can remove this code once https://github.com/elastic/kibana/issues/68885 is resolved. + const providerLifespan = + type === 'anonymous' && + providerSessionConfig?.lifespan === undefined && + session.lifespan === undefined + ? defaultAnonymousSessionLifespan + : providerSessionConfig?.lifespan; + const [idleTimeout, lifespan] = [ [session.idleTimeout, providerSessionConfig?.idleTimeout], - [session.lifespan, providerSessionConfig?.lifespan], + [session.lifespan, providerLifespan], ].map(([globalTimeout, providerTimeout]) => { const timeout = providerTimeout === undefined ? globalTimeout ?? null : providerTimeout; return timeout && timeout.asMilliseconds() > 0 ? timeout : null; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 7d4cc41cfbe5a3d..505ad3c7d866b36 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -43,6 +43,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/security_api_integration/oidc.config.ts'), require.resolve('../test/security_api_integration/oidc_implicit_flow.config.ts'), require.resolve('../test/security_api_integration/token.config.ts'), + require.resolve('../test/security_api_integration/anonymous.config.ts'), require.resolve('../test/observability_api_integration/basic/config.ts'), require.resolve('../test/observability_api_integration/trial/config.ts'), require.resolve('../test/encrypted_saved_objects_api_integration/config.ts'), diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index 2d9ee00234bb676..ef80ab475cbd676 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -5,7 +5,7 @@ */ import { FtrProviderContext } from '../ftr_provider_context'; -import { Role } from '../../../plugins/security/common/model'; +import { AuthenticatedUser, Role } from '../../../plugins/security/common/model'; export function SecurityPageProvider({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); @@ -17,6 +17,7 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider const esArchiver = getService('esArchiver'); const userMenu = getService('userMenu'); const comboBox = getService('comboBox'); + const supertest = getService('supertestWithoutAuth'); const PageObjects = getPageObjects(['common', 'header', 'error']); interface LoginOptions { @@ -41,10 +42,14 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider }); } + async function isLoginFormVisible() { + return await testSubjects.exists('loginForm'); + } + async function waitForLoginForm() { log.debug('Waiting for Login Form to appear.'); await retry.waitForWithTimeout('login form', config.get('timeouts.waitFor') * 5, async () => { - return await testSubjects.exists('loginForm'); + return await isLoginFormVisible(); }); } @@ -107,7 +112,9 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider const loginPage = Object.freeze({ async login(username?: string, password?: string, options: LoginOptions = {}) { - await PageObjects.common.navigateToApp('login'); + if (!(await isLoginFormVisible())) { + await PageObjects.common.navigateToApp('login'); + } // ensure welcome screen won't be shown. This is relevant for environments which don't allow // to use the yml setting, e.g. cloud @@ -218,6 +225,21 @@ export function SecurityPageProvider({ getService, getPageObjects }: FtrProvider await waitForLoginPage(); } + async getCurrentUser() { + const sidCookie = await browser.getCookie('sid'); + if (!sidCookie?.value) { + log.debug('User is not authenticated yet.'); + return null; + } + + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', `sid=${sidCookie.value}`) + .expect(200); + return user as AuthenticatedUser; + } + async forceLogout() { log.debug('SecurityPage.forceLogout'); if (await find.existsByDisplayedByCssSelector('.login-form', 100)) { diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 6d8eade25d7e6d1..1aa6216236827d0 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -6,6 +6,7 @@ import { services as kibanaFunctionalServices } from '../../../../test/functional/services'; import { services as kibanaApiIntegrationServices } from '../../../../test/api_integration/services'; +import { services as kibanaXPackApiIntegrationServices } from '../../api_integration/services'; import { services as commonServices } from '../../common/services'; import { @@ -64,6 +65,7 @@ export const services = { ...commonServices, supertest: kibanaApiIntegrationServices.supertest, + supertestWithoutAuth: kibanaXPackApiIntegrationServices.supertestWithoutAuth, esSupertest: kibanaApiIntegrationServices.esSupertest, monitoringNoData: MonitoringNoDataProvider, monitoringClusterList: MonitoringClusterListProvider, diff --git a/x-pack/test/security_api_integration/anonymous.config.ts b/x-pack/test/security_api_integration/anonymous.config.ts new file mode 100644 index 000000000000000..1742bd09c92f5ab --- /dev/null +++ b/x-pack/test/security_api_integration/anonymous.config.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaAPITestsConfig = await readConfigFile( + require.resolve('../../../test/api_integration/config.js') + ); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + return { + testFiles: [require.resolve('./tests/anonymous')], + servers: xPackAPITestsConfig.get('servers'), + security: { disableTestUser: true }, + services: { + ...kibanaAPITestsConfig.get('services'), + ...xPackAPITestsConfig.get('services'), + }, + junit: { + reportName: 'X-Pack Security API Integration Tests (Anonymous with Username and Password)', + }, + + esTestCluster: { ...xPackAPITestsConfig.get('esTestCluster') }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--xpack.security.authc.selector.enabled=false`, + `--xpack.security.authc.providers=${JSON.stringify({ + anonymous: { + anonymous1: { + order: 0, + credentials: { username: 'anonymous_user', password: 'changeme' }, + }, + }, + basic: { basic1: { order: 1 } }, + })}`, + ], + }, + }; +} diff --git a/x-pack/test/security_api_integration/login_selector.config.ts b/x-pack/test/security_api_integration/login_selector.config.ts index 9688d42cb436172..97c7b4334c3b7b4 100644 --- a/x-pack/test/security_api_integration/login_selector.config.ts +++ b/x-pack/test/security_api_integration/login_selector.config.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { readFileSync } from 'fs'; import { resolve } from 'path'; import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; @@ -35,6 +36,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { kibana: { ...xPackAPITestsConfig.get('servers.kibana'), protocol: 'https', + certificateAuthorities: [readFileSync(CA_CERT_PATH)], }, }; @@ -43,9 +45,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { servers, security: { disableTestUser: true }, services: { - randomness: kibanaAPITestsConfig.get('services.randomness'), - legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), - supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + ...kibanaAPITestsConfig.get('services'), + ...xPackAPITestsConfig.get('services'), }, junit: { reportName: 'X-Pack Security API Integration Tests (Login Selector)', @@ -127,6 +128,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { useRelayStateDeepLink: true, }, }, + anonymous: { + anonymous1: { + order: 6, + credentials: { username: 'anonymous_user', password: 'changeme' }, + }, + }, })}`, ], }, diff --git a/x-pack/test/security_api_integration/tests/anonymous/index.ts b/x-pack/test/security_api_integration/tests/anonymous/index.ts new file mode 100644 index 000000000000000..3819d26ae5efa58 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/anonymous/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security APIs - Anonymous access', function () { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./login')); + }); +} diff --git a/x-pack/test/security_api_integration/tests/anonymous/login.ts b/x-pack/test/security_api_integration/tests/anonymous/login.ts new file mode 100644 index 000000000000000..9d4d72e9d7e2a88 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/anonymous/login.ts @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import request, { Cookie } from 'request'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); + const security = getService('security'); + + function checkCookieIsSet(cookie: Cookie) { + expect(cookie.value).to.not.be.empty(); + + expect(cookie.key).to.be('sid'); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.maxAge).to.be(null); + } + + function checkCookieIsCleared(cookie: Cookie) { + expect(cookie.value).to.be.empty(); + + expect(cookie.key).to.be('sid'); + expect(cookie.path).to.be('/'); + expect(cookie.httpOnly).to.be(true); + expect(cookie.maxAge).to.be(0); + } + + describe('Anonymous authentication', () => { + before(async () => { + await security.user.create('anonymous_user', { + password: 'changeme', + roles: [], + full_name: 'Guest', + }); + }); + + after(async () => { + await security.user.delete('anonymous_user'); + }); + + it('should reject API requests if client is not authenticated', async () => { + await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401); + }); + + it('does not prevent basic login', async () => { + const [username, password] = config.get('servers.elasticsearch.auth').split(':'); + const response = await supertest + .post('/internal/security/login') + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'basic', + providerName: 'basic1', + currentURL: '/', + params: { username, password }, + }) + .expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const cookie = request.cookie(cookies[0])!; + checkCookieIsSet(cookie); + + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', cookie.cookieString()) + .expect(200); + + expect(user.username).to.eql(username); + expect(user.authentication_provider).to.eql({ type: 'basic', name: 'basic1' }); + expect(user.authentication_type).to.eql('realm'); + // Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud + }); + + describe('login', () => { + it('should properly set cookie and authenticate user', async () => { + const response = await supertest.get('/security/account').expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + const { body: user } = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(user.username).to.eql('anonymous_user'); + expect(user.authentication_provider).to.eql({ type: 'anonymous', name: 'anonymous1' }); + expect(user.authentication_type).to.eql('realm'); + // Do not assert on the `authentication_realm`, as the value differs for on-prem vs cloud + }); + + it('should fail if `Authorization` header is present, but not valid', async () => { + const spnegoResponse = await supertest + .get('/security/account') + .set('Authorization', 'Basic wow') + .expect(401); + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('API access with active session', () => { + let sessionCookie: Cookie; + + beforeEach(async () => { + const response = await supertest.get('/security/account').expect(200); + + const cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + }); + + it('should not extend cookie for system AND non-system API calls', async () => { + const apiResponseOne = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponseOne.headers['set-cookie']).to.be(undefined); + + const systemAPIResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('kbn-system-request', 'true') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(systemAPIResponse.headers['set-cookie']).to.be(undefined); + }); + + it('should fail and preserve session cookie if unsupported authentication schema is used', async () => { + const apiResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Authorization', 'Basic a3JiNTprcmI1') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + expect(apiResponse.headers['set-cookie']).to.be(undefined); + }); + }); + + describe('logging out', () => { + it('should redirect to `logged_out` page after successful logout', async () => { + // First authenticate user to retrieve session cookie. + const response = await supertest.get('/security/account').expect(200); + let cookies = response.headers['set-cookie']; + expect(cookies).to.have.length(1); + + const sessionCookie = request.cookie(cookies[0])!; + checkCookieIsSet(sessionCookie); + + // And then log user out. + const logoutResponse = await supertest + .get('/api/security/logout') + .set('Cookie', sessionCookie.cookieString()) + .expect(302); + + cookies = logoutResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + + expect(logoutResponse.headers.location).to.be('/security/logged_out'); + + // Old cookie should be invalidated and not allow API access. + const apiResponse = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(401); + + // If Kibana detects cookie with invalid token it tries to clear it. + cookies = apiResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + checkCookieIsCleared(request.cookie(cookies[0])!); + }); + + it('should redirect to home page if session cookie is not provided', async () => { + const logoutResponse = await supertest.get('/api/security/logout').expect(302); + + expect(logoutResponse.headers['set-cookie']).to.be(undefined); + expect(logoutResponse.headers.location).to.be('/'); + }); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts index cf141972b044a14..edcc1b5744fe37b 100644 --- a/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_api_integration/tests/login_selector/basic_functionality.ts @@ -23,6 +23,7 @@ export default function ({ getService }: FtrProviderContext) { const randomness = getService('randomness'); const supertest = getService('supertestWithoutAuth'); const config = getService('config'); + const security = getService('security'); const kibanaServerConfig = config.get('servers.kibana'); const validUsername = kibanaServerConfig.username; @@ -748,5 +749,68 @@ export default function ({ getService }: FtrProviderContext) { ); }); }); + + describe('Anonymous', () => { + before(async () => { + await security.user.create('anonymous_user', { + password: 'changeme', + roles: [], + full_name: 'Guest', + }); + }); + + after(async () => { + await security.user.delete('anonymous_user'); + }); + + it('should be able to log in from Login Selector', async () => { + const authenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'anonymous', + providerName: 'anonymous1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'anonymous_user', + { type: 'anonymous', name: 'anonymous1' }, + { name: 'native1', type: 'native' }, + 'realm' + ); + }); + + it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => { + const authenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'anonymous', + providerName: 'anonymous1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'anonymous_user', + { type: 'anonymous', name: 'anonymous1' }, + { name: 'native1', type: 'native' }, + 'realm' + ); + }); + }); }); } diff --git a/x-pack/test/security_functional/login_selector.config.ts b/x-pack/test/security_functional/login_selector.config.ts index 9fc4c54ba13444d..2ee47491c5ff38f 100644 --- a/x-pack/test/security_functional/login_selector.config.ts +++ b/x-pack/test/security_functional/login_selector.config.ts @@ -42,7 +42,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { from: 'snapshot', serverArgs: [ 'xpack.security.authc.token.enabled=true', - 'xpack.security.authc.realms.saml.saml1.order=0', + 'xpack.security.authc.realms.native.native1.order=0', + 'xpack.security.authc.realms.saml.saml1.order=1', `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`, 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1', `xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`, @@ -60,15 +61,29 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d', '--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', `--xpack.security.loginHelp="Some-login-help."`, - '--xpack.security.authc.providers.basic.basic1.order=0', - '--xpack.security.authc.providers.saml.saml1.order=1', - '--xpack.security.authc.providers.saml.saml1.realm=saml1', - '--xpack.security.authc.providers.saml.saml1.description="Log-in-with-SAML"', - '--xpack.security.authc.providers.saml.saml1.icon=logoKibana', - '--xpack.security.authc.providers.saml.unknown_saml.order=2', - '--xpack.security.authc.providers.saml.unknown_saml.realm=unknown_realm', - '--xpack.security.authc.providers.saml.unknown_saml.description="Do-not-log-in-with-THIS-SAML"', - '--xpack.security.authc.providers.saml.unknown_saml.icon=logoAWS', + `--xpack.security.authc.providers=${JSON.stringify({ + basic: { basic1: { order: 0 } }, + saml: { + saml1: { + order: 1, + realm: 'saml1', + description: 'Log-in-with-SAML', + icon: 'logoKibana', + }, + unknown_saml: { + order: 2, + realm: 'unknown_realm', + description: 'Do-not-log-in-with-THIS-SAML', + icon: 'logoAWS', + }, + }, + anonymous: { + anonymous1: { + order: 3, + credentials: { username: 'anonymous_user', password: 'changeme' }, + }, + }, + })}`, ], }, uiSettings: { diff --git a/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts new file mode 100644 index 000000000000000..8c2086255909276 --- /dev/null +++ b/x-pack/test/security_functional/tests/login_selector/auth_provider_hint.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { parse } from 'url'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const security = getService('security'); + const PageObjects = getPageObjects(['security', 'common']); + + describe('Authentication provider hint', function () { + this.tags('includeFirefox'); + + before(async () => { + await getService('esSupertest') + .post('/_security/role_mapping/saml1') + .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } }) + .expect(200); + + await security.user.create('anonymous_user', { + password: 'changeme', + roles: ['superuser'], + full_name: 'Guest', + }); + + await esArchiver.load('../../functional/es_archives/empty_kibana'); + await PageObjects.security.forceLogout(); + }); + + after(async () => { + await security.user.delete('anonymous_user'); + await esArchiver.unload('../../functional/es_archives/empty_kibana'); + }); + + beforeEach(async () => { + await browser.get(`${PageObjects.common.getHostPort()}/login`); + await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); + }); + + afterEach(async () => { + await PageObjects.security.forceLogout(); + }); + + it('automatically activates Login Form preserving original URL', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'management', + '/security/users', + '?auth_provider_hint=basic1', + { ensureCurrentUrl: false, shouldLoginIfPrompted: false } + ); + await PageObjects.common.waitUntilUrlIncludes('next='); + + // Login form should be automatically activated by the auth provider hint. + await PageObjects.security.loginSelector.verifyLoginFormIsVisible(); + await PageObjects.security.loginPage.login(undefined, undefined, { expectSuccess: true }); + + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({ + type: 'basic', + name: 'basic1', + }); + }); + + it('automatically login with SSO preserving original URL', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'management', + '/security/users', + '?auth_provider_hint=saml1', + { ensureCurrentUrl: false, shouldLoginIfPrompted: false } + ); + + await PageObjects.common.waitUntilUrlIncludes('/app/management/security/users'); + + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({ + type: 'saml', + name: 'saml1', + }); + }); + + it('can login anonymously preserving original URL', async () => { + await PageObjects.common.navigateToUrlWithBrowserHistory( + 'management', + '/security/users', + '?auth_provider_hint=anonymous1', + { ensureCurrentUrl: false, shouldLoginIfPrompted: false } + ); + + await PageObjects.common.waitUntilUrlIncludes('/app/management/security/users'); + + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + expect((await PageObjects.security.getCurrentUser())?.authentication_provider).to.eql({ + type: 'anonymous', + name: 'anonymous1', + }); + }); + }); +} diff --git a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts index 153387c52e5c3c5..a08fae4cdb0a13a 100644 --- a/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts +++ b/x-pack/test/security_functional/tests/login_selector/basic_functionality.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const browser = getService('browser'); + const security = getService('security'); const PageObjects = getPageObjects(['security', 'common']); describe('Basic functionality', function () { @@ -71,6 +72,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(currentURL.pathname).to.eql('/app/management/security/users'); }); + it('can login anonymously preserving original URL', async () => { + await PageObjects.common.navigateToUrl('management', 'security/users', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + shouldUseHashForSubUrl: false, + }); + await PageObjects.common.waitUntilUrlIncludes('next='); + + await security.user.create('anonymous_user', { + password: 'changeme', + roles: ['superuser'], + full_name: 'Guest', + }); + await PageObjects.security.loginSelector.login('anonymous', 'anonymous1'); + await security.user.delete('anonymous_user'); + + // We need to make sure that both path and hash are respected. + const currentURL = parse(await browser.getCurrentUrl()); + expect(currentURL.pathname).to.eql('/app/management/security/users'); + }); + it('should show toast with error if SSO fails', async () => { await PageObjects.security.loginSelector.selectLoginMethod('saml', 'unknown_saml'); @@ -80,6 +102,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); }); + it('should show toast with error if anonymous login fails', async () => { + await PageObjects.security.loginSelector.selectLoginMethod('anonymous', 'anonymous1'); + + const toastTitle = await PageObjects.common.closeToast(); + expect(toastTitle).to.eql('Could not perform login.'); + + await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible(); + }); + it('can go to Login Form and return back to Selector', async () => { await PageObjects.security.loginSelector.selectLoginMethod('basic', 'basic1'); await PageObjects.security.loginSelector.verifyLoginFormIsVisible(); diff --git a/x-pack/test/security_functional/tests/login_selector/index.ts b/x-pack/test/security_functional/tests/login_selector/index.ts index 0d1060fbf1f513e..ee25e365d495d3e 100644 --- a/x-pack/test/security_functional/tests/login_selector/index.ts +++ b/x-pack/test/security_functional/tests/login_selector/index.ts @@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { this.tags('ciGroup4'); loadTestFile(require.resolve('./basic_functionality')); + loadTestFile(require.resolve('./auth_provider_hint')); }); }