diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts index deebbccf5aa49a..5b2218af1fd521 100644 --- a/x-pack/legacy/plugins/security/index.ts +++ b/x-pack/legacy/plugins/security/index.ts @@ -51,10 +51,7 @@ export const security = (kibana: Record) => uiExports: { hacks: ['plugins/security/hacks/legacy'], injectDefaultVars: (server: Server) => { - return { - secureCookies: getSecurityPluginSetup(server).__legacyCompat.config.secureCookies, - enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), - }; + return { enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled') }; }, }, diff --git a/x-pack/plugins/security/common/login_state.ts b/x-pack/plugins/security/common/login_state.ts new file mode 100644 index 00000000000000..4342e82d2f90b1 --- /dev/null +++ b/x-pack/plugins/security/common/login_state.ts @@ -0,0 +1,20 @@ +/* + * 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 { LoginLayout } from './licensing'; + +export interface LoginSelector { + enabled: boolean; + providers: Array<{ type: string; name: string; description?: string }>; +} + +export interface LoginState { + layout: LoginLayout; + allowLogin: boolean; + showLoginForm: boolean; + requiresSecureConnection: boolean; + selector: LoginSelector; +} diff --git a/x-pack/plugins/security/common/model/authenticated_user.mock.ts b/x-pack/plugins/security/common/model/authenticated_user.mock.ts index 220b284e76591f..f8b0d27efcbf4b 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.mock.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -15,7 +15,7 @@ export function mockAuthenticatedUser(user: Partial = {}) { enabled: true, authentication_realm: { name: 'native1', type: 'native' }, lookup_realm: { name: 'native1', type: 'native' }, - authentication_provider: 'basic', + authentication_provider: 'basic1', ...user, }; } diff --git a/x-pack/plugins/security/common/parse_next.test.ts b/x-pack/plugins/security/common/parse_next.test.ts index b5e6c7dca41d8f..11a843d397dedc 100644 --- a/x-pack/plugins/security/common/parse_next.test.ts +++ b/x-pack/plugins/security/common/parse_next.test.ts @@ -34,6 +34,15 @@ describe('parseNext', () => { expect(parseNext(href, basePath)).toEqual(`${next}#${hash}`); }); + it('should properly handle multiple next with hash', () => { + const basePath = '/iqf'; + const next1 = `${basePath}/app/kibana`; + const next2 = `${basePath}/app/ml`; + const hash = '/discover/New-Saved-Search'; + const href = `${basePath}/login?next=${next1}&next=${next2}#${hash}`; + expect(parseNext(href, basePath)).toEqual(`${next1}#${hash}`); + }); + it('should properly decode special characters', () => { const basePath = '/iqf'; const next = `${encodeURIComponent(basePath)}%2Fapp%2Fkibana`; @@ -118,6 +127,14 @@ describe('parseNext', () => { expect(parseNext(href)).toEqual(`${next}#${hash}`); }); + it('should properly handle multiple next with hash', () => { + const next1 = '/app/kibana'; + const next2 = '/app/ml'; + const hash = '/discover/New-Saved-Search'; + const href = `/login?next=${next1}&next=${next2}#${hash}`; + expect(parseNext(href)).toEqual(`${next1}#${hash}`); + }); + it('should properly decode special characters', () => { const next = '%2Fapp%2Fkibana'; const hash = '/discover/New-Saved-Search'; diff --git a/x-pack/plugins/security/common/parse_next.ts b/x-pack/plugins/security/common/parse_next.ts index 834acd783abbe9..7cbe335825a5a7 100644 --- a/x-pack/plugins/security/common/parse_next.ts +++ b/x-pack/plugins/security/common/parse_next.ts @@ -40,5 +40,5 @@ export function parseNext(href: string, basePath = '') { return `${basePath}/`; } - return query.next + (hash || ''); + return next + (hash || ''); } 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 30715be1db232c..ecbdfedac1dd38 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 @@ -23,7 +23,7 @@ exports[`LoginPage disabled form states renders as expected when an unknown logi @@ -38,6 +38,25 @@ exports[`LoginPage disabled form states renders as expected when an unknown logi /> `; +exports[`LoginPage disabled form states renders as expected when login is not enabled 1`] = ` + + } + title={ + + } +/> +`; + exports[`LoginPage disabled form states renders as expected when secure connection is required but not present 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`] = ` - `; @@ -172,7 +254,7 @@ exports[`LoginPage page renders as expected 1`] = ` gutterSize="l" > - diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap deleted file mode 100644 index b09f398ed5ed9d..00000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap +++ /dev/null @@ -1,95 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`BasicLoginForm renders as expected 1`] = ` - - - - - -
- - } - labelType="label" - > - - - - } - labelType="label" - > - - - - - -
-
-
-`; diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx deleted file mode 100644 index e62fd7191dfaec..00000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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 React from 'react'; -import { act } from '@testing-library/react'; -import { EuiButton, EuiCallOut } from '@elastic/eui'; -import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { BasicLoginForm } from './basic_login_form'; - -import { coreMock } from '../../../../../../../../src/core/public/mocks'; - -describe('BasicLoginForm', () => { - beforeAll(() => { - Object.defineProperty(window, 'location', { - value: { href: 'https://some-host/bar' }, - writable: true, - }); - }); - - afterAll(() => { - delete (window as any).location; - }); - - it('renders as expected', () => { - expect( - shallowWithIntl( - - ) - ).toMatchSnapshot(); - }); - - it('renders an info message when provided.', () => { - const wrapper = shallowWithIntl( - - ); - - expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); - }); - - it('renders an invalid credentials message', async () => { - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockRejectedValue({ response: { status: 401 } }); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(EuiCallOut).props().title).toEqual( - `Invalid username or password. Please try again.` - ); - }); - - it('renders unknown error message', async () => { - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockRejectedValue({ response: { status: 500 } }); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(EuiCallOut).props().title).toEqual(`Oops! Error. Try again.`); - }); - - it('properly redirects after successful login', async () => { - window.location.href = `https://some-host/login?next=${encodeURIComponent( - '/some-base-path/app/kibana#/home?_g=()' - )}`; - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockResolvedValue({}); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(mockHTTP.post).toHaveBeenCalledTimes(1); - expect(mockHTTP.post).toHaveBeenCalledWith('/internal/security/login', { - body: JSON.stringify({ username: 'username1', password: 'password1' }), - }); - - expect(window.location.href).toBe('/some-base-path/app/kibana#/home?_g=()'); - expect(wrapper.find(EuiCallOut).exists()).toBe(false); - }); -}); diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx deleted file mode 100644 index 7302ee9bf9851e..00000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/* - * 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 React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; -import ReactMarkdown from 'react-markdown'; -import { - EuiButton, - EuiCallOut, - EuiFieldText, - EuiFormRow, - EuiPanel, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { HttpStart, IHttpFetchError } from 'src/core/public'; -import { parseNext } from '../../../../../common/parse_next'; - -interface Props { - http: HttpStart; - infoMessage?: string; - loginAssistanceMessage: string; -} - -interface State { - hasError: boolean; - isLoading: boolean; - username: string; - password: string; - message: string; -} - -export class BasicLoginForm extends Component { - public state = { - hasError: false, - isLoading: false, - username: '', - password: '', - message: '', - }; - - public render() { - return ( - - {this.renderLoginAssistanceMessage()} - {this.renderMessage()} - -
- - } - > - - - - - } - > - - - - - - -
-
-
- ); - } - - private renderLoginAssistanceMessage = () => { - return ( - - - {this.props.loginAssistanceMessage} - - - ); - }; - - private renderMessage = () => { - if (this.state.message) { - return ( - - - - - ); - } - - if (this.props.infoMessage) { - return ( - - - - - ); - } - - return null; - }; - - private setUsernameInputRef(ref: HTMLInputElement) { - if (ref) { - ref.focus(); - } - } - - private isFormValid = () => { - const { username, password } = this.state; - - return username && password; - }; - - private onUsernameChange = (e: ChangeEvent) => { - this.setState({ - username: e.target.value, - }); - }; - - private onPasswordChange = (e: ChangeEvent) => { - this.setState({ - password: e.target.value, - }); - }; - - private submit = async (e: MouseEvent | FormEvent) => { - e.preventDefault(); - - if (!this.isFormValid()) { - return; - } - - this.setState({ - isLoading: true, - message: '', - }); - - const { http } = this.props; - const { username, password } = this.state; - - try { - await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); - window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); - } catch (error) { - const message = - (error as IHttpFetchError).response?.status === 401 - ? i18n.translate( - 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', - { defaultMessage: 'Invalid username or password. Please try again.' } - ) - : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { - defaultMessage: 'Oops! Error. Try again.', - }); - - this.setState({ - hasError: true, - message, - isLoading: false, - }); - } - }; -} diff --git a/x-pack/plugins/security/public/authentication/login/components/index.ts b/x-pack/plugins/security/public/authentication/login/components/index.ts index 5f267f7c4caa20..3113a177d433e9 100644 --- a/x-pack/plugins/security/public/authentication/login/components/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { BasicLoginForm } from './basic_login_form'; +export { LoginForm } from './login_form'; export { DisabledLoginForm } from './disabled_login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap new file mode 100644 index 00000000000000..a25498a637c2fe --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoginForm login selector renders as expected with login form 1`] = ` + + + Login w/SAML + + + + Login w/PKI + + + + ―――   + +   ――― + + + +
+ + } + labelType="label" + > + + + + } + labelType="label" + > + + + + + +
+
+
+`; + +exports[`LoginForm login selector renders as expected without login form for providers with and without description 1`] = ` + + + Login w/SAML + + + + + + + +`; + +exports[`LoginForm renders as expected 1`] = ` + + +
+ + } + labelType="label" + > + + + + } + labelType="label" + > + + + + + +
+
+
+`; diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts similarity index 82% rename from x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts rename to x-pack/plugins/security/public/authentication/login/components/login_form/index.ts index 3c1350ba590a62..c09a8dad4945ca 100644 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { BasicLoginForm } from './basic_login_form'; +export { LoginForm } from './login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx new file mode 100644 index 00000000000000..c17c10a2c51484 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -0,0 +1,272 @@ +/* + * 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 React from 'react'; +import { act } from '@testing-library/react'; +import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { LoginForm } from './login_form'; + +import { coreMock } from '../../../../../../../../src/core/public/mocks'; + +describe('LoginForm', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host/bar' }, + writable: true, + }); + }); + + afterAll(() => { + delete (window as any).location; + }); + + it('renders as expected', () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('renders an info message when provided.', () => { + const coreStartMock = coreMock.createStart(); + const wrapper = shallowWithIntl( + + ); + + expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); + }); + + it('renders an invalid credentials message', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue({ response: { status: 401 } }); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiCallOut).props().title).toEqual( + `Invalid username or password. Please try again.` + ); + }); + + it('renders unknown error message', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue({ response: { status: 500 } }); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiCallOut).props().title).toEqual(`Oops! Error. Try again.`); + }); + + it('properly redirects after successful login', async () => { + window.location.href = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockResolvedValue({}); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ username: 'username1', password: 'password1' }), + }); + + expect(window.location.href).toBe('/some-base-path/app/kibana#/home?_g=()'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + }); + + describe('login selector', () => { + it('renders as expected with login form', async () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('renders as expected without login form for providers with and without description', async () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('properly redirects after successful login', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockResolvedValue({ + location: 'https://external-idp/login?optional-arg=2#optional-hash', + }); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + + ); + + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe('https://external-idp/login?optional-arg=2#optional-hash'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); + }); + + it('shows error toast if login fails', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const failureReason = new Error('Oh no!'); + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue(failureReason); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + + ); + + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe(currentURL); + expect(coreStartMock.notifications.toasts.addError).toHaveBeenCalledWith(failureReason, { + title: 'Could not perform login.', + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx new file mode 100644 index 00000000000000..a028eb1ba4b707 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -0,0 +1,343 @@ +/* + * 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 React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { + EuiButton, + EuiCallOut, + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; +import { parseNext } from '../../../../../common/parse_next'; +import { LoginSelector } from '../../../../../common/login_state'; + +interface Props { + http: HttpStart; + notifications: NotificationsStart; + selector: LoginSelector; + showLoginForm: boolean; + infoMessage?: string; + loginAssistanceMessage: string; +} + +interface State { + loadingState: + | { type: LoadingStateType.None } + | { type: LoadingStateType.Form } + | { type: LoadingStateType.Selector; providerName: string }; + username: string; + password: string; + message: + | { type: MessageType.None } + | { type: MessageType.Danger | MessageType.Info; content: string }; +} + +enum LoadingStateType { + None, + Form, + Selector, +} + +enum MessageType { + None, + Info, + Danger, +} + +export class LoginForm extends Component { + public state: State = { + loadingState: { type: LoadingStateType.None }, + username: '', + password: '', + message: this.props.infoMessage + ? { type: MessageType.Info, content: this.props.infoMessage } + : { type: MessageType.None }, + }; + + public render() { + return ( + + {this.renderLoginAssistanceMessage()} + {this.renderMessage()} + {this.renderSelector()} + {this.renderLoginForm()} + + ); + } + + private renderLoginForm = () => { + if (!this.props.showLoginForm) { + return null; + } + + return ( + +
+ + } + > + + + + + } + > + + + + + + +
+
+ ); + }; + + private renderLoginAssistanceMessage = () => { + if (!this.props.loginAssistanceMessage) { + return null; + } + + return ( + + + {this.props.loginAssistanceMessage} + + + ); + }; + + private renderMessage = () => { + const { message } = this.state; + if (message.type === MessageType.Danger) { + return ( + + + + + ); + } + + if (message.type === MessageType.Info) { + return ( + + + + + ); + } + + return null; + }; + + private renderSelector = () => { + const showLoginSelector = + this.props.selector.enabled && this.props.selector.providers.length > 0; + if (!showLoginSelector) { + return null; + } + + const loginSelectorAndLoginFormSeparator = showLoginSelector && this.props.showLoginForm && ( + <> + + ―――   + +   ――― + + + + ); + + return ( + <> + {this.props.selector.providers.map((provider, index) => ( + + this.loginWithSelector(provider.type, provider.name)} + > + {provider.description ?? ( + + )} + + + + ))} + {loginSelectorAndLoginFormSeparator} + + ); + }; + + private setUsernameInputRef(ref: HTMLInputElement) { + if (ref) { + ref.focus(); + } + } + + private isFormValid = () => { + const { username, password } = this.state; + + return username && password; + }; + + private onUsernameChange = (e: ChangeEvent) => { + this.setState({ + username: e.target.value, + }); + }; + + private onPasswordChange = (e: ChangeEvent) => { + this.setState({ + password: e.target.value, + }); + }; + + private submitLoginForm = async ( + e: MouseEvent | FormEvent + ) => { + e.preventDefault(); + + if (!this.isFormValid()) { + return; + } + + this.setState({ + loadingState: { type: LoadingStateType.Form }, + message: { type: MessageType.None }, + }); + + const { http } = this.props; + const { username, password } = this.state; + + try { + await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); + window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); + } catch (error) { + const message = + (error as IHttpFetchError).response?.status === 401 + ? i18n.translate( + 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', + { defaultMessage: 'Invalid username or password. Please try again.' } + ) + : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { + defaultMessage: 'Oops! Error. Try again.', + }); + + this.setState({ + message: { type: MessageType.Danger, content: message }, + loadingState: { type: LoadingStateType.None }, + }); + } + }; + + private loginWithSelector = async (providerType: string, providerName: string) => { + this.setState({ + loadingState: { type: LoadingStateType.Selector, providerName }, + message: { type: MessageType.None }, + }); + + try { + const { location } = await this.props.http.post<{ location: string }>( + '/internal/security/login_with', + { body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }) } + ); + + window.location.href = location; + } catch (err) { + this.props.notifications.toasts.addError(err, { + title: i18n.translate('xpack.security.loginPage.loginSelectorErrorMessage', { + defaultMessage: 'Could not perform login.', + }), + }); + + this.setState({ loadingState: { type: LoadingStateType.None } }); + } + }; + + private isLoadingState(type: LoadingStateType.None | LoadingStateType.Form): boolean; + private isLoadingState(type: LoadingStateType.Selector, providerName: string): boolean; + private isLoadingState(type: LoadingStateType, providerName?: string) { + const { loadingState } = this.state; + if (loadingState.type !== type) { + return false; + } + + return ( + loadingState.type !== LoadingStateType.Selector || loadingState.providerName === providerName + ); + } +} diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index 051f08058ed8dc..2597a935f45df8 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -38,7 +38,6 @@ describe('loginApp', () => { it('properly renders application', async () => { const coreSetupMock = coreMock.createSetup(); const coreStartMock = coreMock.createStart(); - coreStartMock.injectedMetadata.getInjectedVar.mockReturnValue(true); coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}]); const containerMock = document.createElement('div'); @@ -55,16 +54,13 @@ describe('loginApp', () => { history: (scopedHistoryMock.create() as unknown) as ScopedHistory, }); - expect(coreStartMock.injectedMetadata.getInjectedVar).toHaveBeenCalledTimes(1); - expect(coreStartMock.injectedMetadata.getInjectedVar).toHaveBeenCalledWith('secureCookies'); - const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; expect(mockRenderApp).toHaveBeenCalledTimes(1); expect(mockRenderApp).toHaveBeenCalledWith(coreStartMock.i18n, containerMock, { http: coreStartMock.http, + notifications: coreStartMock.notifications, fatalErrors: coreStartMock.fatalErrors, loginAssistanceMessage: 'some-message', - requiresSecureConnection: true, }); }); }); diff --git a/x-pack/plugins/security/public/authentication/login/login_app.ts b/x-pack/plugins/security/public/authentication/login/login_app.ts index 4f4bf3903a1fa4..1642aba51c1ae9 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.ts @@ -31,11 +31,9 @@ export const loginApp = Object.freeze({ ]); return renderLoginPage(coreStart.i18n, element, { http: coreStart.http, + notifications: coreStart.notifications, fatalErrors: coreStart.fatalErrors, loginAssistanceMessage: config.loginAssistanceMessage, - requiresSecureConnection: coreStart.injectedMetadata.getInjectedVar( - 'secureCookies' - ) as boolean, }); }, }); 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 294434cd08ebc6..c4be57d8d7db7d 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,15 +8,18 @@ import React from 'react'; import { shallow } from 'enzyme'; import { act } from '@testing-library/react'; import { nextTick } from 'test_utils/enzyme_helpers'; -import { LoginState } from './login_state'; +import { LoginState } from '../../../common/login_state'; import { LoginPage } from './login_page'; import { coreMock } from '../../../../../../src/core/public/mocks'; -import { DisabledLoginForm, BasicLoginForm } from './components'; +import { DisabledLoginForm, LoginForm } from './components'; const createLoginState = (options?: Partial) => { return { allowLogin: true, layout: 'form', + requiresSecureConnection: false, + showLoginForm: true, + selector: { enabled: false, providers: [] }, ...options, } as LoginState; }; @@ -55,9 +58,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -74,14 +77,14 @@ describe('LoginPage', () => { describe('disabled form states', () => { it('renders as expected when secure connection is required but not present', async () => { const coreStartMock = coreMock.createStart(); - httpMock.get.mockResolvedValue(createLoginState()); + httpMock.get.mockResolvedValue(createLoginState({ requiresSecureConnection: true })); const wrapper = shallow( ); @@ -100,9 +103,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -121,9 +124,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -144,9 +147,30 @@ describe('LoginPage', () => { const wrapper = shallow( + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(DisabledLoginForm)).toMatchSnapshot(); + }); + + it('renders as expected when login is not enabled', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState({ showLoginForm: false })); + + const wrapper = shallow( + ); @@ -167,9 +191,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -179,7 +203,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); it('renders as expected when info message is set', async () => { @@ -190,9 +214,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -202,7 +226,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); it('renders as expected when loginAssistanceMessage is set', async () => { @@ -212,9 +236,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -224,7 +248,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); }); @@ -236,9 +260,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -261,9 +285,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); 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 d38dee74faedf8..70f8f76ee0a9c0 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -12,16 +12,15 @@ import { parse } from 'url'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CoreStart, FatalErrorsStart, HttpStart } from 'src/core/public'; -import { LoginLayout } from '../../../common/licensing'; -import { BasicLoginForm, DisabledLoginForm } from './components'; -import { LoginState } from './login_state'; +import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public'; +import { LoginState } from '../../../common/login_state'; +import { LoginForm, DisabledLoginForm } from './components'; interface Props { http: HttpStart; + notifications: NotificationsStart; fatalErrors: FatalErrorsStart; loginAssistanceMessage: string; - requiresSecureConnection: boolean; } interface State { @@ -44,7 +43,7 @@ const infoMessageMap = new Map([ ]); export class LoginPage extends Component { - state = { loginState: null }; + state = { loginState: null } as State; public async componentDidMount() { const loadingCount$ = new BehaviorSubject(1); @@ -67,12 +66,10 @@ export class LoginPage extends Component { } const isSecureConnection = !!window.location.protocol.match(/^https/); - const { allowLogin, layout } = loginState; + const { allowLogin, layout, requiresSecureConnection } = loginState; const loginIsSupported = - this.props.requiresSecureConnection && !isSecureConnection - ? false - : allowLogin && layout === 'form'; + requiresSecureConnection && !isSecureConnection ? false : allowLogin && layout === 'form'; const contentHeaderClasses = classNames('loginWelcome__content', 'eui-textCenter', { ['loginWelcome__contentDisabledForm']: !loginIsSupported, @@ -111,7 +108,7 @@ export class LoginPage extends Component {
- {this.getLoginForm({ isSecureConnection, layout })} + {this.getLoginForm({ ...loginState, isSecureConnection })}
@@ -119,13 +116,34 @@ export class LoginPage extends Component { } private getLoginForm = ({ - isSecureConnection, layout, - }: { - isSecureConnection: boolean; - layout: LoginLayout; - }) => { - if (this.props.requiresSecureConnection && !isSecureConnection) { + requiresSecureConnection, + isSecureConnection, + selector, + showLoginForm, + }: LoginState & { isSecureConnection: boolean }) => { + const isLoginExplicitlyDisabled = + !showLoginForm && (!selector.enabled || selector.providers.length === 0); + if (isLoginExplicitlyDisabled) { + return ( + + } + message={ + + } + /> + ); + } + + if (requiresSecureConnection && !isSecureConnection) { return ( { ); } - switch (layout) { - case 'form': - return ( - - ); - case 'error-es-unavailable': - return ( - - } - message={ - - } - /> - ); - case 'error-xpack-unavailable': - return ( - - } - message={ - - } - /> - ); - default: - return ( - - } - message={ - - } - /> - ); + if (layout === 'error-es-unavailable') { + return ( + + } + message={ + + } + /> + ); + } + + if (layout === 'error-xpack-unavailable') { + return ( + + } + message={ + + } + /> + ); + } + + if (layout !== 'form') { + return ( + + } + message={ + + } + /> + ); } + + return ( + + ); }; } diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 36cc5b12268d72..b93cfaa33783e3 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -5,6 +5,7 @@ */ jest.mock('./providers/basic'); +jest.mock('./providers/token'); jest.mock('./providers/saml'); jest.mock('./providers/http'); @@ -20,34 +21,33 @@ import { sessionStorageMock, } from '../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { ConfigSchema, createConfig } from '../config'; import { AuthenticationResult } from './authentication_result'; import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator'; import { DeauthenticationResult } from './deauthentication_result'; -import { BasicAuthenticationProvider } from './providers'; +import { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers'; function getMockOptions({ session, providers, http = {}, + selector, }: { session?: AuthenticatorOptions['config']['session']; - providers?: string[]; + providers?: Record | string[]; http?: Partial; + selector?: AuthenticatorOptions['config']['authc']['selector']; } = {}) { return { clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, loggers: loggingServiceMock.create(), getServerBaseURL: jest.fn(), - config: { - session: { idleTimeout: null, lifespan: null, ...(session || {}) }, - authc: { - providers: providers || [], - oidc: {}, - saml: {}, - http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'], ...http }, - }, - }, + config: createConfig( + ConfigSchema.validate({ session, authc: { selector, providers, http } }), + loggingServiceMock.create().get(), + { isTLSEnabled: false } + ), sessionStorageFactory: sessionStorageMock.createFactory(), }; } @@ -57,26 +57,36 @@ describe('Authenticator', () => { beforeEach(() => { mockBasicAuthenticationProvider = { login: jest.fn(), - authenticate: jest.fn(), - logout: jest.fn(), + authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()), getHTTPAuthenticationScheme: jest.fn(), }; jest.requireMock('./providers/http').HTTPAuthenticationProvider.mockImplementation(() => ({ + type: 'http', authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()), + })); + + jest.requireMock('./providers/basic').BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', + ...mockBasicAuthenticationProvider, })); - jest - .requireMock('./providers/basic') - .BasicAuthenticationProvider.mockImplementation(() => mockBasicAuthenticationProvider); + jest.requireMock('./providers/saml').SAMLAuthenticationProvider.mockImplementation(() => ({ + type: 'saml', + getHTTPAuthenticationScheme: jest.fn(), + })); }); afterEach(() => jest.clearAllMocks()); describe('initialization', () => { it('fails if authentication providers are not configured.', () => { - expect(() => new Authenticator(getMockOptions())).toThrowError( - 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' + expect( + () => new Authenticator(getMockOptions({ providers: {}, http: { enabled: false } })) + ).toThrowError( + 'No authentication provider is configured. Verify `xpack.security.authc.*` config value.' ); }); @@ -86,11 +96,19 @@ describe('Authenticator', () => { ); }); + it('fails if any of the user specified provider uses reserved __http__ name.', () => { + expect( + () => + new Authenticator(getMockOptions({ providers: { basic: { __http__: { order: 0 } } } })) + ).toThrowError('Provider name "__http__" is reserved.'); + }); + describe('HTTP authentication provider', () => { beforeEach(() => { jest .requireMock('./providers/basic') .BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', getHTTPAuthenticationScheme: jest.fn().mockReturnValue('basic'), })); }); @@ -98,9 +116,9 @@ describe('Authenticator', () => { afterEach(() => jest.resetAllMocks()); it('enabled by default', () => { - const authenticator = new Authenticator(getMockOptions({ providers: ['basic'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + const authenticator = new Authenticator(getMockOptions()); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -111,11 +129,13 @@ describe('Authenticator', () => { it('includes all required schemes if `autoSchemesEnabled` is enabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic', 'kerberos'] }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } }, kerberos: { kerberos1: { order: 1 } } }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('kerberos')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('kerberos')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -126,11 +146,14 @@ describe('Authenticator', () => { it('does not include additional schemes if `autoSchemesEnabled` is disabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic', 'kerberos'], http: { autoSchemesEnabled: false } }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } }, kerberos: { kerberos1: { order: 1 } } }, + http: { autoSchemesEnabled: false }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('kerberos')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('kerberos')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -139,10 +162,13 @@ describe('Authenticator', () => { it('disabled if explicitly disabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic'], http: { enabled: false } }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } } }, + http: { enabled: false }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(false); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(false); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -157,14 +183,15 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -177,17 +204,26 @@ describe('Authenticator', () => { ); }); - it('fails if login attempt is not provided.', async () => { + it('fails if login attempt is not provided or invalid.', async () => { await expect( authenticator.login(httpServerMock.createKibanaRequest(), undefined as any) ).rejects.toThrowError( - 'Login attempt should be an object with non-empty "provider" property.' + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' ); await expect( authenticator.login(httpServerMock.createKibanaRequest(), {} as any) ).rejects.toThrowError( - 'Login attempt should be an object with non-empty "provider" property.' + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' + ); + + await expect( + authenticator.login(httpServerMock.createKibanaRequest(), { + provider: 'basic', + value: {}, + } as any) + ).rejects.toThrowError( + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' ); }); @@ -199,9 +235,9 @@ describe('Authenticator', () => { AuthenticationResult.failed(failureReason) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); }); it('returns user that authentication provider returns.', async () => { @@ -212,7 +248,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) ); }); @@ -226,9 +264,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.succeeded(user, { state: { authorization } }) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: { authorization } })); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -239,9 +277,171 @@ describe('Authenticator', () => { it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); - await expect(authenticator.login(request, { provider: 'token', value: {} })).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect( + authenticator.login(request, { provider: { type: 'token' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + await expect( + authenticator.login(request, { provider: { name: 'basic2' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + describe('multi-provider scenarios', () => { + let mockSAMLAuthenticationProvider1: jest.Mocked>; + let mockSAMLAuthenticationProvider2: jest.Mocked>; + + beforeEach(() => { + mockSAMLAuthenticationProvider1 = { + login: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + mockSAMLAuthenticationProvider2 = { + login: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + jest + .requireMock('./providers/saml') + .SAMLAuthenticationProvider.mockImplementationOnce(() => ({ + type: 'saml', + ...mockSAMLAuthenticationProvider1, + })) + .mockImplementationOnce(() => ({ + type: 'saml', + ...mockSAMLAuthenticationProvider2, + })); + + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { + saml1: { realm: 'saml1-realm', order: 1 }, + saml2: { realm: 'saml2-realm', order: 2 }, + }, + }, + }); + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + it('tries to login only with the provider that has specified name', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + mockSAMLAuthenticationProvider2.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { token: 'access-token' } }) + ); + + await expect( + authenticator.login(request, { provider: { name: 'saml2' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: { token: 'access-token' } }) + ); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml2' }, + state: { token: 'access-token' }, + }); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).not.toHaveBeenCalled(); + }); + + it('tries to login only with the provider that has specified type', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider1.login.mock.invocationCallOrder[0]).toBeLessThan( + mockSAMLAuthenticationProvider2.login.mock.invocationCallOrder[0] + ); + }); + + it('returns as soon as provider handles request', async () => { + const request = httpServerMock.createKibanaRequest(); + + const authenticationResults = [ + AuthenticationResult.failed(new Error('Fail')), + AuthenticationResult.succeeded(mockAuthenticatedUser(), { state: { result: '200' } }), + AuthenticationResult.redirectTo('/some/url', { state: { result: '302' } }), + ]; + + for (const result of authenticationResults) { + mockSAMLAuthenticationProvider1.login.mockResolvedValue(result); + + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: {} }) + ).resolves.toEqual(result); + } + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(2); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + state: { result: '200' }, + }); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + state: { result: '302' }, + }); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider2.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(3); + }); + + it('provides session only if provider name matches', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml2' }, + }); + + const loginAttemptValue = Symbol('attempt'); + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: loginAttemptValue }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledWith( + request, + loginAttemptValue, + null + ); + + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledWith( + request, + loginAttemptValue, + mockSessVal.state + ); + + // Presence of the session has precedence over order. + expect(mockSAMLAuthenticationProvider2.login.mock.invocationCallOrder[0]).toBeLessThan( + mockSAMLAuthenticationProvider1.login.mock.invocationCallOrder[0] + ); + }); }); it('clears session if it belongs to a different provider.', async () => { @@ -250,10 +450,13 @@ describe('Authenticator', () => { const request = httpServerMock.createKibanaRequest(); mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect( - authenticator.login(request, { provider: 'basic', value: credentials }) + authenticator.login(request, { provider: { type: 'basic' }, value: credentials }) ).resolves.toEqual(AuthenticationResult.succeeded(user)); expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith( @@ -266,17 +469,67 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + it('clears session if it belongs to a provider with the name that is registered but has different type.', async () => { + const user = mockAuthenticatedUser(); + const credentials = { username: 'user', password: 'password' }; + const request = httpServerMock.createKibanaRequest(); + + // Re-configure authenticator with `token` provider that uses the name of `basic`. + const loginMock = jest.fn().mockResolvedValue(AuthenticationResult.succeeded(user)); + jest.requireMock('./providers/token').TokenAuthenticationProvider.mockImplementation(() => ({ + type: 'token', + login: loginMock, + getHTTPAuthenticationScheme: jest.fn(), + })); + mockOptions = getMockOptions({ providers: { token: { basic1: { order: 0 } } } }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + mockSessionStorage.get.mockResolvedValue(mockSessVal); + + await expect( + authenticator.login(request, { provider: { name: 'basic1' }, value: credentials }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(loginMock).toHaveBeenCalledWith(request, credentials, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + it('clears session if provider asked to do so.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.login.mockResolvedValue( AuthenticationResult.succeeded(user, { state: null }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.succeeded(user, { state: null }) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: null })); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + + it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, {}, null); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -289,14 +542,15 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -431,7 +685,7 @@ describe('Authenticator', () => { idleTimeout: duration(3600 * 24), lifespan: null, }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -470,7 +724,7 @@ describe('Authenticator', () => { idleTimeout: duration(hr * 2), lifespan: duration(hr * 8), }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -522,7 +776,7 @@ describe('Authenticator', () => { idleTimeout: null, lifespan, }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -704,14 +958,33 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledWith(request, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + it('does not clear session if provider can not handle system API request authentication with active session.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-system-request': 'true' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( @@ -727,9 +1000,6 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( @@ -745,10 +1015,10 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'true' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() @@ -763,10 +1033,10 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() @@ -775,6 +1045,70 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + + describe('with Login Selector', () => { + beforeEach(() => { + mockOptions = getMockOptions({ + selector: { enabled: true }, + providers: { basic: { basic1: { order: 0 } } }, + }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + it('does not redirect to Login Selector if there is an active session', async () => { + const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect AJAX requests to Login Selector', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector if request has `Authorization` header', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic ***' }, + }); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector if it is not enabled', async () => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + authenticator = new Authenticator(mockOptions); + + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('redirects to the Login Selector when needed.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' + ) + ); + expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); + }); + }); }); describe('`logout` method', () => { @@ -783,14 +1117,14 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -806,6 +1140,7 @@ describe('Authenticator', () => { it('returns `notHandled` if session does not exist.', async () => { const request = httpServerMock.createKibanaRequest(); mockSessionStorage.get.mockResolvedValue(null); + mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled()); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() @@ -830,7 +1165,7 @@ describe('Authenticator', () => { }); it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => { - const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic' } }); + const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic1' } }); mockSessionStorage.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue( @@ -856,16 +1191,20 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('only clears session if it belongs to not configured provider.', async () => { + it('clears session if it belongs to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); const state = { authorization: 'Bearer xxx' }; - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, state, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + state, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() ); - expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); }); @@ -875,7 +1214,7 @@ describe('Authenticator', () => { let mockOptions: ReturnType; let mockSessionStorage: jest.Mocked>; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); @@ -890,13 +1229,13 @@ describe('Authenticator', () => { now: currentDate, idleTimeoutExpiration: currentDate + 60000, lifespanExpiration: currentDate + 120000, - provider: 'basic', + provider: 'basic1', }; mockSessionStorage.get.mockResolvedValue({ idleTimeoutExpiration: mockInfo.idleTimeoutExpiration, lifespanExpiration: mockInfo.lifespanExpiration, state, - provider: mockInfo.provider, + provider: { type: 'basic', name: mockInfo.provider }, path: mockOptions.basePath.serverBasePath, }); jest.spyOn(Date, 'now').mockImplementation(() => currentDate); @@ -918,13 +1257,22 @@ describe('Authenticator', () => { describe('`isProviderEnabled` method', () => { it('returns `true` only if specified provider is enabled', () => { - let authenticator = new Authenticator(getMockOptions({ providers: ['basic'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('saml')).toBe(false); - - authenticator = new Authenticator(getMockOptions({ providers: ['basic', 'saml'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('saml')).toBe(true); + let authenticator = new Authenticator( + getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }) + ); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('saml')).toBe(false); + + authenticator = new Authenticator( + getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'test' } }, + }, + }) + ); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('saml')).toBe(true); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 42e428c8db6e8f..096d5daaab0950 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -28,21 +28,22 @@ import { OIDCAuthenticationProvider, PKIAuthenticationProvider, HTTPAuthenticationProvider, - isSAMLRequestQuery, } from './providers'; import { AuthenticationResult } from './authentication_result'; import { DeauthenticationResult } from './deauthentication_result'; import { Tokens } from './tokens'; import { SessionInfo } from '../../public'; +import { canRedirectRequest } from './can_redirect_request'; +import { HTTPAuthorizationHeader } from './http_authentication'; /** * The shape of the session that is actually stored in the cookie. */ export interface ProviderSession { /** - * Name/type of the provider this session belongs to. + * Name and type of the provider this session belongs to. */ - provider: string; + provider: { type: string; name: string }; /** * The Unix time in ms when the session should be considered expired. If `null`, session will stay @@ -73,9 +74,9 @@ export interface ProviderSession { */ export interface ProviderLoginAttempt { /** - * Name/type of the provider this login attempt is targeted for. + * Name or type of the provider this login attempt is targeted for. */ - provider: string; + provider: { name: string } | { type: string }; /** * Login attempt can have any form and defined by the specific provider. @@ -116,11 +117,42 @@ function assertRequest(request: KibanaRequest) { } function assertLoginAttempt(attempt: ProviderLoginAttempt) { - if (!attempt || !attempt.provider || typeof attempt.provider !== 'string') { - throw new Error('Login attempt should be an object with non-empty "provider" property.'); + if (!isLoginAttemptWithProviderType(attempt) && !isLoginAttemptWithProviderName(attempt)) { + throw new Error( + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' + ); } } +function isLoginAttemptWithProviderName( + attempt: unknown +): attempt is { value: unknown; provider: { name: string } } { + return ( + typeof attempt === 'object' && + (attempt as any)?.provider?.name && + typeof (attempt as any)?.provider?.name === 'string' + ); +} + +function isLoginAttemptWithProviderType( + attempt: unknown +): attempt is { value: unknown; provider: { type: string } } { + return ( + typeof attempt === 'object' && + (attempt as any)?.provider?.type && + typeof (attempt as any)?.provider?.type === 'string' + ); +} + +/** + * Determines if session value was created by the previous Kibana versions which had a different + * session value format. + * @param sessionValue The session value to check. + */ +function isLegacyProviderSession(sessionValue: any) { + return typeof sessionValue?.provider === 'string'; +} + /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -196,29 +228,22 @@ export class Authenticator { getServerBaseURL: this.options.getServerBaseURL, }; - const authProviders = this.options.config.authc.providers; - if (authProviders.length === 0) { - throw new Error( - 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' - ); - } - this.providers = new Map( - authProviders.map(providerType => { - const providerSpecificOptions = this.options.config.authc.hasOwnProperty(providerType) - ? (this.options.config.authc as Record)[providerType] - : undefined; - - this.logger.debug(`Enabling "${providerType}" authentication provider.`); + this.options.config.authc.sortedProviders.map(({ type, name }) => { + this.logger.debug(`Enabling "${name}" (${type}) authentication provider.`); return [ - providerType, + name, instantiateProvider( - providerType, - Object.freeze({ ...providerCommonOptions, logger: options.loggers.get(providerType) }), - providerSpecificOptions + type, + Object.freeze({ + ...providerCommonOptions, + name, + logger: options.loggers.get(type, name), + }), + this.options.config.authc.providers[type]?.[name] ), - ] as [string, BaseAuthenticationProvider]; + ]; }) ); @@ -227,11 +252,18 @@ export class Authenticator { this.setupHTTPAuthenticationProvider( Object.freeze({ ...providerCommonOptions, + name: '__http__', logger: options.loggers.get(HTTPAuthenticationProvider.type), }) ); } + if (this.providers.size === 0) { + throw new Error( + 'No authentication provider is configured. Verify `xpack.security.authc.*` config value.' + ); + } + this.serverBasePath = this.options.basePath.serverBasePath || '/'; this.idleTimeout = this.options.config.session.idleTimeout; @@ -247,60 +279,58 @@ export class Authenticator { assertRequest(request); assertLoginAttempt(attempt); - // If there is an attempt to login with a provider that isn't enabled, we should fail. - const provider = this.providers.get(attempt.provider); - if (provider === undefined) { + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const existingSession = await this.getSessionValue(sessionStorage); + + // Login attempt can target specific provider by its name (e.g. chosen at the Login Selector UI) + // or a group of providers with the specified type (e.g. in case of 3rd-party initiated login + // attempts we may not know what provider exactly can handle that attempt and we have to try + // every enabled provider of the specified type). + const providers: Array<[string, BaseAuthenticationProvider]> = + isLoginAttemptWithProviderName(attempt) && this.providers.has(attempt.provider.name) + ? [[attempt.provider.name, this.providers.get(attempt.provider.name)!]] + : isLoginAttemptWithProviderType(attempt) + ? [...this.providerIterator(existingSession)].filter( + ([, { type }]) => type === attempt.provider.type + ) + : []; + + if (providers.length === 0) { this.logger.debug( - `Login attempt for provider "${attempt.provider}" is detected, but it isn't enabled.` + `Login attempt for provider with ${ + isLoginAttemptWithProviderName(attempt) + ? `name ${attempt.provider.name}` + : `type "${(attempt.provider as Record).type}"` + } is detected, but it isn't enabled.` ); return AuthenticationResult.notHandled(); } - this.logger.debug(`Performing login using "${attempt.provider}" provider.`); - - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + for (const [providerName, provider] of providers) { + // Check if current session has been set by this provider. + const ownsSession = + existingSession?.provider.name === providerName && + existingSession?.provider.type === provider.type; - // If we detect an existing session that belongs to a different provider than the one requested - // to perform a login we should clear such session. - let existingSession = await this.getSessionValue(sessionStorage); - if (existingSession && existingSession.provider !== attempt.provider) { - this.logger.debug( - `Clearing existing session of another ("${existingSession.provider}") provider.` + const authenticationResult = await provider.login( + request, + attempt.value, + ownsSession ? existingSession!.state : null ); - sessionStorage.clear(); - existingSession = null; - } - - const authenticationResult = await provider.login( - request, - attempt.value, - existingSession && existingSession.state - ); - // There are two possible cases when we'd want to clear existing state: - // 1. If provider owned the state (e.g. intermediate state used for multi step login), but failed - // to login, that likely means that state is not valid anymore and we should clear it. - // 2. Also provider can specifically ask to clear state by setting it to `null` even if - // authentication attempt didn't fail (e.g. custom realm could "pin" client/request identity to - // a server-side only session established during multi step login that relied on intermediate - // client-side state which isn't needed anymore). - const shouldClearSession = - authenticationResult.shouldClearState() || - (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401); - if (existingSession && shouldClearSession) { - sessionStorage.clear(); - } else if (authenticationResult.shouldUpdateState()) { - const { idleTimeoutExpiration, lifespanExpiration } = this.calculateExpiry(existingSession); - sessionStorage.set({ - state: authenticationResult.state, - provider: attempt.provider, - idleTimeoutExpiration, - lifespanExpiration, - path: this.serverBasePath, + this.updateSessionValue(sessionStorage, { + provider: { type: provider.type, name: providerName }, + isSystemRequest: request.isSystemRequest, + authenticationResult, + existingSession: ownsSession ? existingSession : null, }); + + if (!authenticationResult.notHandled()) { + return authenticationResult; + } } - return authenticationResult; + return AuthenticationResult.notHandled(); } /** @@ -313,33 +343,46 @@ export class Authenticator { const sessionStorage = this.options.sessionStorageFactory.asScoped(request); const existingSession = await this.getSessionValue(sessionStorage); - let authenticationResult = AuthenticationResult.notHandled(); - for (const [providerType, provider] of this.providerIterator(existingSession)) { + // If request doesn't have any session information, isn't attributed with HTTP Authorization + // header and Login Selector is enabled, we must redirect user to the login selector. + const useLoginSelector = + !existingSession && + this.options.config.authc.selector.enabled && + canRedirectRequest(request) && + HTTPAuthorizationHeader.parseFromRequest(request) == null; + if (useLoginSelector) { + 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.path}` + )}` + ); + } + + for (const [providerName, provider] of this.providerIterator(existingSession)) { // Check if current session has been set by this provider. - const ownsSession = existingSession && existingSession.provider === providerType; + const ownsSession = + existingSession?.provider.name === providerName && + existingSession?.provider.type === provider.type; - authenticationResult = await provider.authenticate( + const authenticationResult = await provider.authenticate( request, ownsSession ? existingSession!.state : null ); this.updateSessionValue(sessionStorage, { - providerType, + provider: { type: provider.type, name: providerName }, isSystemRequest: request.isSystemRequest, authenticationResult, existingSession: ownsSession ? existingSession : null, }); - if ( - authenticationResult.failed() || - authenticationResult.succeeded() || - authenticationResult.redirected() - ) { + if (!authenticationResult.notHandled()) { return authenticationResult; } } - return authenticationResult; + return AuthenticationResult.notHandled(); } /** @@ -351,28 +394,33 @@ export class Authenticator { const sessionStorage = this.options.sessionStorageFactory.asScoped(request); const sessionValue = await this.getSessionValue(sessionStorage); - const providerName = this.getProviderName(request.query); if (sessionValue) { sessionStorage.clear(); - return this.providers.get(sessionValue.provider)!.logout(request, sessionValue.state); - } else if (providerName) { + return this.providers.get(sessionValue.provider.name)!.logout(request, sessionValue.state); + } + + const providerName = this.getProviderName(request.query); + if (providerName) { // provider name is passed in a query param and sourced from the browser's local storage; // hence, we can't assume that this provider exists, so we have to check it const provider = this.providers.get(providerName); if (provider) { return provider.logout(request, null); } - } - - // 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 if SAML is supported there - // is a special case when logout is initiated by the IdP or another SP, then IdP will request _every_ - // SP associated with the current user session to do the logout. So if Kibana (without active session) - // receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP - // with correct logout response and only Elasticsearch knows how to do that. - if (isSAMLRequestQuery(request.query) && this.providers.has('saml')) { - return this.providers.get('saml')!.logout(request); + } else { + // In case logout is called and we cannot figure out what provider is supposed to handle it, + // we should iterate through all providers and let them decide if they can perform a logout. + // This can be necessary if some 3rd-party initiates logout. And even if user doesn't have an + // active session already some providers can still properly respond to the 3rd-party logout + // request. For example SAML provider can process logout request encoded in `SAMLRequest` + // query string parameter. + for (const [, provider] of this.providerIterator(null)) { + const deauthenticationResult = await provider.logout(request); + if (!deauthenticationResult.notHandled()) { + return deauthenticationResult; + } + } } return DeauthenticationResult.notHandled(); @@ -395,7 +443,7 @@ export class Authenticator { now: Date.now(), idleTimeoutExpiration: sessionValue.idleTimeoutExpiration, lifespanExpiration: sessionValue.lifespanExpiration, - provider: sessionValue.provider, + provider: sessionValue.provider.name, }; } return null; @@ -405,8 +453,8 @@ export class Authenticator { * Checks whether specified provider type is currently enabled. * @param providerType Type of the provider (`basic`, `saml`, `pki` etc.). */ - isProviderEnabled(providerType: string) { - return this.providers.has(providerType); + isProviderTypeEnabled(providerType: string) { + return [...this.providers.values()].some(provider => provider.type === providerType); } /** @@ -430,10 +478,11 @@ export class Authenticator { } } - this.providers.set( - HTTPAuthenticationProvider.type, - new HTTPAuthenticationProvider(options, { supportedSchemes }) - ); + if (this.providers.has(options.name)) { + throw new Error(`Provider name "${options.name}" is reserved.`); + } + + this.providers.set(options.name, new HTTPAuthenticationProvider(options, { supportedSchemes })); } /** @@ -449,11 +498,11 @@ export class Authenticator { if (!sessionValue) { yield* this.providers; } else { - yield [sessionValue.provider, this.providers.get(sessionValue.provider)!]; + yield [sessionValue.provider.name, this.providers.get(sessionValue.provider.name)!]; - for (const [providerType, provider] of this.providers) { - if (providerType !== sessionValue.provider) { - yield [providerType, provider]; + for (const [providerName, provider] of this.providers) { + if (providerName !== sessionValue.provider.name) { + yield [providerName, provider]; } } } @@ -465,14 +514,19 @@ export class Authenticator { * @param sessionStorage Session storage instance. */ private async getSessionValue(sessionStorage: SessionStorage) { - let sessionValue = await sessionStorage.get(); + const sessionValue = await sessionStorage.get(); - // If for some reason we have a session stored for the provider that is not available - // (e.g. when user was logged in with one provider, but then configuration has changed - // and that provider is no longer available), then we should clear session entirely. - if (sessionValue && !this.providers.has(sessionValue.provider)) { + // If we detect that session is in incompatible format or for some reason we have a session + // stored for the provider that is not available anymore (e.g. when user was logged in with one + // provider, but then configuration has changed and that provider is no longer available), then + // we should clear session entirely. + if ( + sessionValue && + (isLegacyProviderSession(sessionValue) || + this.providers.get(sessionValue.provider.name)?.type !== sessionValue.provider.type) + ) { sessionStorage.clear(); - sessionValue = null; + return null; } return sessionValue; @@ -481,12 +535,12 @@ export class Authenticator { private updateSessionValue( sessionStorage: SessionStorage, { - providerType, + provider, authenticationResult, existingSession, isSystemRequest, }: { - providerType: string; + provider: { type: string; name: string }; authenticationResult: AuthenticationResult; existingSession: ProviderSession | null; isSystemRequest: boolean; @@ -517,7 +571,7 @@ export class Authenticator { state: authenticationResult.shouldUpdateState() ? authenticationResult.state : existingSession!.state, - provider: providerType, + provider, idleTimeoutExpiration, lifespanExpiration, path: this.serverBasePath, diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index 43892753f0d3fb..8092c1c81017b5 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -10,7 +10,7 @@ export const authenticationMock = { create: (): jest.Mocked => ({ login: jest.fn(), logout: jest.fn(), - isProviderEnabled: jest.fn(), + isProviderTypeEnabled: jest.fn(), createAPIKey: jest.fn(), getCurrentUser: jest.fn(), grantAPIKeyAsInternalUser: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 1f57ab4ba2671d..6694cf63e99881 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -10,7 +10,6 @@ jest.mock('./api_keys'); jest.mock('./authenticator'); import Boom from 'boom'; -import { first } from 'rxjs/operators'; import { loggingServiceMock, @@ -31,7 +30,7 @@ import { ScopedClusterClient, } from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../common/model'; -import { ConfigType, createConfig$ } from '../config'; +import { ConfigSchema, ConfigType, createConfig } from '../config'; import { AuthenticationResult } from './authentication_result'; import { Authentication, setupAuthentication } from '.'; import { @@ -51,24 +50,18 @@ describe('setupAuthentication()', () => { license: jest.Mocked; }; let mockScopedClusterClient: jest.Mocked>; - beforeEach(async () => { - const mockConfig$ = createConfig$( - coreMock.createPluginInitializerContext({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - session: { - idleTimeout: null, - lifespan: null, - }, - cookieName: 'my-sid-cookie', - authc: { providers: ['basic'], http: { enabled: true } }, - public: {}, - }), - true - ); + beforeEach(() => { mockSetupAuthenticationParams = { http: coreMock.createSetup().http, - config: await mockConfig$.pipe(first()).toPromise(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingServiceMock.create().get(), + { isTLSEnabled: false } + ), clusterClient: elasticsearchServiceMock.createClusterClient(), license: licenseMock.create(), loggers: loggingServiceMock.create(), diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 132f9690b0f7b8..775e7f1b324886 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -21,7 +21,7 @@ export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; -export { OIDCAuthenticationFlow, SAMLLoginStep } from './providers'; +export { OIDCLogin, SAMLLogin } from './providers'; export { CreateAPIKeyResult, InvalidateAPIKeyResult, @@ -181,7 +181,7 @@ export async function setupAuthentication({ login: authenticator.login.bind(authenticator), logout: authenticator.logout.bind(authenticator), getSessionInfo: authenticator.getSessionInfo.bind(authenticator), - isProviderEnabled: authenticator.isProviderEnabled.bind(authenticator), + isProviderTypeEnabled: authenticator.isProviderTypeEnabled.bind(authenticator), getCurrentUser, createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => apiKeys.create(request, params), diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 296dd0ae964383..8127cfe256db5c 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -14,7 +14,7 @@ export type MockAuthenticationProviderOptions = ReturnType< typeof mockAuthenticationProviderOptions >; -export function mockAuthenticationProviderOptions() { +export function mockAuthenticationProviderOptions(options?: { name: string }) { const basePath = httpServiceMock.createSetupContract().basePath; basePath.get.mockReturnValue('/base-path'); @@ -24,5 +24,6 @@ export function mockAuthenticationProviderOptions() { logger: loggingServiceMock.create().get(), basePath, tokens: { refresh: jest.fn(), invalidate: jest.fn() }, + name: options?.name ?? 'basic1', }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index b09f6ed0fc2582..08a5905017ec5c 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -21,6 +21,7 @@ import { Tokens } from '../tokens'; * Represents available provider options. */ export interface AuthenticationProviderOptions { + name: string; getServerBaseURL: () => string; basePath: HttpServiceSetup['basePath']; client: IClusterClient; @@ -42,6 +43,12 @@ export abstract class BaseAuthenticationProvider { */ static readonly type: string; + /** + * Type of the provider. We use `this.constructor` trick to get access to the static `type` field + * of the specific `BaseAuthenticationProvider` subclass. + */ + public readonly type = (this.constructor as any).type as string; + /** * Logger instance bound to a specific provider context. */ @@ -103,9 +110,7 @@ export abstract class BaseAuthenticationProvider { ...(await this.options.client .asScoped({ headers: { ...request.headers, ...authHeaders } }) .callAsCurrentUser('shield.authenticate')), - // We use `this.constructor` trick to get access to the static `type` field of the specific - // `BaseAuthenticationProvider` subclass. - authentication_provider: (this.constructor as any).type, + authentication_provider: this.options.name, } as AuthenticatedUser); } } diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index b7bdff0531fc29..97ca4e46d3eb59 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -91,6 +91,12 @@ describe('BasicAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); }); + it('does not redirect requests that do not require authentication to the login page.', async () => { + await expect( + provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false })) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { await expect( provider.authenticate( @@ -172,8 +178,14 @@ describe('BasicAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('always redirects to the login page.', async () => { + 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 login page.', async () => { + await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') ); }); @@ -181,7 +193,10 @@ describe('BasicAuthenticationProvider', () => { it('passes query string parameters to the login page.', async () => { await expect( provider.logout( - httpServerMock.createKibanaRequest({ query: { next: '/app/ml', msg: 'SESSION_EXPIRED' } }) + httpServerMock.createKibanaRequest({ + query: { next: '/app/ml', msg: 'SESSION_EXPIRED' }, + }), + {} ) ).resolves.toEqual( DeauthenticationResult.redirectTo('/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED') diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index 76a9f936eca48a..83d4ea689f46a6 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -34,6 +34,16 @@ interface ProviderState { authorization?: string; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the login page where they can enter username and password. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports request authentication via Basic HTTP Authentication. */ @@ -92,7 +102,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { } // If state isn't present let's redirect user to the login page. - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug('Redirecting request to Login page.'); const basePath = this.options.basePath.get(request); return AuthenticationResult.redirectTo( @@ -106,8 +116,15 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { /** * Redirects user to the login page preserving query string parameters. * @param request Request instance. + * @param [state] Optional state object associated with the provider. */ - public async logout(request: KibanaRequest) { + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); + + if (!state) { + return DeauthenticationResult.notHandled(); + } + // Query string may contain the path where logout has been called or // logout reason that login page may need to know. const queryString = request.url.search || `?msg=LOGGED_OUT`; @@ -134,7 +151,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate via state.'); if (!authorization) { - this.logger.debug('Access token is not found in state.'); + this.logger.debug('Authorization header is not found in state.'); return AuthenticationResult.notHandled(); } diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index 65fbd7cd9f4adf..47715670e4697a 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -32,7 +32,7 @@ function expectAuthenticateCall( describe('HTTPAuthenticationProvider', () => { let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'http' }); }); it('throws if `schemes` are not specified', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index cd8f5a70c64e3d..048afb6190d18c 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -11,8 +11,8 @@ export { } from './base'; export { BasicAuthenticationProvider } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; -export { SAMLAuthenticationProvider, isSAMLRequestQuery, SAMLLoginStep } from './saml'; +export { SAMLAuthenticationProvider, SAMLLogin } from './saml'; export { TokenAuthenticationProvider } from './token'; -export { OIDCAuthenticationProvider, OIDCAuthenticationFlow } from './oidc'; +export { OIDCAuthenticationProvider, OIDCLogin } from './oidc'; export { PKIAuthenticationProvider } from './pki'; export { HTTPAuthenticationProvider } from './http'; diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 955805296e2bd9..6eb47cfa83e32f 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -14,6 +14,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { ElasticsearchErrorHelpers, IClusterClient, + KibanaRequest, ScopeableRequest, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; @@ -36,43 +37,13 @@ describe('KerberosAuthenticationProvider', () => { let provider: KerberosAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'kerberos' }); provider = new KerberosAuthenticationProvider(mockOptions); }); - describe('`authenticate` method', () => { - it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - - it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - const tokenPair = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - + function defineCommonLoginAndAuthenticateTests( + operation: (request: KibanaRequest) => Promise + ) { it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); @@ -80,9 +51,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({}); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, @@ -98,33 +67,13 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, }); }); - it('fails if state is present, but backend does not support Kerberos.', async () => { - const request = httpServerMock.createKibanaRequest(); - const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.mockResolvedValue(null); - - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - }); - it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); @@ -137,7 +86,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(failureReason, { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) @@ -156,9 +105,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, @@ -179,7 +126,7 @@ describe('KerberosAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'kerberos' }, { @@ -215,7 +162,7 @@ describe('KerberosAuthenticationProvider', () => { kerberos_authentication_response_token: 'response-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'kerberos' }, { @@ -249,7 +196,7 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' }, }) @@ -274,7 +221,7 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) @@ -295,9 +242,7 @@ describe('KerberosAuthenticationProvider', () => { const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, @@ -320,9 +265,7 @@ describe('KerberosAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer some-token' }, @@ -334,6 +277,74 @@ describe('KerberosAuthenticationProvider', () => { expect(request.headers.authorization).toBe('negotiate spnego'); }); + } + + describe('`login` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.login(request)); + }); + + describe('`authenticate` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.authenticate(request, null)); + + it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('fails if state is present, but backend does not support Kerberos.', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + }); + + it('does not start SPNEGO if request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); @@ -454,6 +465,29 @@ describe('KerberosAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); }); + + it('does not re-start SPNEGO if both access and refresh tokens from the state are expired.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' }; + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + }); }); describe('`logout` method', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index dbd0a438d71c91..c4bbe554a3da15 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -27,6 +27,15 @@ type ProviderState = TokenPair; */ const WWWAuthenticateHeaderName = 'WWW-Authenticate'; +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication. + return request.route.options.authRequired === true; +} + /** * Provider that supports Kerberos request authentication. */ @@ -36,6 +45,20 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { */ static readonly type = 'kerberos'; + /** + * Performs initial login request. + * @param request Request instance. + */ + public async login(request: KibanaRequest) { + this.logger.debug('Trying to perform a login.'); + + if (HTTPAuthorizationHeader.parseFromRequest(request)?.scheme.toLowerCase() === 'negotiate') { + return await this.authenticateWithNegotiateScheme(request); + } + + return await this.authenticateViaSPNEGO(request); + } + /** * Performs Kerberos request authentication. * @param request Request instance. @@ -66,7 +89,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can // start authentication mechanism negotiation, otherwise just return authentication result we have. - return authenticationResult.notHandled() + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.authenticateViaSPNEGO(request, state) : authenticationResult; } @@ -239,10 +262,10 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO. if (refreshedTokenPair === null) { - this.logger.debug( - 'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.' - ); - return this.authenticateViaSPNEGO(request, state); + this.logger.debug('Both access and refresh tokens are expired.'); + return canStartNewSession(request) + ? this.authenticateViaSPNEGO(request, state) + : AuthenticationResult.notHandled(); } try { diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 6a4ba1ccb41e22..14fe42aac7599d 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -18,7 +18,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { OIDCAuthenticationProvider, OIDCAuthenticationFlow, ProviderLoginAttempt } from './oidc'; +import { OIDCAuthenticationProvider, OIDCLogin, ProviderLoginAttempt } from './oidc'; function expectAuthenticateCall( mockClusterClient: jest.Mocked, @@ -36,7 +36,7 @@ describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'oidc' }); provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' }); }); @@ -72,7 +72,7 @@ describe('OIDCAuthenticationProvider', () => { await expect( provider.login(request, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: 'theissuer', loginHint: 'loginhint', }) @@ -84,7 +84,14 @@ describe('OIDCAuthenticationProvider', () => { '&state=statevalue' + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + '&login_hint=loginhint', - { state: { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/' } } + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/mock-server-basepath/', + realm: 'oidc1', + }, + } ) ); @@ -93,6 +100,50 @@ describe('OIDCAuthenticationProvider', () => { }); }); + it('redirects user initiated login attempts to the OpenId Connect Provider.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + state: 'statevalue', + nonce: 'noncevalue', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + }); + + await expect( + provider.login(request, { + type: OIDCLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/app/super-kibana', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/mock-server-basepath/app/super-kibana', + realm: 'oidc1', + }, + } + ) + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + body: { realm: 'oidc1' }, + }); + }); + function defineAuthenticationFlowTests( getMocks: () => { request: KibanaRequest; @@ -113,10 +164,15 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path', + realm: 'oidc1', }) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/some-path', { - state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' }, + state: { + accessToken: 'some-token', + refreshToken: 'some-refresh-token', + realm: 'oidc1', + }, }) ); @@ -137,7 +193,7 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { nextURL: '/base-path/some-path' }) + provider.login(request, attempt, { nextURL: '/base-path/some-path', realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -153,7 +209,11 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { state: 'statevalue', nonce: 'noncevalue' }) + provider.login(request, attempt, { + state: 'statevalue', + nonce: 'noncevalue', + realm: 'oidc1', + }) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -168,7 +228,7 @@ describe('OIDCAuthenticationProvider', () => { it('fails if session state is not presented.', async () => { const { request, attempt } = getMocks(); - await expect(provider.login(request, attempt, {})).resolves.toEqual( + await expect(provider.login(request, attempt, {} as any)).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( 'Response session state does not have corresponding state or nonce parameters or redirect URL.' @@ -192,6 +252,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path', + realm: 'oidc1', }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -207,6 +268,20 @@ 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( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".' + ) + ) + ); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); } describe('authorization code flow', () => { @@ -215,7 +290,7 @@ describe('OIDCAuthenticationProvider', () => { path: '/api/security/oidc/callback?code=somecodehere&state=somestatehere', }), attempt: { - flow: OIDCAuthenticationFlow.AuthorizationCode, + type: OIDCLogin.LoginWithAuthorizationCodeFlow, authenticationResponseURI: '/api/security/oidc/callback?code=somecodehere&state=somestatehere', }, @@ -230,7 +305,7 @@ describe('OIDCAuthenticationProvider', () => { '/api/security/oidc/callback?authenticationResponseURI=http://kibana/api/security/oidc/implicit#id_token=sometoken', }), attempt: { - flow: OIDCAuthenticationFlow.Implicit, + type: OIDCLogin.LoginWithImplicitFlow, authenticationResponseURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken', }, expectedRedirectURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken', @@ -246,6 +321,13 @@ describe('OIDCAuthenticationProvider', () => { ); }); + it('does not handle non-AJAX request that does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + }); + it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); @@ -272,6 +354,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/s/foo/some-path', + realm: 'oidc1', }, } ) @@ -310,7 +393,9 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'oidc' }, { authHeaders: { authorization } } @@ -344,6 +429,7 @@ describe('OIDCAuthenticationProvider', () => { provider.authenticate(request, { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'oidc1', }) ).resolves.toEqual(AuthenticationResult.notHandled()); @@ -364,9 +450,9 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); @@ -401,12 +487,18 @@ describe('OIDCAuthenticationProvider', () => { refreshToken: 'new-refresh-token', }); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'oidc' }, { authHeaders: { authorization: 'Bearer new-access-token' }, - state: { accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }, + state: { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + realm: 'oidc1', + }, } ) ); @@ -434,9 +526,9 @@ describe('OIDCAuthenticationProvider', () => { }; mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(refreshFailureReason as any) - ); + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual(AuthenticationResult.failed(refreshFailureReason as any)); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); @@ -470,7 +562,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.redirectTo( 'https://op-host/path/login?response_type=code' + '&scope=openid%20profile%20email' + @@ -482,6 +576,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/s/foo/some-path', + realm: 'oidc1', }, } ) @@ -515,7 +610,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) ); @@ -528,6 +625,44 @@ describe('OIDCAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + + 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 tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; + const authorization = `Bearer ${tokenPair.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + + 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( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".' + ) + ) + ); + }); }); describe('`logout` method', () => { @@ -538,11 +673,11 @@ describe('OIDCAuthenticationProvider', () => { DeauthenticationResult.notHandled() ); - await expect(provider.logout(request, {})).resolves.toEqual( + await expect(provider.logout(request, {} as any)).resolves.toEqual( DeauthenticationResult.notHandled() ); - await expect(provider.logout(request, { nonce: 'x' })).resolves.toEqual( + await expect(provider.logout(request, { nonce: 'x', realm: 'oidc1' })).resolves.toEqual( DeauthenticationResult.notHandled() ); @@ -557,9 +692,9 @@ describe('OIDCAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( - DeauthenticationResult.failed(failureReason) - ); + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { @@ -574,7 +709,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -593,7 +730,9 @@ describe('OIDCAuthenticationProvider', () => { redirect: 'http://fake-idp/logout&id_token_hint=thehint', }); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/logout&id_token_hint=thehint') ); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 21bce028b0d98d..f8e6ac0f9b5d08 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -19,23 +19,25 @@ import { } from './base'; /** - * Describes possible OpenID Connect authentication flows. + * Describes possible OpenID Connect login flows. */ -export enum OIDCAuthenticationFlow { - Implicit = 'implicit', - AuthorizationCode = 'authorization-code', - InitiatedBy3rdParty = 'initiated-by-3rd-party', +export enum OIDCLogin { + LoginInitiatedByUser = 'login-by-user', + LoginWithImplicitFlow = 'login-implicit', + LoginWithAuthorizationCodeFlow = 'login-authorization-code', + LoginInitiatedBy3rdParty = 'login-initiated-by-3rd-party', } /** * Describes the parameters that are required by the provider to process the initial login request. */ export type ProviderLoginAttempt = + | { type: OIDCLogin.LoginInitiatedByUser; redirectURLPath: string } | { - flow: OIDCAuthenticationFlow.Implicit | OIDCAuthenticationFlow.AuthorizationCode; + type: OIDCLogin.LoginWithImplicitFlow | OIDCLogin.LoginWithAuthorizationCodeFlow; authenticationResponseURI: string; } - | { flow: OIDCAuthenticationFlow.InitiatedBy3rdParty; iss: string; loginHint?: string }; + | { type: OIDCLogin.LoginInitiatedBy3rdParty; iss: string; loginHint?: string }; /** * The state supported by the provider (for the OpenID Connect handshake or established session). @@ -57,6 +59,21 @@ interface ProviderState extends Partial { * URL to redirect user to after successful OpenID Connect handshake. */ nextURL?: string; + + /** + * The name of the OpenID Connect realm that was used to establish session. + */ + realm: string; +} + +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the Identity Provider where they can authenticate. + return canRedirectRequest(request) && request.route.options.authRequired === true; } /** @@ -102,15 +119,38 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { ) { this.logger.debug('Trying to perform a login.'); - if (attempt.flow === OIDCAuthenticationFlow.InitiatedBy3rdParty) { - this.logger.debug('Authentication has been initiated by a Third Party.'); + // 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}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + + if (attempt.type === OIDCLogin.LoginInitiatedBy3rdParty) { + this.logger.debug('Login has been initiated by a Third Party.'); // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) const oidcPrepareParams = attempt.loginHint ? { iss: attempt.iss, login_hint: attempt.loginHint } : { iss: attempt.iss }; - return this.initiateOIDCAuthentication(request, oidcPrepareParams); - } else if (attempt.flow === OIDCAuthenticationFlow.Implicit) { + return this.initiateOIDCAuthentication( + request, + oidcPrepareParams, + `${this.options.basePath.serverBasePath}/` + ); + } + + if (attempt.type === OIDCLogin.LoginInitiatedByUser) { + this.logger.debug(`Login has been initiated by a user.`); + return this.initiateOIDCAuthentication( + request, + { realm: this.realm }, + attempt.redirectURLPath + ); + } + + if (attempt.type === OIDCLogin.LoginWithImplicitFlow) { this.logger.debug('OpenID Connect Implicit Authentication flow is used.'); } else { this.logger.debug('OpenID Connect Authorization Code Authentication flow is used.'); @@ -136,6 +176,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } + // 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}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + let authenticationResult = AuthenticationResult.notHandled(); if (state) { authenticationResult = await this.authenticateViaState(request, state); @@ -151,7 +199,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // initiate an OpenID Connect based authentication, otherwise just return the authentication result we have. // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) - return authenticationResult.notHandled() + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.initiateOIDCAuthentication(request, { realm: this.realm }) : authenticationResult; } @@ -211,7 +259,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via OpenID Connect.'); return AuthenticationResult.redirectTo(stateRedirectURL, { - state: { accessToken, refreshToken }, + state: { accessToken, refreshToken, realm: this.realm }, }); } catch (err) { this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); @@ -224,49 +272,30 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * * @param request Request instance. * @param params OIDC authentication parameters. - * @param [sessionState] Optional state object associated with the provider. + * @param [redirectURLPath] Optional URL user is supposed to be redirected to after successful + * login. If not provided the URL of the specified request is used. */ private async initiateOIDCAuthentication( request: KibanaRequest, params: { realm: string } | { iss: string; login_hint?: string }, - sessionState?: ProviderState | null + redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}` ) { this.logger.debug('Trying to initiate OpenID Connect authentication.'); - // If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication. - if (!canRedirectRequest(request)) { - this.logger.debug('OpenID Connect authentication can not be initiated by AJAX requests.'); - return AuthenticationResult.notHandled(); - } - try { - /* - * Possibly adds the state and nonce parameter that was saved in the user's session state to - * the params. There is no use case where we would have only a state parameter or only a nonce - * parameter in the session state so we only enrich the params object if we have both - */ - const oidcPrepareParams = - sessionState && sessionState.nonce && sessionState.state - ? { ...params, nonce: sessionState.nonce, state: sessionState.state } - : params; // 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/prepare`. - const { state, nonce, redirect } = await this.options.client.callAsInternalUser( - 'shield.oidcPrepare', - { - body: oidcPrepareParams, - } - ); + const { + state, + nonce, + redirect, + } = await this.options.client.callAsInternalUser('shield.oidcPrepare', { body: params }); this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.'); - // If this is a third party initiated login, redirect to the base path - const redirectAfterLogin = `${this.options.basePath.get(request)}${ - 'iss' in params ? '/' : request.url.path - }`; return AuthenticationResult.redirectTo( redirect, // Store the state and nonce parameters in the session state of the user - { state: { state, nonce, nextURL: redirectAfterLogin } } + { state: { state, nonce, nextURL: redirectURLPath, realm: this.realm } } ); } catch (err) { this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); @@ -334,7 +363,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // seems logical to do the same on Kibana side and `401` would force user to logout and do full SLO if it's // supported. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); @@ -356,7 +385,10 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { ...refreshedTokenPair, realm: this.realm }, + }); } catch (err) { this.logger.debug(`Failed to refresh elasticsearch access token: ${err.message}`); return AuthenticationResult.failed(err); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 044416032a4c3d..638bb5732f3c09 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -19,6 +19,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { ElasticsearchErrorHelpers, IClusterClient, + KibanaRequest, ScopeableRequest, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; @@ -78,53 +79,21 @@ describe('PKIAuthenticationProvider', () => { let provider: PKIAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'pki' }); provider = new PKIAuthenticationProvider(mockOptions); }); afterEach(() => jest.clearAllMocks()); - describe('`authenticate` method', () => { - it('does not handle authentication via `authorization` header.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - - it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - const state = { - accessToken: 'some-valid-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - + function defineCommonLoginAndAuthenticateTests( + operation: (request: KibanaRequest) => Promise + ) { it('does not handle requests without certificate.', async () => { const request = httpServerMock.createKibanaRequest({ socket: getMockSocket({ authorized: true }), }); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); @@ -135,58 +104,12 @@ describe('PKIAuthenticationProvider', () => { socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), }); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ authorized: true }), - }); - - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(new Error('Peer certificate is not available')) - ); - - expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); - }); - - it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => { - const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) - ); - - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - }); - }); - - it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), - }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) - ); - - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - }); - }); - it('gets an access token in exchange to peer certificate chain and stores it in the state.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ @@ -202,7 +125,7 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'pki' }, { @@ -244,7 +167,7 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'pki' }, { @@ -266,6 +189,156 @@ describe('PKIAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + + it('fails if could not retrieve user using the new access token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: {}, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); + + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer access-token' }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + } + + describe('`login` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.login(request)); + }); + + describe('`authenticate` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.authenticate(request, null)); + + it('does not handle authentication via `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + const state = { + accessToken: 'some-valid-token', + peerCertificateFingerprint256: '2A:7A:C2:DD', + }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not exchange peer certificate to access token if request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ + routeAuthRequired: false, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ authorized: true }), + }); + + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(new Error('Peer certificate is not available')) + ); + + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => { + const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + }); + + it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), + }); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + }); + it('invalidates existing token and gets a new one if fingerprints do not match.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ @@ -351,75 +424,45 @@ describe('PKIAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); - it('fails with 401 if existing token is expired, but certificate is not present.', async () => { - const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + it('does not exchange peer certificate to a new access token even if existing token is expired and request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ + routeAuthRequired: false, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + mockScopedClusterClient.callAsCurrentUser.mockRejectedValueOnce( ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) + AuthenticationResult.notHandled() ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(request.headers).not.toHaveProperty('authorization'); - }); - - it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ - authorized: true, - peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), - }), - }); - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { - body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, - }); - expect(request.headers).not.toHaveProperty('authorization'); }); - it('fails if could not retrieve user using the new access token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: {}, - socket: getMockSocket({ - authorized: true, - peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), - }), - }); + it('fails with 401 if existing token is expired, but certificate is not present.', async () => { + const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { - body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, - }); - - expectAuthenticateCall(mockOptions.client, { - headers: { authorization: 'Bearer access-token' }, - }); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index db022ff3557025..243e5415ad2c2c 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -28,6 +28,15 @@ interface ProviderState { peerCertificateFingerprint256: string; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication. + return request.route.options.authRequired === true; +} + /** * Provider that supports PKI request authentication. */ @@ -37,6 +46,15 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { */ static readonly type = 'pki'; + /** + * Performs initial login request. + * @param request Request instance. + */ + public async login(request: KibanaRequest) { + this.logger.debug('Trying to perform a login.'); + return await this.authenticateViaPeerCertificate(request); + } + /** * Performs PKI request authentication. * @param request Request instance. @@ -55,12 +73,12 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { authenticationResult = await this.authenticateViaState(request, state); // 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. - if ( + // a new one in exchange to peer certificate chain assuming request can initiate new session. + const invalidAccessToken = authenticationResult.notHandled() || (authenticationResult.failed() && - Tokens.isAccessTokenExpiredError(authenticationResult.error)) - ) { + Tokens.isAccessTokenExpiredError(authenticationResult.error)); + if (invalidAccessToken && canStartNewSession(request)) { authenticationResult = await this.authenticateViaPeerCertificate(request); // 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 @@ -68,12 +86,15 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { if (authenticationResult.notHandled()) { return AuthenticationResult.failed(Boom.unauthorized()); } + } else if (invalidAccessToken) { + return AuthenticationResult.notHandled(); } } // If we couldn't authenticate by means of all methods above, let's try to check if we can authenticate // request using its peer certificate chain, otherwise just return authentication result we have. - return authenticationResult.notHandled() + // We shouldn't establish new session if authentication isn't required for this particular request. + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.authenticateViaPeerCertificate(request) : authenticationResult; } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index b3f6ca4af13c45..c85a44d819adc2 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -18,7 +18,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { SAMLAuthenticationProvider, SAMLLoginStep } from './saml'; +import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; function expectAuthenticateCall( mockClusterClient: jest.Mocked, @@ -36,7 +36,7 @@ describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); provider = new SAMLAuthenticationProvider(mockOptions, { maxRedirectURLSize: new ByteSizeValue(100), }); @@ -66,13 +66,18 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', access_token: 'some-token', refresh_token: 'some-refresh-token', + realm: 'test-realm', }); await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-app' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#some-app', + realm: 'test-realm', + } ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { @@ -80,13 +85,14 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-token', refreshToken: 'some-refresh-token', + realm: 'test-realm', }, }) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml' } } + { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); }); @@ -96,8 +102,8 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - {} + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + {} as any ) ).resolves.toEqual( AuthenticationResult.failed( @@ -108,32 +114,61 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const request = httpServerMock.createKibanaRequest(); + const customMockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); + provider = new SAMLAuthenticationProvider(customMockOptions, { + realm: 'test-realm', + maxRedirectURLSize: new ByteSizeValue(100), + }); + + await expect( + provider.login( + request, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { 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".' + ) + ) + ); + + expect(customMockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + it('redirects to the default location if state contains empty redirect URL.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'user', access_token: 'user-initiated-login-token', refresh_token: 'user-initiated-login-refresh-token', + realm: 'test-realm', }); await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/', { state: { + username: 'user', accessToken: 'user-initiated-login-token', refreshToken: 'user-initiated-login-refresh-token', + realm: 'test-realm', }, }) ); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml' } } + { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); }); @@ -141,20 +176,24 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ + username: 'user', + realm: 'test-realm', access_token: 'idp-initiated-login-token', refresh_token: 'idp-initiated-login-refresh-token', }); await expect( provider.login(request, { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/', { state: { + username: 'user', accessToken: 'idp-initiated-login-token', refreshToken: 'idp-initiated-login-refresh-token', + realm: 'test-realm', }, }) ); @@ -174,14 +213,18 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path', + realm: 'test-realm', + } ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', - { body: { ids: ['some-request-id'], content: 'saml-response-xml' } } + { body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } } ); }); @@ -189,7 +232,7 @@ describe('SAMLAuthenticationProvider', () => { const request = httpServerMock.createKibanaRequest(); // Create new provider instance with additional `realm` option. - const customMockOptions = mockAuthenticationProviderOptions(); + const customMockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); provider = new SAMLAuthenticationProvider(customMockOptions, { realm: 'test-realm', maxRedirectURLSize: new ByteSizeValue(100), @@ -199,13 +242,18 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', access_token: 'some-token', refresh_token: 'some-refresh-token', + realm: 'test-realm', }); await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path', + realm: 'test-realm', + } ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path', { @@ -213,6 +261,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-token', refreshToken: 'some-refresh-token', + realm: 'test-realm', }, }) ); @@ -226,7 +275,7 @@ describe('SAMLAuthenticationProvider', () => { }); describe('IdP initiated login with existing session', () => { - it('fails if new SAML Response is rejected.', async () => { + it('fails if new SAML Response is rejected and provider is not configured with specific realm.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = 'Bearer some-valid-token'; @@ -241,11 +290,12 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', } ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -260,51 +310,43 @@ describe('SAMLAuthenticationProvider', () => { ); }); - it('fails if token received in exchange to new SAML Response is rejected.', async () => { - const request = httpServerMock.createKibanaRequest(); - - // Call to `authenticate` using existing valid session and also with token received in - // exchange to new SAML payload. - const user = mockAuthenticatedUser(); - const failureReason = new Error('Access token is invalid!'); - mockOptions.client.asScoped.mockImplementation(scopeableRequest => { - if (scopeableRequest?.headers.authorization === 'Bearer new-invalid-token') { - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - return mockScopedClusterClient; - } - - if (scopeableRequest?.headers.authorization === 'Bearer existing-valid-token') { - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); - return mockScopedClusterClient; - } + it('returns `notHandled` if new SAML Response is rejected and provider is configured with specific realm.', async () => { + const request = httpServerMock.createKibanaRequest({ headers: {} }); + const authorization = 'Bearer some-valid-token'; - throw new Error('Unexpected call'); + const customMockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); + provider = new SAMLAuthenticationProvider(customMockOptions, { + realm: 'test-realm', + maxRedirectURLSize: new ByteSizeValue(100), }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - username: 'user', - access_token: 'new-invalid-token', - refresh_token: 'new-invalid-token', - }); + const user = mockAuthenticatedUser(); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); + customMockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + const failureReason = new Error('SAML response is invalid!'); + customMockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, { username: 'user', - accessToken: 'existing-valid-token', - refreshToken: 'existing-valid-refresh-token', + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', } ) - ).resolves.toEqual(AuthenticationResult.failed(failureReason)); + ).resolves.toEqual(AuthenticationResult.notHandled()); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith( + expectAuthenticateCall(customMockOptions.client, { headers: { authorization } }); + + expect(customMockOptions.client.callAsInternalUser).toHaveBeenCalledWith( 'shield.samlAuthenticate', { - body: { ids: [], content: 'saml-response-xml' }, + body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' }, } ); }); @@ -315,6 +357,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const user = mockAuthenticatedUser(); @@ -334,7 +377,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -359,6 +402,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const user = { username: 'user', authentication_realm: { name: 'saml1' } }; @@ -370,6 +414,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token', + realm: 'test-realm', }); mockOptions.tokens.invalidate.mockResolvedValue(undefined); @@ -377,7 +422,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual( @@ -386,6 +431,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', }, }) ); @@ -410,6 +456,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; @@ -434,6 +481,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'new-user', access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token', + realm: 'test-realm', }); mockOptions.tokens.invalidate.mockResolvedValue(undefined); @@ -441,7 +489,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual( @@ -450,6 +498,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'new-user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', }, }) ); @@ -474,6 +523,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'saml1', }; const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } }; @@ -498,6 +548,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token', + realm: 'saml2', }); mockOptions.tokens.invalidate.mockResolvedValue(undefined); @@ -505,7 +556,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual( @@ -514,6 +565,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', + realm: 'saml2', }, }) ); @@ -534,56 +586,81 @@ describe('SAMLAuthenticationProvider', () => { }); describe('User initiated login with captured redirect URL', () => { - it('fails if state is not available', async () => { + it('fails if redirectURLPath is not available', async () => { const request = httpServerMock.createKibanaRequest(); await expect( provider.login(request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }) ).resolves.toEqual( AuthenticationResult.failed( - Boom.badRequest('State does not include URL path to redirect to.') + Boom.badRequest('State or login attempt does not include URL path to redirect to.') ) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('does not handle AJAX requests.', async () => { - const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + it('redirects requests to the IdP remembering combined redirect URL.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + realm: 'test-realm', + }); await expect( provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }, { redirectURL: '/test-base-path/some-path' } ) - ).resolves.toEqual(AuthenticationResult.notHandled()); + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#some-fragment', + realm: 'test-realm', + }, + } + ) + ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { + acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml', + }, + }); + + expect(mockOptions.logger.warn).not.toHaveBeenCalled(); }); - it('redirects non-AJAX requests to the IdP remembering combined redirect URL.', async () => { + it('redirects requests to the IdP remembering combined redirect URL if path is provided in attempt.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + realm: 'test-realm', }); await expect( provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: '/test-base-path/some-path', redirectURLFragment: '#some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + null ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -592,6 +669,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-fragment', + realm: 'test-realm', }, } ) @@ -612,13 +690,14 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + realm: 'test-realm', }); await expect( provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '../some-fragment', }, { redirectURL: '/test-base-path/some-path' } @@ -630,6 +709,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#../some-fragment', + realm: 'test-realm', }, } ) @@ -647,19 +727,20 @@ describe('SAMLAuthenticationProvider', () => { ); }); - it('redirects non-AJAX requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { + it('redirects requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + realm: 'test-realm', }); await expect( provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment'.repeat(10), }, { redirectURL: '/test-base-path/some-path' } @@ -671,6 +752,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path', + realm: 'test-realm', }, } ) @@ -701,16 +783,17 @@ describe('SAMLAuthenticationProvider', () => { customMockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + realm: 'test-realm', }); await expect( provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -719,6 +802,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-fragment', + realm: 'test-realm', }, } ) @@ -729,6 +813,43 @@ describe('SAMLAuthenticationProvider', () => { ).toHaveBeenCalledWith('shield.samlPrepare', { body: { realm: 'test-realm' } }); }); + it('redirects requests to the IdP remembering base path if redirect URL path in attempt is too large.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + realm: 'test-realm', + }); + + await expect( + provider.login( + request, + { + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: `/s/foo/${'some-path'.repeat(11)}`, + redirectURLFragment: '#some-fragment', + }, + null + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } + ) + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { + acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml', + }, + }); + + expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); + expect(mockOptions.logger.warn).toHaveBeenCalledWith( + 'Max URL path size should not exceed 100b but it was 106b. URL is not captured.' + ); + }); + it('fails if SAML request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -739,7 +860,7 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }, { redirectURL: '/test-base-path/some-path' } @@ -764,6 +885,13 @@ describe('SAMLAuthenticationProvider', () => { ); }); + it('does not handle non-AJAX request that does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + }); + it('does not handle authentication via `authorization` header.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { authorization: 'Bearer some-token' }, @@ -787,6 +915,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual(AuthenticationResult.notHandled()); @@ -797,14 +926,9 @@ describe('SAMLAuthenticationProvider', () => { it('redirects non-AJAX request that can not be authenticated to the "capture fragment" page.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); - mockOptions.client.callAsInternalUser.mockResolvedValue({ - id: 'some-request-id', - redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', - }); - await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/api/security/saml/capture-url-fragment', + '/mock-server-basepath/internal/security/saml/capture-url-fragment', { state: { redirectURL: '/base-path/s/foo/some-path' } } ) ); @@ -820,12 +944,13 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + realm: 'test-realm', }); await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '' } } + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } ) ); @@ -867,6 +992,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -892,6 +1018,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -916,6 +1043,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'valid-refresh-token', + realm: 'test-realm', }; mockOptions.client.asScoped.mockImplementation(scopeableRequest => { @@ -950,6 +1078,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'new-access-token', refreshToken: 'new-refresh-token', + realm: 'test-realm', }, } ) @@ -967,6 +1096,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -1000,6 +1130,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -1025,12 +1156,45 @@ describe('SAMLAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + 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 = { + username: 'user', + accessToken: 'expired-token', + refreshToken: 'expired-refresh-token', + realm: 'test-realm', + }; + const authorization = `Bearer ${state.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -1044,7 +1208,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/api/security/saml/capture-url-fragment', + '/mock-server-basepath/internal/security/saml/capture-url-fragment', { state: { redirectURL: '/base-path/s/foo/some-path' } } ) ); @@ -1066,12 +1230,14 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; mockOptions.client.callAsInternalUser.mockResolvedValue({ id: 'some-request-id', redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + realm: 'test-realm', }); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -1085,7 +1251,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '' } } + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } ) ); @@ -1105,6 +1271,25 @@ describe('SAMLAuthenticationProvider', () => { 'Max URL path size should not exceed 100b but it was 107b. URL is not captured.' ); }); + + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const request = httpServerMock.createKibanaRequest(); + + // Create new provider instance with additional `realm` option. + const customMockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); + provider = new SAMLAuthenticationProvider(customMockOptions, { + realm: 'test-realm', + maxRedirectURLSize: new ByteSizeValue(100), + }); + + await expect(provider.authenticate(request, { 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".' + ) + ) + ); + }); }); describe('`logout` method', () => { @@ -1131,7 +1316,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); @@ -1167,7 +1357,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -1186,7 +1381,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -1207,7 +1407,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -1228,6 +1433,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') @@ -1235,10 +1441,7 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', { - body: { - queryString: 'SAMLRequest=xxx%20yyy', - acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml', - }, + body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' }, }); }); @@ -1288,7 +1491,12 @@ describe('SAMLAuthenticationProvider', () => { }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); @@ -1308,6 +1516,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index ad3720d7e33355..aabe711e48760f 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -7,7 +7,6 @@ import Boom from 'boom'; import { ByteSizeValue } from '@kbn/config-schema'; import { KibanaRequest } from '../../../../../../src/core/server'; -import { AuthenticatedUser } from '../../../common/model'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; import { canRedirectRequest } from '../can_redirect_request'; @@ -33,37 +32,54 @@ interface ProviderState extends Partial { * initiate SAML handshake and where we should redirect user after successful authentication. */ redirectURL?: string; + + /** + * The name of the SAML realm that was used to establish session (may not be known during URL + * fragment capturing stage). + */ + realm?: string; } /** - * Describes possible SAML Login steps. + * Describes possible SAML Login flows. */ -export enum SAMLLoginStep { +export enum SAMLLogin { /** - * The final login step when IdP responds with SAML Response payload. + * The login flow when user initiates SAML handshake (SP Initiated Login). */ - SAMLResponseReceived = 'saml-response-received', + LoginInitiatedByUser = 'login-by-user', /** - * The login step when we've captured user URL fragment and ready to start SAML handshake. + * The login flow when IdP responds with SAML Response payload (last step of the SP Initiated + * Login or IdP initiated Login). */ - RedirectURLFragmentCaptured = 'redirect-url-fragment-captured', + LoginWithSAMLResponse = 'login-saml-response', } /** * Describes the parameters that are required by the provider to process the initial login request. */ type ProviderLoginAttempt = - | { step: SAMLLoginStep.RedirectURLFragmentCaptured; redirectURLFragment: string } - | { step: SAMLLoginStep.SAMLResponseReceived; samlResponse: string }; + | { type: SAMLLogin.LoginInitiatedByUser; redirectURLPath?: string; redirectURLFragment?: string } + | { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string }; /** * Checks whether request query includes SAML request from IdP. * @param query Parsed HTTP request query. */ -export function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { +function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { return query && query.SAMLRequest; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the Identity Provider where they can authenticate. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports SAML request authentication. */ @@ -111,31 +127,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { ) { this.logger.debug('Trying to perform a login.'); - if (attempt.step === SAMLLoginStep.RedirectURLFragmentCaptured) { - if (!state || !state.redirectURL) { - const message = 'State does not include URL path to redirect to.'; + // 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 && 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}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + + if (attempt.type === SAMLLogin.LoginInitiatedByUser) { + const redirectURLPath = attempt.redirectURLPath || state?.redirectURL; + if (!redirectURLPath) { + const message = 'State or login attempt does not include URL path to redirect to.'; this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } - let redirectURLFragment = attempt.redirectURLFragment; - if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { - this.logger.warn('Redirect URL fragment does not start with `#`.'); - redirectURLFragment = `#${redirectURLFragment}`; - } - - let redirectURL = `${state.redirectURL}${redirectURLFragment}`; - const redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` - ); - redirectURL = state.redirectURL; - } else { - this.logger.debug('Captured redirect URL.'); - } - - return this.authenticateViaHandshake(request, redirectURL); + return this.captureRedirectURL(request, redirectURLPath, attempt.redirectURLFragment); } const { samlResponse } = attempt; @@ -155,8 +163,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return await this.loginWithNewSAMLResponse( request, samlResponse, - (authenticationResult.state || state) as ProviderState, - authenticationResult.user as AuthenticatedUser + (authenticationResult.state || state) as ProviderState ); } @@ -185,6 +192,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } + // 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 && 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}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + let authenticationResult = AuthenticationResult.notHandled(); if (state) { authenticationResult = await this.authenticateViaState(request, state); @@ -198,7 +213,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // 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() && canRedirectRequest(request) + return authenticationResult.notHandled() && canStartNewSession(request) ? this.captureRedirectURL(request) : authenticationResult; } @@ -211,15 +226,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if ((!state || !state.accessToken) && !isSAMLRequestQuery(request.query)) { - this.logger.debug('There is neither access token nor SAML session to invalidate.'); + // 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 is enabled + // there is a special case when logout is initiated by the IdP or another SP, then IdP will + // request _every_ SP associated with the current user session to do the logout. So if Kibana, + // without an active session, receives such request it shouldn't redirect user to the home page, + // but rather redirect back to IdP with correct logout response and only Elasticsearch knows how + // to do that. + const isIdPInitiatedSLO = isSAMLRequestQuery(request.query); + if (!state?.accessToken && !isIdPInitiatedSLO) { + this.logger.debug('There is no SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } try { - const redirect = isSAMLRequestQuery(request.query) - ? await this.performIdPInitiatedSingleLogout(request) - : await this.performUserInitiatedSingleLogout(state!.accessToken!, state!.refreshToken!); + const redirect = isIdPInitiatedSLO + ? await this.performIdPInitiatedSingleLogout(request, this.realm || state?.realm) + : await this.performUserInitiatedSingleLogout(state?.accessToken!, state?.refreshToken!); // Having non-null `redirect` field within logout response means that IdP // supports SAML Single Logout and we should redirect user to the specified @@ -271,9 +294,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // If we have a `SAMLResponse` and state, but state doesn't contain all the necessary information, // then something unexpected happened and we should fail. - const { requestId: stateRequestId, redirectURL: stateRedirectURL } = state || { + const { + requestId: stateRequestId, + redirectURL: stateRedirectURL, + realm: stateRealm, + } = state || { requestId: '', redirectURL: '', + realm: '', }; if (state && !stateRequestId) { const message = 'SAML response state does not have corresponding request id.'; @@ -282,12 +310,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } // When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login. + const isIdPInitiatedLogin = !stateRequestId; this.logger.debug( - stateRequestId + !isIdPInitiatedLogin ? 'Login has been previously initiated by Kibana.' : 'Login has been initiated by Identity Provider.' ); + const providerRealm = this.realm || stateRealm; + 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/saml/authenticate`. @@ -295,22 +326,29 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { username, access_token: accessToken, refresh_token: refreshToken, + realm, } = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { body: { - ids: stateRequestId ? [stateRequestId] : [], + ids: !isIdPInitiatedLogin ? [stateRequestId] : [], content: samlResponse, - ...(this.realm ? { realm: this.realm } : {}), + ...(providerRealm ? { realm: providerRealm } : {}), }, }); this.logger.debug('Login has been performed with SAML response.'); return AuthenticationResult.redirectTo( stateRedirectURL || `${this.options.basePath.get(request)}/`, - { state: { username, accessToken, refreshToken } } + { state: { username, accessToken, refreshToken, realm } } ); } catch (err) { this.logger.debug(`Failed to log in with SAML response: ${err.message}`); - return AuthenticationResult.failed(err); + + // Since we don't know upfront what realm is targeted by the Identity Provider initiated login + // there is a chance that it failed because of realm mismatch and hence we should return + // `notHandled` and give other SAML providers a chance to properly handle it instead. + return isIdPInitiatedLogin && providerRealm + ? AuthenticationResult.notHandled() + : AuthenticationResult.failed(err); } } @@ -325,19 +363,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * @param request Request instance. * @param samlResponse SAMLResponse payload string. * @param existingState State existing user session is based on. - * @param user User returned for the existing session. */ private async loginWithNewSAMLResponse( request: KibanaRequest, samlResponse: string, - existingState: ProviderState, - user: AuthenticatedUser + existingState: ProviderState ) { this.logger.debug('Trying to log in with SAML response payload and existing valid session.'); // First let's try to authenticate via SAML Response payload. const payloadAuthenticationResult = await this.loginWithSAMLResponse(request, samlResponse); - if (payloadAuthenticationResult.failed()) { + if (payloadAuthenticationResult.failed() || payloadAuthenticationResult.notHandled()) { return payloadAuthenticationResult; } @@ -349,31 +385,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } const newState = payloadAuthenticationResult.state as ProviderState; - let differentUser = newState.username !== user.username; - - // If realm name is not configured we can't be sure that user is authenticated by the same realm - // even though user name is the same. - if (!differentUser && !this.realm) { - // Then use received tokens to retrieve user information. We need just username and authentication - // realm, once ES starts returning this info from `saml/authenticate` we can get rid of this call. - const newUserAuthenticationResult = await this.authenticateViaState(request, newState); - if (newUserAuthenticationResult.failed()) { - return newUserAuthenticationResult; - } - - if (newUserAuthenticationResult.user === undefined) { - // Should never happen, but if it does - it's a bug. - return AuthenticationResult.failed( - new Error( - 'Could not retrieve user information using tokens produced for the SAML payload.' - ) - ); - } - - differentUser = - newUserAuthenticationResult.user.authentication_realm.name !== - user.authentication_realm.name; - } // Now let's invalidate tokens from the existing session. try { @@ -387,7 +398,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.failed(err); } - if (differentUser) { + // At this stage both `existingState` and `newState` should already include `realm` and `username` + // returned from saml/authenticate. + if (newState.username !== existingState.username || newState.realm !== existingState.realm) { this.logger.debug( 'Login initiated by Identity Provider is for a different user than currently authenticated.' ); @@ -438,7 +451,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { */ private async authenticateViaRefreshToken( request: KibanaRequest, - { username, refreshToken }: ProviderState + { username, refreshToken, realm }: ProviderState ) { this.logger.debug('Trying to refresh access token.'); @@ -460,7 +473,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // There are two reasons for `400` and not `401`: Elasticsearch search responds with `400` so it seems logical // to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug( 'Both access and refresh tokens are expired. Capturing redirect URL and re-initiating SAML handshake.' ); @@ -484,7 +497,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via refreshed token.'); return AuthenticationResult.succeeded(user, { authHeaders, - state: { username, ...refreshedTokenPair }, + state: { username, realm: this.realm || realm, ...refreshedTokenPair }, }); } catch (err) { this.logger.debug( @@ -502,19 +515,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { private async authenticateViaHandshake(request: KibanaRequest, redirectURL: string) { this.logger.debug('Trying to initiate SAML handshake.'); - // If client can't handle redirect response, we shouldn't initiate SAML handshake. - if (!canRedirectRequest(request)) { - this.logger.debug('SAML handshake can not be initiated by AJAX requests.'); - return AuthenticationResult.notHandled(); - } - try { // Prefer realm name if it's specified, otherwise fallback to ACS. const preparePayload = this.realm ? { realm: this.realm } : { acs: this.getACS() }; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/prepare`. - const { id: requestId, redirect } = await this.options.client.callAsInternalUser( + const { id: requestId, redirect, realm } = await this.options.client.callAsInternalUser( 'shield.samlPrepare', { body: preparePayload, @@ -524,7 +531,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Redirecting to Identity Provider with SAML request.'); // Store request id in the state so that we can reuse it once we receive `SAMLResponse`. - return AuthenticationResult.redirectTo(redirect, { state: { requestId, redirectURL } }); + return AuthenticationResult.redirectTo(redirect, { + state: { requestId, redirectURL, realm }, + }); } catch (err) { this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`); return AuthenticationResult.failed(err); @@ -554,12 +563,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { * Calls `saml/invalidate` with the `SAMLRequest` query string parameter received from the Identity * Provider and redirects user back to the Identity Provider if needed. * @param request Request instance. + * @param realm Configured SAML realm name. */ - private async performIdPInitiatedSingleLogout(request: KibanaRequest) { + private async performIdPInitiatedSingleLogout(request: KibanaRequest, realm?: string) { this.logger.debug('Single logout has been initiated by the Identity Provider.'); // Prefer realm name if it's specified, otherwise fallback to ACS. - const invalidatePayload = this.realm ? { realm: this.realm } : { acs: this.getACS() }; + const invalidatePayload = realm ? { realm } : { acs: this.getACS() }; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/invalidate`. @@ -586,18 +596,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Redirects user to the client-side page that will grab URL fragment and redirect user back to Kibana - * to initiate SAML handshake. + * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. * @param request Request instance. + * @param [redirectURLPath] Optional URL path user is supposed to be redirected to after successful + * login. If not provided the URL path of the specified request is used. + * @param [redirectURLFragment] Optional URL fragment of the URL user is supposed to be redirected + * to after successful login. If not provided user will be redirected to the client-side page that + * will grab it and redirect user back to Kibana to initiate SAML handshake. */ - private captureRedirectURL(request: KibanaRequest) { - const basePath = this.options.basePath.get(request); - const redirectURL = `${basePath}${request.url.path}`; - + private captureRedirectURL( + request: KibanaRequest, + redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}`, + redirectURLFragment?: string + ) { // If the size of the path already exceeds the maximum allowed size of the URL to store in the // session there is no reason to try to capture URL fragment and we start handshake immediately. // In this case user will be redirected to the Kibana home/root after successful login. - const redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); + let redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURLPath)); if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { this.logger.warn( `Max URL path size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. URL is not captured.` @@ -605,9 +620,30 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return this.authenticateViaHandshake(request, ''); } - return AuthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/api/security/saml/capture-url-fragment`, - { state: { redirectURL } } - ); + // If URL fragment wasn't specified at all, let's try to capture it. + if (redirectURLFragment === undefined) { + return AuthenticationResult.redirectTo( + `${this.options.basePath.serverBasePath}/internal/security/saml/capture-url-fragment`, + { state: { redirectURL: redirectURLPath, realm: this.realm } } + ); + } + + if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { + this.logger.warn('Redirect URL fragment does not start with `#`.'); + redirectURLFragment = `#${redirectURLFragment}`; + } + + let redirectURL = `${redirectURLPath}${redirectURLFragment}`; + redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); + if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { + this.logger.warn( + `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` + ); + redirectURL = redirectURLPath; + } else { + this.logger.debug('Captured redirect URL.'); + } + + return this.authenticateViaHandshake(request, redirectURL); } } diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index e81d14e8bf9f35..7472adb30307c5 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -36,7 +36,7 @@ describe('TokenAuthenticationProvider', () => { let provider: TokenAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'token' }); provider = new TokenAuthenticationProvider(mockOptions); }); @@ -163,6 +163,12 @@ describe('TokenAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); }); + it('does not redirect requests that do not require authentication to the login page.', async () => { + await expect( + provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false })) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { await expect( provider.authenticate( @@ -346,6 +352,35 @@ describe('TokenAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + it('does not redirect non-AJAX requests that do not require authentication if token token cannot be refreshed', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: {}, + routeAuthRequired: false, + path: '/some-path', + }); + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + it('fails if new access token is rejected after successful refresh', async () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; @@ -386,15 +421,13 @@ describe('TokenAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `redirected` if state is not presented.', async () => { + it('returns `notHandled` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - await expect(provider.logout(request)).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') - ); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + DeauthenticationResult.notHandled() ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 91808c22c43003..abf4c293c4c531 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -26,6 +26,16 @@ interface ProviderLoginAttempt { */ type ProviderState = TokenPair; +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the login page where they can enter username and password. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports token-based request authentication. */ @@ -102,7 +112,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // finally, if authentication still can not be handled for this // request/state combination, redirect to the login page if appropriate - if (authenticationResult.notHandled() && canRedirectRequest(request)) { + if (authenticationResult.notHandled() && canStartNewSession(request)) { this.logger.debug('Redirecting request to Login page.'); authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request)); } @@ -118,16 +128,17 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (state) { - this.logger.debug('Token-based logout has been initiated by the user.'); - try { - await this.options.tokens.invalidate(state); - } catch (err) { - this.logger.debug(`Failed invalidating user's access token: ${err.message}`); - return DeauthenticationResult.failed(err); - } - } else { + if (!state) { 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.'); + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); } const queryString = request.url.search || `?msg=LOGGED_OUT`; @@ -190,7 +201,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and redirect user to the // login page to re-authenticate, or fail if redirect isn't possible. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug('Clearing session since both access and refresh tokens are expired.'); // Set state to `null` to let `Authenticator` know that we want to clear current session. diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 8532622906ffee..d0f497b2c924b8 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -6,9 +6,8 @@ jest.mock('crypto', () => ({ randomBytes: jest.fn() })); -import { first } from 'rxjs/operators'; -import { loggingServiceMock, coreMock } from '../../../../src/core/server/mocks'; -import { createConfig$, ConfigSchema } from './config'; +import { loggingServiceMock } from '../../../../src/core/server/mocks'; +import { createConfig, ConfigSchema } from './config'; describe('config schema', () => { it('generates proper defaults', () => { @@ -25,9 +24,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -55,9 +67,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -85,9 +110,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -254,6 +292,7 @@ describe('config schema', () => { "providers": Array [ "oidc", ], + "selector": Object {}, } `); }); @@ -287,6 +326,7 @@ describe('config schema', () => { "oidc", "basic", ], + "selector": Object {}, } `); }); @@ -318,6 +358,7 @@ describe('config schema', () => { "valueInBytes": 2048, }, }, + "selector": Object {}, } `); @@ -339,6 +380,7 @@ describe('config schema', () => { "valueInBytes": 2048, }, }, + "selector": Object {}, } `); @@ -364,6 +406,7 @@ describe('config schema', () => { }, "realm": "realm-1", }, + "selector": Object {}, } `); }); @@ -441,27 +484,476 @@ describe('config schema', () => { `); }); }); + + describe('authc.providers (extended format)', () => { + describe('`basic` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('does not allow custom description', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { basic: { basic1: { order: 0, description: 'Some description' } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.description]: \`basic\` provider does not support custom description." +`); + }); + + it('cannot be hidden from selector', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { basic: { basic1: { order: 0, showInSelector: false } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`." +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { order: 0 }, basic2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "basic": Object { + "basic1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`token` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { token: { token1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('does not allow custom description', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { token: { token1: { order: 0, description: 'Some description' } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.description]: \`token\` provider does not support custom description." +`); + }); + + it('cannot be hidden from selector', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { token: { token1: { order: 0, showInSelector: false } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`." +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { token: { token1: { order: 0 }, token2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token]: Only one \\"token\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { token: { token1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "token": Object { + "token1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`pki` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { order: 0 }, pki2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "pki": Object { + "pki1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`kerberos` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { kerberos: { kerberos1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { kerberos: { kerberos1: { order: 0 }, kerberos2: { order: 1 } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { kerberos: { kerberos1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "kerberos": Object { + "kerberos1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`oidc` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { oidc: { oidc1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('requires `realm`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { oidc: { oidc1: { order: 0 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]" +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + oidc: { oidc1: { order: 0, realm: 'oidc1' }, oidc2: { order: 1, realm: 'oidc2' } }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "oidc": Object { + "oidc1": Object { + "enabled": true, + "order": 0, + "realm": "oidc1", + "showInSelector": true, + }, + "oidc2": Object { + "enabled": true, + "order": 1, + "realm": "oidc2", + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`saml` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('requires `realm`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { order: 0 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]" +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + saml: { + saml1: { order: 0, realm: 'saml1' }, + saml2: { order: 1, realm: 'saml2', maxRedirectURLSize: '1kb' }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "saml": Object { + "saml1": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 0, + "realm": "saml1", + "showInSelector": true, + }, + "saml2": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 1024, + }, + "order": 1, + "realm": "saml2", + "showInSelector": true, + }, + }, + } + `); + }); + }); + + it('`name` should be unique across all provider types', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + basic: { provider1: { order: 0 } }, + saml: { + provider2: { order: 1, realm: 'saml1' }, + provider1: { order: 2, realm: 'saml2' }, + }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]" +`); + }); + + it('`order` should be unique across all provider types', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + basic: { provider1: { order: 0 } }, + saml: { + provider2: { order: 0, realm: 'saml1' }, + provider3: { order: 2, realm: 'saml2' }, + }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]" +`); + }); + + it('can be successfully validated with multiple providers ignoring uniqueness violations in disabled ones', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 }, basic2: { enabled: false, order: 1 } }, + saml: { + saml1: { order: 1, realm: 'saml1' }, + saml2: { order: 2, realm: 'saml2' }, + basic1: { order: 3, realm: 'saml3', enabled: false }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "basic": Object { + "basic1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + "basic2": Object { + "enabled": false, + "order": 1, + "showInSelector": true, + }, + }, + "saml": Object { + "basic1": Object { + "enabled": false, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 3, + "realm": "saml3", + "showInSelector": true, + }, + "saml1": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 1, + "realm": "saml1", + "showInSelector": true, + }, + "saml2": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 2, + "realm": "saml2", + "showInSelector": true, + }, + }, + } + `); + }); + }); }); -describe('createConfig$()', () => { - const mockAndCreateConfig = async (isTLSEnabled: boolean, value = {}, context?: any) => { - const contextMock = coreMock.createPluginInitializerContext( - // we must use validate to avoid errors in `createConfig$` - ConfigSchema.validate(value, context) - ); - return await createConfig$(contextMock, isTLSEnabled) - .pipe(first()) - .toPromise() - .then(config => ({ contextMock, config })); - }; +describe('createConfig()', () => { it('should log a warning and set xpack.security.encryptionKey if not set', async () => { const mockRandomBytes = jest.requireMock('crypto').randomBytes; mockRandomBytes.mockReturnValue('ab'.repeat(16)); - const { contextMock, config } = await mockAndCreateConfig(true, {}, { dist: true }); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}, { dist: true }), logger, { + isTLSEnabled: true, + }); expect(config.encryptionKey).toEqual('ab'.repeat(16)); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.security.encryptionKey in kibana.yml", @@ -471,10 +963,11 @@ describe('createConfig$()', () => { }); it('should log a warning if SSL is not configured', async () => { - const { contextMock, config } = await mockAndCreateConfig(false, {}); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}), logger, { isTLSEnabled: false }); expect(config.secureCookies).toEqual(false); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Session cookies will be transmitted over insecure connections. This is not recommended.", @@ -484,10 +977,13 @@ describe('createConfig$()', () => { }); it('should log a warning if SSL is not configured yet secure cookies are being used', async () => { - const { contextMock, config } = await mockAndCreateConfig(false, { secureCookies: true }); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({ secureCookies: true }), logger, { + isTLSEnabled: false, + }); expect(config.secureCookies).toEqual(true); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to function properly.", @@ -497,9 +993,210 @@ describe('createConfig$()', () => { }); it('should set xpack.security.secureCookies if SSL is configured', async () => { - const { contextMock, config } = await mockAndCreateConfig(true, {}); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}), logger, { isTLSEnabled: true }); expect(config.secureCookies).toEqual(true); - expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); + expect(loggingServiceMock.collect(logger).warn).toEqual([]); + }); + + it('transforms legacy `authc.providers` into new format', () => { + const logger = loggingServiceMock.create().get(); + + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: ['saml', 'basic'], + saml: { realm: 'saml-realm' }, + }, + }), + logger, + { isTLSEnabled: true } + ).authc + ).toMatchInlineSnapshot(` + Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "providers": Object { + "basic": Object { + "basic": Object { + "enabled": true, + "order": 1, + "showInSelector": true, + }, + }, + "saml": Object { + "saml": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 0, + "realm": "saml-realm", + "showInSelector": true, + }, + }, + }, + "selector": Object { + "enabled": false, + }, + "sortedProviders": Array [ + Object { + "name": "saml", + "options": Object { + "description": undefined, + "order": 0, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "basic", + "options": Object { + "description": undefined, + "order": 1, + "showInSelector": true, + }, + "type": "basic", + }, + ], + } + `); + }); + + it('does not automatically set `authc.selector.enabled` to `true` if legacy `authc.providers` format is used', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { providers: ['saml', 'basic'], saml: { realm: 'saml-realm' } }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(false); + + // But keep it as `true` if it's explicitly set. + expect( + createConfig( + ConfigSchema.validate({ + authc: { + selector: { enabled: true }, + providers: ['saml', 'basic'], + saml: { realm: 'saml-realm' }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(true); + }); + + it('does not automatically set `authc.selector.enabled` to `true` if less than 2 providers must be shown there', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 } }, + saml: { + saml1: { order: 1, realm: 'saml1', showInSelector: false }, + saml2: { enabled: false, order: 2, realm: 'saml2' }, + }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(false); + }); + + it('automatically set `authc.selector.enabled` to `true` if more than 1 provider must be shown there', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'saml1' }, saml2: { order: 2, realm: 'saml2' } }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(true); + }); + + it('correctly sorts providers based on the `order`', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 3 } }, + saml: { saml1: { order: 2, realm: 'saml1' }, saml2: { order: 1, realm: 'saml2' } }, + oidc: { oidc1: { order: 0, realm: 'oidc1' }, oidc2: { order: 4, realm: 'oidc2' } }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.sortedProviders + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "oidc1", + "options": Object { + "description": undefined, + "order": 0, + "showInSelector": true, + }, + "type": "oidc", + }, + Object { + "name": "saml2", + "options": Object { + "description": undefined, + "order": 1, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "saml1", + "options": Object { + "description": undefined, + "order": 2, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "basic1", + "options": Object { + "description": undefined, + "order": 3, + "showInSelector": true, + }, + "type": "basic", + }, + Object { + "name": "oidc2", + "options": Object { + "description": undefined, + "order": 4, + "showInSelector": true, + }, + "type": "oidc", + }, + ] + `); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 55f088629611fa..2d6feee6bc9d75 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -5,14 +5,10 @@ */ import crypto from 'crypto'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; import { schema, Type, TypeOf } from '@kbn/config-schema'; -import { PluginInitializerContext } from '../../../../src/core/server'; +import { Logger } from '../../../../src/core/server'; -export type ConfigType = ReturnType extends Observable - ? P - : ReturnType; +export type ConfigType = ReturnType; const providerOptionsSchema = (providerType: string, optionsSchema: Type) => schema.conditional( @@ -24,6 +20,114 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = schema.never() ); +type ProvidersCommonConfigType = Record< + 'enabled' | 'showInSelector' | 'order' | 'description', + Type +>; +function getCommonProviderSchemaProperties(overrides: Partial = {}) { + return { + enabled: schema.boolean({ defaultValue: true }), + showInSelector: schema.boolean({ defaultValue: true }), + order: schema.number({ min: 0 }), + description: schema.maybe(schema.string()), + ...overrides, + }; +} + +function getUniqueProviderSchema( + providerType: string, + overrides?: Partial +) { + 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.`; + } + }, + }) + ); +} + +type ProvidersConfigType = TypeOf; +const providersConfigSchema = schema.object( + { + basic: getUniqueProviderSchema('basic', { + description: schema.maybe( + schema.any({ + validate: () => '`basic` provider does not support custom description.', + }) + ), + showInSelector: schema.boolean({ + defaultValue: true, + validate: value => { + if (!value) { + return '`basic` provider only supports `true` in `showInSelector`.'; + } + }, + }), + }), + token: getUniqueProviderSchema('token', { + description: schema.maybe( + schema.any({ + validate: () => '`token` provider does not support custom description.', + }) + ), + showInSelector: schema.boolean({ + defaultValue: true, + validate: value => { + if (!value) { + return '`token` provider only supports `true` in `showInSelector`.'; + } + }, + }), + }), + kerberos: getUniqueProviderSchema('kerberos'), + pki: getUniqueProviderSchema('pki'), + saml: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ + ...getCommonProviderSchemaProperties(), + realm: schema.string(), + maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + }) + ) + ), + oidc: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string() }) + ) + ), + }, + { + validate(config) { + const checks = { sameOrder: new Map(), sameName: new Map() }; + for (const [providerType, providerGroup] of Object.entries(config)) { + for (const [providerName, { enabled, order }] of Object.entries(providerGroup ?? {})) { + if (!enabled) { + continue; + } + + const providerPath = `xpack.security.authc.providers.${providerType}.${providerName}`; + const providerWithSameOrderPath = checks.sameOrder.get(order); + if (providerWithSameOrderPath) { + return `Found multiple providers configured with the same order "${order}": [${providerWithSameOrderPath}, ${providerPath}]`; + } + checks.sameOrder.set(order, providerPath); + + const providerWithSameName = checks.sameName.get(providerName); + if (providerWithSameName) { + return `Found multiple providers configured with the same name "${providerName}": [${providerWithSameName}, ${providerPath}]`; + } + checks.sameName.set(providerName, providerPath); + } + } + }, + } +); + export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), loginAssistanceMessage: schema.string({ defaultValue: '' }), @@ -45,7 +149,17 @@ export const ConfigSchema = schema.object({ port: schema.maybe(schema.number({ min: 0, max: 65535 })), }), authc: schema.object({ - providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), + selector: schema.object({ enabled: schema.maybe(schema.boolean()) }), + providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], { + defaultValue: { + basic: { basic: { enabled: true, showInSelector: true, order: 0, description: undefined } }, + token: undefined, + saml: undefined, + oidc: undefined, + pki: undefined, + kerberos: undefined, + }, + }), oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), saml: providerOptionsSchema( 'saml', @@ -65,42 +179,96 @@ export const ConfigSchema = schema.object({ }), }); -export function createConfig$(context: PluginInitializerContext, isTLSEnabled: boolean) { - return context.config.create>().pipe( - map(config => { - const logger = context.logger.get('config'); +export function createConfig( + config: TypeOf, + logger: Logger, + { isTLSEnabled }: { isTLSEnabled: boolean } +) { + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + 'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.security.encryptionKey in kibana.yml' + ); - let encryptionKey = config.encryptionKey; - if (encryptionKey === undefined) { - logger.warn( - 'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + - 'restart, please set xpack.security.encryptionKey in kibana.yml' - ); + encryptionKey = crypto.randomBytes(16).toString('hex'); + } - encryptionKey = crypto.randomBytes(16).toString('hex'); - } + let secureCookies = config.secureCookies; + if (!isTLSEnabled) { + if (secureCookies) { + logger.warn( + 'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + + 'function properly.' + ); + } else { + logger.warn( + 'Session cookies will be transmitted over insecure connections. This is not recommended.' + ); + } + } else if (!secureCookies) { + secureCookies = true; + } - let secureCookies = config.secureCookies; - if (!isTLSEnabled) { - if (secureCookies) { - logger.warn( - 'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + - 'function properly.' - ); - } else { - logger.warn( - 'Session cookies will be transmitted over insecure connections. This is not recommended.' - ); - } - } else if (!secureCookies) { - secureCookies = true; + const isUsingLegacyProvidersFormat = Array.isArray(config.authc.providers); + const providers = (isUsingLegacyProvidersFormat + ? [...new Set(config.authc.providers as Array)].reduce( + (legacyProviders, providerType, order) => { + legacyProviders[providerType] = { + [providerType]: + providerType === 'saml' || providerType === 'oidc' + ? { enabled: true, showInSelector: true, order, ...config.authc[providerType] } + : { enabled: true, showInSelector: true, order }, + }; + return legacyProviders; + }, + {} as Record + ) + : config.authc.providers) as ProvidersConfigType; + + // Remove disabled providers and sort the rest. + const sortedProviders: Array<{ + type: keyof ProvidersConfigType; + name: string; + options: { order: number; showInSelector: boolean; description?: string }; + }> = []; + for (const [type, providerGroup] of Object.entries(providers)) { + for (const [name, { enabled, showInSelector, order, description }] of Object.entries( + providerGroup ?? {} + )) { + if (!enabled) { + delete providerGroup![name]; + } else { + sortedProviders.push({ + type: type as any, + name, + options: { order, showInSelector, description }, + }); } + } + } - return { - ...config, - encryptionKey, - secureCookies, - }; - }) + sortedProviders.sort(({ options: { order: orderA } }, { options: { order: orderB } }) => + orderA < orderB ? -1 : orderA > orderB ? 1 : 0 ); + + // We enable Login Selector by default if a) it's not explicitly disabled, b) new config + // format of providers is used and c) we have more than one provider enabled. + const isLoginSelectorEnabled = + typeof config.authc.selector.enabled === 'boolean' + ? config.authc.selector.enabled + : !isUsingLegacyProvidersFormat && + sortedProviders.filter(provider => provider.options.showInSelector).length > 1; + + return { + ...config, + authc: { + selector: { ...config.authc.selector, enabled: isLoginSelectorEnabled }, + providers, + sortedProviders: Object.freeze(sortedProviders), + http: config.authc.http, + }, + encryptionKey, + secureCookies, + }; } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index ca204e454df84f..01128d1aa7b0fe 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -23,6 +23,8 @@ export { CreateAPIKeyResult, InvalidateAPIKeyParams, InvalidateAPIKeyResult, + SAMLLogin, + OIDCLogin, } from './authentication'; export { SecurityPluginSetup }; export { AuthenticatedUser } from '../common/model'; @@ -33,27 +35,38 @@ export const config: PluginConfigDescriptor> = { rename('sessionTimeout', 'session.idleTimeout'), rename('authProviders', 'authc.providers'), unused('authorization.legacyFallback.enabled'), + // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. (settings, fromPath, log) => { - const securityConfig = settings?.xpack?.security; - const hasProvider = (provider: string) => - securityConfig?.authc?.providers?.includes(provider) ?? false; - - if (hasProvider('basic') && hasProvider('token')) { + if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { log( - 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' + 'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.' ); } - if (hasProvider('saml') && !securityConfig?.authc?.saml?.realm) { + return settings; + }, + (settings, fromPath, log) => { + const hasProviderType = (providerType: string) => { + const providers = settings?.xpack?.security?.authc?.providers; + if (Array.isArray(providers)) { + return providers.includes(providerType); + } + + return Object.values(providers?.[providerType] || {}).some( + provider => (provider as { enabled: boolean | undefined })?.enabled !== false + ); + }; + + if (hasProviderType('basic') && hasProviderType('token')) { log( - 'Config key "xpack.security.authc.saml.realm" will become mandatory when using the SAML authentication provider in the next major version.' + 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' ); } - if (securityConfig?.public) { + if (settings?.xpack?.security?.public) { log( 'Config key "xpack.security.public" is deprecated and will be removed in the next major version. ' + - 'Specify "xpack.security.authc.saml.realm" instead.' + 'Specify SAML authentication provider and its realm in "xpack.security.authc.providers.saml.*" instead.' ); } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a011f7e7be11e0..a23c826b32fbd6 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -26,6 +26,7 @@ describe('Security Plugin', () => { lifespan: null, }, authc: { + selector: { enabled: false }, providers: ['saml', 'token'], saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'] }, @@ -49,9 +50,6 @@ describe('Security Plugin', () => { await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(` Object { "__legacyCompat": Object { - "config": Object { - "secureCookies": true, - }, "license": Object { "features$": Observable { "_isScalar": false, @@ -78,7 +76,7 @@ describe('Security Plugin', () => { "invalidateAPIKey": [Function], "invalidateAPIKeyAsInternalUser": [Function], "isAuthenticated": [Function], - "isProviderEnabled": [Function], + "isProviderTypeEnabled": [Function], "login": [Function], "logout": [Function], }, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 13300ee55eba05..032d231fe798fc 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -5,13 +5,13 @@ */ import { combineLatest } from 'rxjs'; -import { first } from 'rxjs/operators'; +import { first, map } from 'rxjs/operators'; +import { TypeOf } from '@kbn/config-schema'; import { ICustomClusterClient, CoreSetup, Logger, PluginInitializerContext, - RecursiveReadonly, } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/utils'; import { SpacesPluginSetup } from '../../spaces/server'; @@ -20,7 +20,7 @@ import { LicensingPluginSetup } from '../../licensing/server'; import { Authentication, setupAuthentication } from './authentication'; import { Authorization, setupAuthorization } from './authorization'; -import { createConfig$ } from './config'; +import { ConfigSchema, createConfig } from './config'; import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; @@ -65,7 +65,6 @@ export interface SecurityPluginSetup { registerLegacyAPI: (legacyAPI: LegacyAPI) => void; registerPrivilegesWithCluster: () => void; license: SecurityLicense; - config: RecursiveReadonly<{ secureCookies: boolean }>; }; } @@ -106,7 +105,13 @@ export class Plugin { public async setup(core: CoreSetup, { features, licensing }: PluginSetupDependencies) { const [config, legacyConfig] = await combineLatest([ - createConfig$(this.initializerContext, core.http.isTlsEnabled), + this.initializerContext.config.create>().pipe( + map(rawConfig => + createConfig(rawConfig, this.initializerContext.logger.get('config'), { + isTLSEnabled: core.http.isTlsEnabled, + }) + ) + ), this.initializerContext.config.legacy.globalConfig$, ]) .pipe(first()) @@ -183,11 +188,6 @@ export class Plugin { registerPrivilegesWithCluster: async () => await authz.registerPrivilegesWithCluster(), license, - - // We should stop exposing this config as soon as only new platform plugin consumes it. - // This is only currently required because we use legacy code to inject this as metadata - // for consumption by public code in the new platform. - config: { secureCookies: config.secureCookies }, }, }); } diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts index cd3b8716715510..3c114978f26d2c 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.test.ts @@ -29,7 +29,7 @@ describe('Basic authentication routes', () => { router = routeParamsMock.router; authc = routeParamsMock.authc; - authc.isProviderEnabled.mockImplementation(provider => provider === 'basic'); + authc.isProviderTypeEnabled.mockImplementation(provider => provider === 'basic'); mockContext = ({ licensing: { @@ -108,7 +108,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(500); expect(response.payload).toEqual(unhandledException); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -122,7 +122,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(401); expect(response.payload).toEqual(failureReason); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -135,7 +135,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(401); expect(response.payload).toEqual('Unauthorized'); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -149,14 +149,14 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(204); expect(response.payload).toBeUndefined(); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); it('prefers `token` authentication provider if it is enabled', async () => { authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - authc.isProviderEnabled.mockImplementation( + authc.isProviderTypeEnabled.mockImplementation( provider => provider === 'token' || provider === 'basic' ); @@ -165,7 +165,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(204); expect(response.payload).toBeUndefined(); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'token', + provider: { type: 'token' }, value: { username: 'user', password: 'password' }, }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/basic.ts b/x-pack/plugins/security/server/routes/authentication/basic.ts index db36e45fc07e89..ccc6a8df24d6e2 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.ts @@ -26,9 +26,10 @@ export function defineBasicRoutes({ router, authc, config }: RouteDefinitionPara }, createLicensedRouteHandler(async (context, request, response) => { // We should prefer `token` over `basic` if possible. - const loginAttempt = authc.isProviderEnabled('token') - ? { provider: 'token', value: request.body } - : { provider: 'basic', value: request.body }; + const loginAttempt = { + provider: { type: authc.isProviderTypeEnabled('token') ? 'token' : 'basic' }, + value: request.body, + }; try { const authenticationResult = await authc.login(request, loginAttempt); diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts index b611ffffee935c..e2f9593bc09eea 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts @@ -13,7 +13,13 @@ import { RouteConfig, } from '../../../../../../src/core/server'; import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; -import { Authentication, DeauthenticationResult } from '../../authentication'; +import { + Authentication, + AuthenticationResult, + DeauthenticationResult, + OIDCLogin, + SAMLLogin, +} from '../../authentication'; import { defineCommonRoutes } from './common'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; @@ -172,4 +178,260 @@ describe('Common authentication routes', () => { expect(authc.getCurrentUser).toHaveBeenCalledWith(mockRequest); }); }); + + describe('login_with', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [acsRouteConfig, acsRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/login_with' + )!; + + routeConfig = acsRouteConfig; + routeHandler = acsRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.validate).toEqual({ + body: expect.any(Type), + query: undefined, + params: undefined, + }); + + const bodyValidator = (routeConfig.validate as any).body as Type; + expect( + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + }) + ).toEqual({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + }); + + expect( + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '', + }) + ).toEqual({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '', + }); + + expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ providerType: 'saml' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerName]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ providerType: 'saml', providerName: 'saml1' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[currentURL]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + UnknownArg: 'arg', + }) + ).toThrowErrorMatchingInlineSnapshot(`"[UnknownArg]: definition for this key is missing"`); + }); + + it('returns 500 if login throws unhandled exception.', async () => { + const unhandledException = new Error('Something went wrong.'); + authc.login.mockRejectedValue(unhandledException); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + payload: 'Internal Error', + options: {}, + }); + }); + + it('returns 401 if login fails.', async () => { + const failureReason = new Error('Something went wrong.'); + authc.login.mockResolvedValue( + AuthenticationResult.failed(failureReason, { + authResponseHeaders: { 'WWW-Something': 'something' }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 401, + payload: failureReason, + options: { body: failureReason, headers: { 'WWW-Something': 'something' } }, + }); + }); + + it('returns 401 if login is not handled.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.notHandled()); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 401, + payload: 'Unauthorized', + options: {}, + }); + }); + + it('returns redirect location from authentication result if any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + }); + + it('returns location extracted from `next` parameter if authentication result does not specify any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: '/mock-server-basepath/some-url#/app/nav' }, + options: { body: { location: '/mock-server-basepath/some-url#/app/nav' } }, + }); + }); + + it('returns base path if location cannot be extracted from `currentURL` parameter and authentication result does not specify any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + + const invalidCurrentURLs = [ + 'https://kibana.com/?next=https://evil.com/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=https://kibana.com:9000/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=kibana.com/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=//mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=../mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=/some-url#/app/nav', + '', + ]; + + for (const currentURL of invalidCurrentURLs) { + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: '/mock-server-basepath/' }, + options: { body: { location: '/mock-server-basepath/' } }, + }); + } + }); + + it('correctly performs SAML login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'saml1' }, + value: { + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/some-url', + redirectURLFragment: '#/app/nav', + }, + }); + }); + + it('correctly performs OIDC login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'oidc', + providerName: 'oidc1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'oidc1' }, + value: { + type: OIDCLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/some-url', + }, + }); + }); + + it('correctly performs generic login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'some-type', + providerName: 'some-name', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'some-name' }, + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index 19d197b63f540a..abab67c9cd1d28 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -5,9 +5,14 @@ */ import { schema } from '@kbn/config-schema'; -import { canRedirectRequest } from '../../authentication'; +import { parseNext } from '../../../common/parse_next'; +import { canRedirectRequest, OIDCLogin, SAMLLogin } from '../../authentication'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { + OIDCAuthenticationProvider, + SAMLAuthenticationProvider, +} from '../../authentication/providers'; import { RouteDefinitionParams } from '..'; /** @@ -71,4 +76,63 @@ export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDef }) ); } + + function getLoginAttemptForProviderType(providerType: string, redirectURL: string) { + const [redirectURLPath] = redirectURL.split('#'); + const redirectURLFragment = + redirectURL.length > redirectURLPath.length + ? redirectURL.substring(redirectURLPath.length) + : ''; + + if (providerType === SAMLAuthenticationProvider.type) { + return { type: SAMLLogin.LoginInitiatedByUser, redirectURLPath, redirectURLFragment }; + } + + if (providerType === OIDCAuthenticationProvider.type) { + return { type: OIDCLogin.LoginInitiatedByUser, redirectURLPath }; + } + + return undefined; + } + + router.post( + { + path: '/internal/security/login_with', + validate: { + body: schema.object({ + providerType: schema.string(), + providerName: schema.string(), + currentURL: schema.string(), + }), + }, + options: { authRequired: false }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { providerType, providerName, currentURL } = request.body; + logger.info(`Logging in with provider "${providerName}" (${providerType})`); + + const redirectURL = parseNext(currentURL, basePath.serverBasePath); + try { + const authenticationResult = await authc.login(request, { + provider: { name: providerName }, + value: getLoginAttemptForProviderType(providerType, redirectURL), + }); + + if (authenticationResult.redirected() || authenticationResult.succeeded()) { + return response.ok({ + body: { location: authenticationResult.redirectURL || redirectURL }, + headers: authenticationResult.authResponseHeaders, + }); + } + + return response.unauthorized({ + body: authenticationResult.error, + headers: authenticationResult.authResponseHeaders, + }); + } catch (err) { + logger.error(err); + return response.internalError(); + } + }) + ); } diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index a774edfb4ab2c0..f3082b089faf5c 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -27,15 +27,15 @@ export function defineAuthenticationRoutes(params: RouteDefinitionParams) { defineSessionRoutes(params); defineCommonRoutes(params); - if (params.authc.isProviderEnabled('basic') || params.authc.isProviderEnabled('token')) { + if (params.authc.isProviderTypeEnabled('basic') || params.authc.isProviderTypeEnabled('token')) { defineBasicRoutes(params); } - if (params.authc.isProviderEnabled('saml')) { + if (params.authc.isProviderTypeEnabled('saml')) { defineSAMLRoutes(params); } - if (params.authc.isProviderEnabled('oidc')) { + if (params.authc.isProviderTypeEnabled('oidc')) { defineOIDCRoutes(params); } } diff --git a/x-pack/plugins/security/server/routes/authentication/oidc.ts b/x-pack/plugins/security/server/routes/authentication/oidc.ts index 96c36af20e9824..d325a453af9d1b 100644 --- a/x-pack/plugins/security/server/routes/authentication/oidc.ts +++ b/x-pack/plugins/security/server/routes/authentication/oidc.ts @@ -7,11 +7,14 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, KibanaResponseFactory } from '../../../../../../src/core/server'; -import { OIDCAuthenticationFlow } from '../../authentication'; +import { OIDCLogin } from '../../authentication'; import { createCustomResourceResponse } from '.'; import { createLicensedRouteHandler } from '../licensed_route_handler'; import { wrapIntoCustomErrorResponse } from '../../errors'; -import { ProviderLoginAttempt } from '../../authentication/providers/oidc'; +import { + OIDCAuthenticationProvider, + ProviderLoginAttempt, +} from '../../authentication/providers/oidc'; import { RouteDefinitionParams } from '..'; /** @@ -118,7 +121,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route let loginAttempt: ProviderLoginAttempt | undefined; if (request.query.authenticationResponseURI) { loginAttempt = { - flow: OIDCAuthenticationFlow.Implicit, + type: OIDCLogin.LoginWithImplicitFlow, authenticationResponseURI: request.query.authenticationResponseURI, }; } else if (request.query.code || request.query.error) { @@ -133,7 +136,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // failed) authentication from an OpenID Connect Provider during authorization code authentication flow. // See more details at https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth. loginAttempt = { - flow: OIDCAuthenticationFlow.AuthorizationCode, + type: OIDCLogin.LoginWithAuthorizationCodeFlow, // We pass the path only as we can't be sure of the full URL and Elasticsearch doesn't need it anyway. authenticationResponseURI: request.url.path!, }; @@ -145,7 +148,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication. // See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin loginAttempt = { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.query.iss, loginHint: request.query.login_hint, }; @@ -181,7 +184,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route { unknowns: 'allow' } ), }, - options: { authRequired: false }, + options: { authRequired: false, xsrfRequired: false }, }, createLicensedRouteHandler(async (context, request, response) => { const serverBasePath = basePath.serverBasePath; @@ -193,7 +196,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route } return performOIDCLogin(request, response, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.body.iss, loginHint: request.body.login_hint, }); @@ -224,7 +227,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route }, createLicensedRouteHandler(async (context, request, response) => { return performOIDCLogin(request, response, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.query.iss, loginHint: request.query.login_hint, }); @@ -240,7 +243,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // We handle the fact that the user might get redirected to Kibana while already having a session // Return an error notifying the user they are already logged in. const authenticationResult = await authc.login(request, { - provider: 'oidc', + provider: { type: OIDCAuthenticationProvider.type }, value: loginAttempt, }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts index ce594e20e39cc0..64263d351941cb 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -5,7 +5,7 @@ */ import { Type } from '@kbn/config-schema'; -import { Authentication, AuthenticationResult, SAMLLoginStep } from '../../authentication'; +import { Authentication, AuthenticationResult, SAMLLogin } from '../../authentication'; import { defineSAMLRoutes } from './saml'; import { IRouter, RequestHandler, RouteConfig } from '../../../../../../src/core/server'; @@ -46,7 +46,7 @@ describe('SAML authentication routes', () => { }); it('correctly defines route.', () => { - expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.options).toEqual({ authRequired: false, xsrfRequired: false }); expect(routeConfig.validate).toEqual({ body: expect.any(Type), query: undefined, @@ -93,9 +93,9 @@ describe('SAML authentication routes', () => { ); expect(authc.login).toHaveBeenCalledWith(request, { - provider: 'saml', + provider: { type: 'saml' }, value: { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response', }, }); @@ -172,9 +172,9 @@ describe('SAML authentication routes', () => { ); expect(authc.login).toHaveBeenCalledWith(request, { - provider: 'saml', + provider: { type: 'saml' }, value: { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response', }, }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index b5b9f27c2713d5..3ea92c0d8c4c16 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -5,7 +5,8 @@ */ import { schema } from '@kbn/config-schema'; -import { SAMLLoginStep } from '../../authentication'; +import { SAMLLogin } from '../../authentication'; +import { SAMLAuthenticationProvider } from '../../authentication/providers'; import { createCustomResourceResponse } from '.'; import { RouteDefinitionParams } from '..'; @@ -15,7 +16,7 @@ import { RouteDefinitionParams } from '..'; export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: RouteDefinitionParams) { router.get( { - path: '/api/security/saml/capture-url-fragment', + path: '/internal/security/saml/capture-url-fragment', validate: false, options: { authRequired: false }, }, @@ -27,7 +28,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route Kibana SAML Login - + `, 'text/html', csp.header @@ -38,7 +39,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route router.get( { - path: '/api/security/saml/capture-url-fragment.js', + path: '/internal/security/saml/capture-url-fragment.js', validate: false, options: { authRequired: false }, }, @@ -47,7 +48,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route createCustomResourceResponse( ` window.location.replace( - '${basePath.serverBasePath}/api/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) + '${basePath.serverBasePath}/internal/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) ); `, 'text/javascript', @@ -59,7 +60,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route router.get( { - path: '/api/security/saml/start', + path: '/internal/security/saml/start', validate: { query: schema.object({ redirectURLFragment: schema.string() }), }, @@ -68,9 +69,9 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route async (context, request, response) => { try { const authenticationResult = await authc.login(request, { - provider: 'saml', + provider: { type: SAMLAuthenticationProvider.type }, value: { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: request.query.redirectURLFragment, }, }); @@ -100,23 +101,23 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route RelayState: schema.maybe(schema.string()), }), }, - options: { authRequired: false }, + options: { authRequired: false, xsrfRequired: false }, }, async (context, request, response) => { - try { - if (path === '/api/security/v1/saml') { - const serverBasePath = basePath.serverBasePath; - logger.warn( - `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/saml/callback" URL instead.`, - { tags: ['deprecation'] } - ); - } + if (path === '/api/security/v1/saml') { + const serverBasePath = basePath.serverBasePath; + logger.warn( + `The "${serverBasePath}${path}" URL is deprecated and will stop working in the next major version, please use "${serverBasePath}/api/security/saml/callback" URL instead.`, + { tags: ['deprecation'] } + ); + } - // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. + try { + // When authenticating using SAML we _expect_ to redirect to the Kibana target location. const authenticationResult = await authc.login(request, { - provider: 'saml', + provider: { type: SAMLAuthenticationProvider.type }, value: { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: request.body.SAMLResponse, }, }); diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 0821ed8b96af98..aaefdad6c221aa 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -11,17 +11,19 @@ import { } from '../../../../../src/core/server/mocks'; import { authenticationMock } from '../authentication/index.mock'; import { authorizationMock } from '../authorization/index.mock'; -import { ConfigSchema } from '../config'; +import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; export const routeDefinitionParamsMock = { - create: () => ({ + create: (config: Record = {}) => ({ router: httpServiceMock.createRouter(), basePath: httpServiceMock.createBasePath(), csp: httpServiceMock.createSetupContract().csp, logger: loggingServiceMock.create().get(), clusterClient: elasticsearchServiceMock.createClusterClient(), - config: { ...ConfigSchema.validate({}), encryptionKey: 'some-enc-key' }, + config: createConfig(ConfigSchema.validate(config), loggingServiceMock.create().get(), { + isTLSEnabled: false, + }), authc: authenticationMock.create(), authz: authorizationMock.create(), license: licenseMock.create(), diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index c2db34dc3c33cd..bac40202ee6ef3 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -188,7 +188,7 @@ describe('Change password', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { name: 'basic1' }, value: { username, password: 'new-password' }, }); }); @@ -196,7 +196,7 @@ describe('Change password', () => { it('successfully changes own password if provided old password is correct for non-basic provider.', async () => { const mockUser = mockAuthenticatedUser({ username: 'user', - authentication_provider: 'token', + authentication_provider: 'token1', }); authc.getCurrentUser.mockReturnValue(mockUser); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockUser)); @@ -215,7 +215,7 @@ describe('Change password', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'token', + provider: { name: 'token1' }, value: { username, password: 'new-password' }, }); }); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index aa7e8bc26cc1f2..e915cd8759ff10 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -81,7 +81,7 @@ export function defineChangeUserPasswordRoutes({ if (isUserChangingOwnPassword && currentSession) { try { const authenticationResult = await authc.login(request, { - provider: currentUser!.authentication_provider, + provider: { name: currentUser!.authentication_provider }, value: { username, password: newPassword }, }); diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts index 63e8a518c61983..80f7f62a5ff432 100644 --- a/x-pack/plugins/security/server/routes/views/index.test.ts +++ b/x-pack/plugins/security/server/routes/views/index.test.ts @@ -11,7 +11,7 @@ import { routeDefinitionParamsMock } from '../index.mock'; describe('View routes', () => { it('does not register Login routes if both `basic` and `token` providers are disabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation( + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( provider => provider !== 'basic' && provider !== 'token' ); @@ -29,7 +29,9 @@ describe('View routes', () => { it('registers Login routes if `basic` provider is enabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation(provider => provider !== 'token'); + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( + provider => provider !== 'token' + ); defineViewRoutes(routeParamsMock); @@ -47,7 +49,29 @@ describe('View routes', () => { it('registers Login routes if `token` provider is enabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation(provider => provider !== 'basic'); + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( + provider => provider !== 'basic' + ); + + defineViewRoutes(routeParamsMock); + + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/login", + "/internal/security/login_state", + "/security/account", + "/security/logged_out", + "/logout", + "/security/overwritten_session", + ] + `); + }); + + it('registers Login routes if Login Selector is enabled even if both `token` and `basic` providers are not enabled', () => { + const routeParamsMock = routeDefinitionParamsMock.create({ + authc: { selector: { enabled: true } }, + }); + routeParamsMock.authc.isProviderTypeEnabled.mockReturnValue(false); defineViewRoutes(routeParamsMock); diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts index 91e57aed44ab6f..255989dfeb90cd 100644 --- a/x-pack/plugins/security/server/routes/views/index.ts +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -12,7 +12,11 @@ import { defineOverwrittenSessionRoutes } from './overwritten_session'; import { RouteDefinitionParams } from '..'; export function defineViewRoutes(params: RouteDefinitionParams) { - if (params.authc.isProviderEnabled('basic') || params.authc.isProviderEnabled('token')) { + if ( + params.config.authc.selector.enabled || + params.authc.isProviderTypeEnabled('basic') || + params.authc.isProviderTypeEnabled('token') + ) { defineLoginRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index d14aa226e17bab..9217d5a437f9c4 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -13,21 +13,22 @@ import { IRouter, } from '../../../../../../src/core/server'; import { SecurityLicense } from '../../../common/licensing'; -import { Authentication } from '../../authentication'; +import { LoginState } from '../../../common/login_state'; +import { ConfigType } from '../../config'; import { defineLoginRoutes } from './login'; import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../index.mock'; describe('Login view routes', () => { - let authc: jest.Mocked; let router: jest.Mocked; let license: jest.Mocked; + let config: ConfigType; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); - authc = routeParamsMock.authc; router = routeParamsMock.router; license = routeParamsMock.license; + config = routeParamsMock.config; defineLoginRoutes(routeParamsMock); }); @@ -45,7 +46,7 @@ describe('Login view routes', () => { }); it('correctly defines route.', () => { - expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.options).toEqual({ authRequired: 'optional' }); expect(routeConfig.validate).toEqual({ body: undefined, @@ -73,7 +74,7 @@ describe('Login view routes', () => { ); }); - it('redirects user to the root page if they have a session already or login is disabled.', async () => { + it('redirects user to the root page if they are authenticated or login is disabled.', async () => { for (const { query, expectedLocation } of [ { query: {}, expectedLocation: '/mock-server-basepath/' }, { @@ -85,27 +86,27 @@ describe('Login view routes', () => { expectedLocation: '/mock-server-basepath/', }, ]) { - const request = httpServerMock.createKibanaRequest({ query }); + // Redirect if user is authenticated even if `showLogin` is `true`. + let request = httpServerMock.createKibanaRequest({ + query, + auth: { isAuthenticated: true }, + }); (request as any).url = new URL( `${request.url.path}${request.url.search}`, 'https://kibana.co' ); - - // Redirect if user has an active session even if `showLogin` is `true`. - authc.getSessionInfo.mockResolvedValue({ - provider: 'basic', - now: 0, - idleTimeoutExpiration: null, - lifespanExpiration: null, - }); license.getFeatures.mockReturnValue({ showLogin: true } as any); await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ options: { headers: { location: `${expectedLocation}` } }, status: 302, }); - // Redirect if `showLogin` is `false` even if user doesn't have an active session even. - authc.getSessionInfo.mockResolvedValue(null); + // Redirect if `showLogin` is `false` even if user is not authenticated. + request = httpServerMock.createKibanaRequest({ query, auth: { isAuthenticated: false } }); + (request as any).url = new URL( + `${request.url.path}${request.url.search}`, + 'https://kibana.co' + ); license.getFeatures.mockReturnValue({ showLogin: false } as any); await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ options: { headers: { location: `${expectedLocation}` } }, @@ -114,11 +115,10 @@ describe('Login view routes', () => { } }); - it('renders view if user does not have an active session and login page can be shown.', async () => { - authc.getSessionInfo.mockResolvedValue(null); + it('renders view if user is not authenticated and login page can be shown.', async () => { license.getFeatures.mockReturnValue({ showLogin: true } as any); - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ auth: { isAuthenticated: false } }); const contextMock = coreMock.createRequestHandlerContext(); await expect( @@ -133,7 +133,6 @@ describe('Login view routes', () => { status: 200, }); - expect(authc.getSessionInfo).toHaveBeenCalledWith(request); expect(contextMock.rendering.render).toHaveBeenCalledWith({ includeUserSettings: false }); }); }); @@ -170,11 +169,18 @@ describe('Login view routes', () => { const request = httpServerMock.createKibanaRequest(); const contextMock = coreMock.createRequestHandlerContext(); + const expectedPayload = { + allowLogin: true, + layout: 'error-es-unavailable', + showLoginForm: true, + requiresSecureConnection: false, + selector: { enabled: false, providers: [] }, + }; await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) ).resolves.toEqual({ - options: { body: { allowLogin: true, layout: 'error-es-unavailable', showLogin: true } }, - payload: { allowLogin: true, layout: 'error-es-unavailable', showLogin: true }, + options: { body: expectedPayload }, + payload: expectedPayload, status: 200, }); }); @@ -185,13 +191,156 @@ describe('Login view routes', () => { const request = httpServerMock.createKibanaRequest(); const contextMock = coreMock.createRequestHandlerContext(); + const expectedPayload = { + allowLogin: true, + layout: 'form', + showLoginForm: true, + requiresSecureConnection: false, + selector: { enabled: false, providers: [] }, + }; + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + }); + + it('returns `requiresSecureConnection: true` if `secureCookies` is enabled in config.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + config.secureCookies = true; + + const expectedPayload = expect.objectContaining({ requiresSecureConnection: true }); await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) ).resolves.toEqual({ - options: { body: { allowLogin: true, layout: 'form', showLogin: true } }, - payload: { allowLogin: true, layout: 'form', showLogin: true }, + options: { body: expectedPayload }, + payload: expectedPayload, status: 200, }); }); + + it('returns `showLoginForm: true` only if either `basic` or `token` provider is enabled.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + const cases: Array<[boolean, ConfigType['authc']['sortedProviders']]> = [ + [false, []], + [true, [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }]], + [true, [{ type: 'token', name: 'token1', options: { order: 0, showInSelector: true } }]], + ]; + + for (const [showLoginForm, sortedProviders] of cases) { + config.authc.sortedProviders = sortedProviders; + + const expectedPayload = expect.objectContaining({ showLoginForm }); + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + } + }); + + it('correctly returns `selector` information.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + const cases: Array<[ + boolean, + ConfigType['authc']['sortedProviders'], + LoginState['selector']['providers'] + ]> = [ + // selector is disabled, providers shouldn't be returned. + [ + false, + [ + { type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }, + { type: 'saml', name: 'saml1', options: { order: 1, showInSelector: true } }, + ], + [], + ], + // selector is enabled, but only basic/token is available, providers shouldn't be returned. + [ + true, + [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }], + [], + ], + // selector is enabled, non-basic/token providers should be returned + [ + true, + [ + { + type: 'basic', + name: 'basic1', + options: { order: 0, showInSelector: true, description: 'some-desc1' }, + }, + { + type: 'saml', + name: 'saml1', + options: { order: 1, showInSelector: true, description: 'some-desc2' }, + }, + { + type: 'saml', + name: 'saml2', + options: { order: 2, showInSelector: true, description: 'some-desc3' }, + }, + ], + [ + { type: 'saml', name: 'saml1', description: 'some-desc2' }, + { type: 'saml', name: 'saml2', description: 'some-desc3' }, + ], + ], + // selector is enabled, only non-basic/token providers that are enabled in selector should be returned. + [ + true, + [ + { + type: 'basic', + name: 'basic1', + options: { order: 0, showInSelector: true, description: 'some-desc1' }, + }, + { + type: 'saml', + name: 'saml1', + options: { order: 1, showInSelector: false, description: 'some-desc2' }, + }, + { + type: 'saml', + name: 'saml2', + options: { order: 2, showInSelector: true, description: 'some-desc3' }, + }, + ], + [{ type: 'saml', name: 'saml2', description: 'some-desc3' }], + ], + ]; + + for (const [selectorEnabled, sortedProviders, expectedProviders] of cases) { + config.authc.selector.enabled = selectorEnabled; + config.authc.sortedProviders = sortedProviders; + + const expectedPayload = expect.objectContaining({ + selector: { enabled: selectorEnabled, providers: expectedProviders }, + }); + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + } + }); }); }); diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts index ee1fe01ab1b22c..4cabd4337971ce 100644 --- a/x-pack/plugins/security/server/routes/views/login.ts +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -6,15 +6,16 @@ import { schema } from '@kbn/config-schema'; import { parseNext } from '../../../common/parse_next'; +import { LoginState } from '../../../common/login_state'; import { RouteDefinitionParams } from '..'; /** * Defines routes required for the Login view. */ export function defineLoginRoutes({ + config, router, logger, - authc, csp, basePath, license, @@ -31,15 +32,12 @@ export function defineLoginRoutes({ { unknowns: 'allow' } ), }, - options: { authRequired: false }, + options: { authRequired: 'optional' }, }, async (context, request, response) => { // Default to true if license isn't available or it can't be resolved for some reason. const shouldShowLogin = license.isEnabled() ? license.getFeatures().showLogin : true; - - // Authentication flow isn't triggered automatically for this route, so we should explicitly - // check whether user has an active session already. - const isUserAlreadyLoggedIn = (await authc.getSessionInfo(request)) !== null; + const isUserAlreadyLoggedIn = request.auth.isAuthenticated; if (isUserAlreadyLoggedIn || !shouldShowLogin) { logger.debug('User is already authenticated, redirecting...'); return response.redirected({ @@ -57,8 +55,30 @@ export function defineLoginRoutes({ router.get( { path: '/internal/security/login_state', validate: false, options: { authRequired: false } }, async (context, request, response) => { - const { showLogin, allowLogin, layout = 'form' } = license.getFeatures(); - return response.ok({ body: { showLogin, allowLogin, layout } }); + const { allowLogin, layout = 'form' } = license.getFeatures(); + const { sortedProviders, selector } = config.authc; + + let showLoginForm = false; + const providers = []; + for (const { type, name, options } of sortedProviders) { + if (options.showInSelector) { + if (type === 'basic' || type === 'token') { + showLoginForm = true; + } else if (selector.enabled) { + providers.push({ type, name, description: options.description }); + } + } + } + + const loginState: LoginState = { + allowLogin, + layout, + requiresSecureConnection: config.secureCookies, + showLoginForm, + selector: { enabled: selector.enabled, providers }, + }; + + return response.ok({ body: loginState }); } ); } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 8de0cb5dcf5712..63a16a3245b935 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -27,6 +27,7 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/oidc_api_integration/config'), require.resolve('../test/oidc_api_integration/implicit_flow.config'), require.resolve('../test/pki_api_integration/config'), + require.resolve('../test/login_selector_api_integration/config'), require.resolve('../test/spaces_api_integration/spaces_only/config'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic'), diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts index b561c9ea47513b..81999826adbb10 100644 --- a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -8,10 +8,14 @@ import expect from '@kbn/expect'; import request, { Cookie } from 'request'; import { delay } from 'bluebird'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { + getMutualAuthenticationResponseToken, + getSPNEGOToken, +} from '../../fixtures/kerberos_tools'; export default function({ getService }: FtrProviderContext) { - const spnegoToken = - 'YIIChwYGKwYBBQUCoIICezCCAnegDTALBgkqhkiG9xIBAgKiggJkBIICYGCCAlwGCSqGSIb3EgECAgEAboICSzCCAkegAwIBBaEDAgEOogcDBQAAAAAAo4IBW2GCAVcwggFToAMCAQWhERsPVEVTVC5FTEFTVElDLkNPohwwGqADAgEDoRMwERsESFRUUBsJbG9jYWxob3N0o4IBGTCCARWgAwIBEqEDAgECooIBBwSCAQNBN2a1Rso+KEJsDwICYLCt7ACLzdlbhEZF5YNsehO109b/WiZR1VTK6kCQyDdBdQFefyvV8EiC35mz7XnTb239nWz6xBGbdmtjSfF0XzpXKbL/zGzLEKkEXQuqFLPUN6qEJXsh0OoNdj9OWwmTr93FVyugs1hO/E5wjlAe2SDYpBN6uZICXu6dFg9nLQKkb/XgbgKM7ZZvgA/UElWDgHav4nPO1VWppCCLKHqXTRnvpr/AsxeON4qeJLaukxBigfIaJlLFMNQal5H7MyXa0j3Y1sckbURnWoBt6r4XE7c8F8cz0rYoGwoCO+Cs5tNutKY6XcsAFbLh59hjgIkhVBhhyTeypIHSMIHPoAMCARKigccEgcSsXqIRAcHfZivrbHfsnvbFgmzmnrKVPFNtJ9Hl23KunCsNW49nP4VF2dEf9n12prDaIguJDV5LPHpTew9rmCj1GCahKJ9bJbRKIgImLFd+nelm3E2zxRqAhrgM1469oDg0ksE3+5lJBuJlVEECMp0F/gxvEiL7DhasICqw+FOJ/jD9QUYvg+E6BIxWgZyPszaxerzBBszAhIF1rxCHRRL1KLjskNeJlBhH77DkAO6AEmsYGdsgEq7b7uCov9PKPiiPAuFF'; + const spnegoToken = getSPNEGOToken(); + const supertest = getService('supertestWithoutAuth'); const config = getService('config'); @@ -105,7 +109,7 @@ export default function({ getService }: FtrProviderContext) { // Verify that mutual authentication works. expect(response.headers['www-authenticate']).to.be( - 'Negotiate oRQwEqADCgEAoQsGCSqGSIb3EgECAg==' + `Negotiate ${getMutualAuthenticationResponseToken()}` ); const cookies = response.headers['set-cookie']; diff --git a/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts b/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts new file mode 100644 index 00000000000000..2fed5d475cd5c0 --- /dev/null +++ b/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export function getSPNEGOToken() { + return 'YIIChwYGKwYBBQUCoIICezCCAnegDTALBgkqhkiG9xIBAgKiggJkBIICYGCCAlwGCSqGSIb3EgECAgEAboICSzCCAkegAwIBBaEDAgEOogcDBQAAAAAAo4IBW2GCAVcwggFToAMCAQWhERsPVEVTVC5FTEFTVElDLkNPohwwGqADAgEDoRMwERsESFRUUBsJbG9jYWxob3N0o4IBGTCCARWgAwIBEqEDAgECooIBBwSCAQNBN2a1Rso+KEJsDwICYLCt7ACLzdlbhEZF5YNsehO109b/WiZR1VTK6kCQyDdBdQFefyvV8EiC35mz7XnTb239nWz6xBGbdmtjSfF0XzpXKbL/zGzLEKkEXQuqFLPUN6qEJXsh0OoNdj9OWwmTr93FVyugs1hO/E5wjlAe2SDYpBN6uZICXu6dFg9nLQKkb/XgbgKM7ZZvgA/UElWDgHav4nPO1VWppCCLKHqXTRnvpr/AsxeON4qeJLaukxBigfIaJlLFMNQal5H7MyXa0j3Y1sckbURnWoBt6r4XE7c8F8cz0rYoGwoCO+Cs5tNutKY6XcsAFbLh59hjgIkhVBhhyTeypIHSMIHPoAMCARKigccEgcSsXqIRAcHfZivrbHfsnvbFgmzmnrKVPFNtJ9Hl23KunCsNW49nP4VF2dEf9n12prDaIguJDV5LPHpTew9rmCj1GCahKJ9bJbRKIgImLFd+nelm3E2zxRqAhrgM1469oDg0ksE3+5lJBuJlVEECMp0F/gxvEiL7DhasICqw+FOJ/jD9QUYvg+E6BIxWgZyPszaxerzBBszAhIF1rxCHRRL1KLjskNeJlBhH77DkAO6AEmsYGdsgEq7b7uCov9PKPiiPAuFF'; +} + +export function getMutualAuthenticationResponseToken() { + return 'oRQwEqADCgEAoQsGCSqGSIb3EgECAg=='; +} diff --git a/x-pack/test/login_selector_api_integration/apis/index.ts b/x-pack/test/login_selector_api_integration/apis/index.ts new file mode 100644 index 00000000000000..35f83733a7105d --- /dev/null +++ b/x-pack/test/login_selector_api_integration/apis/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('apis', function() { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./login_selector')); + }); +} diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts new file mode 100644 index 00000000000000..3be96d27186d9a --- /dev/null +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -0,0 +1,545 @@ +/* + * 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 request, { Cookie } from 'request'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import url from 'url'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; +import expect from '@kbn/expect'; +import { getStateAndNonce } from '../../oidc_api_integration/fixtures/oidc_tools'; +import { + getMutualAuthenticationResponseToken, + getSPNEGOToken, +} from '../../kerberos_api_integration/fixtures/kerberos_tools'; +import { getSAMLRequestId, getSAMLResponse } from '../../saml_api_integration/fixtures/saml_tools'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const randomness = getService('randomness'); + const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); + + const kibanaServerConfig = config.get('servers.kibana'); + const validUsername = kibanaServerConfig.username; + const validPassword = kibanaServerConfig.password; + + const CA_CERT = readFileSync(CA_CERT_PATH); + const CLIENT_CERT = readFileSync( + resolve(__dirname, '../../pki_api_integration/fixtures/first_client.p12') + ); + + async function checkSessionCookie(sessionCookie: Cookie, username: string, providerName: string) { + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + + const apiResponse = await supertest + .get('/internal/security/me') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponse.body).to.only.have.keys([ + 'username', + 'full_name', + 'email', + 'roles', + 'metadata', + 'enabled', + 'authentication_realm', + 'lookup_realm', + 'authentication_provider', + ]); + + expect(apiResponse.body.username).to.be(username); + expect(apiResponse.body.authentication_provider).to.be(providerName); + } + + describe('Login Selector', () => { + it('should redirect user to a login selector', async () => { + const response = await supertest + .get('/abc/xyz/handshake?one=two three') + .ca(CA_CERT) + .expect(302); + expect(response.headers['set-cookie']).to.be(undefined); + expect(response.headers.location).to.be( + '/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three' + ); + }); + + it('should allow access to login selector with intermediate authentication cookie', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ providerType: 'saml', providerName: 'saml1', currentURL: 'https://kibana.com/' }) + .expect(200); + + // The cookie that includes some state of the in-progress authentication, that doesn't allow + // to fully authenticate user yet. + const intermediateAuthCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + + await supertest + .get('/login') + .ca(CA_CERT) + .set('Cookie', intermediateAuthCookie.cookieString()) + .expect(200); + }); + + describe('SAML', () => { + function createSAMLResponse(options = {}) { + return getSAMLResponse({ + destination: `http://localhost:${kibanaServerConfig.port}/api/security/saml/callback`, + sessionIndex: String(randomness.naturalNumber()), + ...options, + }); + } + + it('should be able to log in via IdP initiated login for any configured realm', async () => { + for (const providerName of ['saml1', 'saml2']) { + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .send({ + SAMLResponse: await createSAMLResponse({ + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // User should be redirected to the base URL. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via IdP initiated login even if session with other provider type exists', async () => { + const basicAuthenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ username: validUsername, password: validPassword }) + .expect(204); + + const basicSessionCookie = request.cookie( + basicAuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1'); + + for (const providerName of ['saml1', 'saml2']) { + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', basicSessionCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // It should be `/overwritten_session` instead of `/` once it's generalized. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via IdP initiated login even if session with other SAML provider exists', async () => { + // First login with `saml1`. + const saml1AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml1` }), + }) + .expect(302); + + const saml1SessionCookie = request.cookie( + saml1AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml1SessionCookie, 'a@b.c', 'saml1'); + + // And now try to login with `saml2`. + const saml2AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml1SessionCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(302); + + // It should be `/overwritten_session` instead of `/` once it's generalized. + expect(saml2AuthenticationResponse.headers.location).to.be('/'); + + const saml2SessionCookie = request.cookie( + saml2AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2'); + }); + + // Ideally we should be able to abandon intermediate session and let user log in, but for the + // time being we cannot distinguish errors coming from Elasticsearch for the case when SAML + // response just doesn't correspond to request ID we have in intermediate cookie and the case + // when something else has happened. + it('should fail for IdP initiated login if intermediate session with other SAML provider exists', async () => { + // First start authentication flow with `saml1`. + const saml1HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + expect( + saml1HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml1HandshakeCookie = request.cookie( + saml1HandshakeResponse.headers['set-cookie'][0] + )!; + + // And now try to login with `saml2`. + await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml1HandshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(401); + }); + + it('should be able to log in via SP initiated login with any configured realm', async () => { + for (const providerName of ['saml1', 'saml2']) { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName, + currentURL: + 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + expect(handshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`)).to.be( + true + ); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); + + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ + inResponseTo: samlRequestId, + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // User should be redirected to the URL that initiated handshake. + expect(authenticationResponse.headers.location).to.be( + '/abc/xyz/handshake?one=two three#/workpad' + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via SP initiated login even if intermediate session with other SAML provider exists', async () => { + // First start authentication flow with `saml1`. + const saml1HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/saml1', + }) + .expect(200); + + expect( + saml1HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml1HandshakeCookie = request.cookie( + saml1HandshakeResponse.headers['set-cookie'][0] + )!; + + // And now try to login with `saml2`. + const saml2HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', saml1HandshakeCookie.cookieString()) + .send({ + providerType: 'saml', + providerName: 'saml2', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/saml2', + }) + .expect(200); + + expect( + saml2HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml2HandshakeCookie = request.cookie( + saml2HandshakeResponse.headers['set-cookie'][0] + )!; + + const saml2AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml2HandshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(302); + + expect(saml2AuthenticationResponse.headers.location).to.be( + '/abc/xyz/handshake?one=two three#/saml2' + ); + + const saml2SessionCookie = request.cookie( + saml2AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2'); + }); + }); + + describe('Kerberos', () => { + it('should be able to log in from Login Selector', async () => { + const spnegoResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Negotiate ${getSPNEGOToken()}`) + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + // Verify that mutual authentication works. + expect(authenticationResponse.headers['www-authenticate']).to.be( + `Negotiate ${getMutualAuthenticationResponseToken()}` + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'tester@TEST.ELASTIC.CO', + 'kerberos1' + ); + }); + + it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => { + const spnegoResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Negotiate ${getSPNEGOToken()}`) + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + // Verify that mutual authentication works. + expect(authenticationResponse.headers['www-authenticate']).to.be( + `Negotiate ${getMutualAuthenticationResponseToken()}` + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'tester@TEST.ELASTIC.CO', + 'kerberos1' + ); + }); + }); + + describe('OpenID Connect', () => { + it('should be able to log in via IdP initiated login', async () => { + const handshakeResponse = await supertest + .get('/api/security/oidc/initiate_login?iss=https://test-op.elastic.co') + .ca(CA_CERT) + .expect(302); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + const { state, nonce } = getStateAndNonce(handshakeResponse.headers.location); + await supertest + .post('/api/oidc_provider/setup') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ nonce }) + .expect(200); + + const authenticationResponse = await supertest + .get(`/api/security/oidc/callback?code=code2&state=${state}`) + .ca(CA_CERT) + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + // User should be redirected to the base URL. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'user2', 'oidc1'); + }); + + it('should be able to log in via SP initiated login', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three', + }) + .expect(200); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect( + handshakeResponse.body.location.startsWith( + `https://test-op.elastic.co/oauth2/v1/authorize` + ) + ).to.be(true); + + expect(redirectURL.query.scope).to.not.be.empty(); + expect(redirectURL.query.response_type).to.not.be.empty(); + expect(redirectURL.query.client_id).to.not.be.empty(); + expect(redirectURL.query.redirect_uri).to.not.be.empty(); + expect(redirectURL.query.state).to.not.be.empty(); + expect(redirectURL.query.nonce).to.not.be.empty(); + + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + const { state, nonce } = redirectURL.query; + await supertest + .post('/api/oidc_provider/setup') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ nonce }) + .expect(200); + + const authenticationResponse = await supertest + .get(`/api/security/oidc/callback?code=code1&state=${state}`) + .ca(CA_CERT) + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + // User should be redirected to the URL that initiated handshake. + expect(authenticationResponse.headers.location).to.be('/abc/xyz/handshake?one=two three'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'user1', 'oidc1'); + }); + }); + + describe('PKI', () => { + it('should redirect user to a login selector even if client provides certificate', async () => { + const response = await supertest + .get('/abc/xyz/handshake?one=two three') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .expect(302); + expect(response.headers['set-cookie']).to.be(undefined); + expect(response.headers.location).to.be( + '/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three' + ); + }); + + it('should be able to log in from Login Selector', async () => { + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'pki', + providerName: 'pki1', + 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])!, 'first_client', 'pki1'); + }); + }); + }); +} diff --git a/x-pack/test/login_selector_api_integration/config.ts b/x-pack/test/login_selector_api_integration/config.ts new file mode 100644 index 00000000000000..6ca9d19b74c179 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/config.ts @@ -0,0 +1,141 @@ +/* + * 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 { resolve } from 'path'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; +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.js')); + const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port'); + + const kerberosKeytabPath = resolve(__dirname, '../kerberos_api_integration/fixtures/krb5.keytab'); + const kerberosConfigPath = resolve(__dirname, '../kerberos_api_integration/fixtures/krb5.conf'); + + const oidcJWKSPath = resolve(__dirname, '../oidc_api_integration/fixtures/jwks.json'); + const oidcIdPPlugin = resolve(__dirname, '../oidc_api_integration/fixtures/oidc_provider'); + + const pkiKibanaCAPath = resolve(__dirname, '../pki_api_integration/fixtures/kibana_ca.crt'); + + const saml1IdPMetadataPath = resolve( + __dirname, + '../saml_api_integration/fixtures/idp_metadata.xml' + ); + const saml2IdPMetadataPath = resolve( + __dirname, + '../saml_api_integration/fixtures/idp_metadata_2.xml' + ); + + const servers = { + ...xPackAPITestsConfig.get('servers'), + elasticsearch: { + ...xPackAPITestsConfig.get('servers.elasticsearch'), + protocol: 'https', + }, + kibana: { + ...xPackAPITestsConfig.get('servers.kibana'), + protocol: 'https', + }, + }; + + return { + testFiles: [require.resolve('./apis')], + servers, + security: { disableTestUser: true }, + services: { + randomness: kibanaAPITestsConfig.get('services.randomness'), + legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + junit: { + reportName: 'X-Pack Login Selector API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + ssl: true, + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + 'xpack.security.http.ssl.client_authentication=optional', + 'xpack.security.http.ssl.verification_mode=certificate', + 'xpack.security.authc.realms.native.native1.order=0', + 'xpack.security.authc.realms.kerberos.kerb1.order=1', + `xpack.security.authc.realms.kerberos.kerb1.keytab.path=${kerberosKeytabPath}`, + 'xpack.security.authc.realms.pki.pki1.order=2', + 'xpack.security.authc.realms.pki.pki1.delegation.enabled=true', + `xpack.security.authc.realms.pki.pki1.certificate_authorities=${CA_CERT_PATH}`, + 'xpack.security.authc.realms.saml.saml1.order=3', + `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${saml1IdPMetadataPath}`, + '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}`, + `xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.saml1.attributes.principal=urn:oid:0.0.7', + 'xpack.security.authc.realms.oidc.oidc1.order=4', + `xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.client_secret=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`, + `xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=https://localhost:${kibanaPort}/api/security/oidc/callback`, + `xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=https://test-op.elastic.co/oauth2/v1/authorize`, + `xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=https://test-op.elastic.co/oauth2/v1/endsession`, + `xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=https://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.userinfo_endpoint=https://localhost:${kibanaPort}/api/oidc_provider/userinfo_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.issuer=https://test-op.elastic.co`, + `xpack.security.authc.realms.oidc.oidc1.op.jwkset_path=${oidcJWKSPath}`, + `xpack.security.authc.realms.oidc.oidc1.claims.principal=sub`, + `xpack.security.authc.realms.oidc.oidc1.ssl.certificate_authorities=${CA_CERT_PATH}`, + 'xpack.security.authc.realms.saml.saml2.order=5', + `xpack.security.authc.realms.saml.saml2.idp.metadata.path=${saml2IdPMetadataPath}`, + 'xpack.security.authc.realms.saml.saml2.idp.entity_id=http://www.elastic.co/saml2', + `xpack.security.authc.realms.saml.saml2.sp.entity_id=http://localhost:${kibanaPort}`, + `xpack.security.authc.realms.saml.saml2.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.saml2.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.saml2.attributes.principal=urn:oid:0.0.7', + ], + serverEnvVars: { + // We're going to use the same TGT multiple times and during a short period of time, so we + // have to disable replay cache so that ES doesn't complain about that. + ES_JAVA_OPTS: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, + }, + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${oidcIdPPlugin}`, + '--optimize.enabled=false', + '--server.ssl.enabled=true', + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, + `--server.ssl.certificateAuthorities=${JSON.stringify([CA_CERT_PATH, pkiKibanaCAPath])}`, + `--server.ssl.clientAuthentication=optional`, + `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + `--xpack.security.authc.providers=${JSON.stringify({ + basic: { basic1: { order: 0 } }, + kerberos: { kerberos1: { order: 4 } }, + pki: { pki1: { order: 2 } }, + oidc: { oidc1: { order: 3, realm: 'oidc1' } }, + saml: { + saml1: { order: 1, realm: 'saml1' }, + saml2: { order: 5, realm: 'saml2', maxRedirectURLSize: '100b' }, + }, + })}`, + '--server.xsrf.whitelist', + JSON.stringify([ + '/api/oidc_provider/token_endpoint', + '/api/oidc_provider/userinfo_endpoint', + ]), + ], + }, + }; +} diff --git a/x-pack/plugins/security/public/authentication/login/login_state.ts b/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts similarity index 56% rename from x-pack/plugins/security/public/authentication/login/login_state.ts rename to x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts index 6ca38296706feb..e3add3748f56d7 100644 --- a/x-pack/plugins/security/public/authentication/login/login_state.ts +++ b/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LoginLayout } from '../../../common/licensing'; +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; -export interface LoginState { - layout: LoginLayout; - allowLogin: boolean; -} +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/login_selector_api_integration/services.ts b/x-pack/test/login_selector_api_integration/services.ts new file mode 100644 index 00000000000000..8bb2dae90bf59e --- /dev/null +++ b/x-pack/test/login_selector_api_integration/services.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 { services as commonServices } from '../common/services'; +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + ...commonServices, + randomness: apiIntegrationServices.randomness, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, +}; diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index fe772a3b1d4601..ac16335b7f4660 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -9,7 +9,6 @@ import request, { Cookie } from 'request'; import { delay } from 'bluebird'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -// @ts-ignore import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/pki_api_integration/config.ts b/x-pack/test/pki_api_integration/config.ts index 21ae1b40efa16a..8177e4aa1afbaf 100644 --- a/x-pack/test/pki_api_integration/config.ts +++ b/x-pack/test/pki_api_integration/config.ts @@ -6,7 +6,6 @@ import { resolve } from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -// @ts-ignore import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { services } from './services'; diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index 1ad367e8bc08d0..35f778e124da03 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -108,11 +108,15 @@ export default function({ getService }: FtrProviderContext) { expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - expect(handshakeResponse.headers.location).to.be('/api/security/saml/capture-url-fragment'); + expect(handshakeResponse.headers.location).to.be( + '/internal/security/saml/capture-url-fragment' + ); }); it('should return an HTML page that will extract URL fragment', async () => { - const response = await supertest.get('/api/security/saml/capture-url-fragment').expect(200); + const response = await supertest + .get('/internal/security/saml/capture-url-fragment') + .expect(200); const kibanaBaseURL = url.format({ ...config.get('servers.kibana'), auth: false }); const dom = new JSDOM(response.text, { @@ -127,7 +131,7 @@ export default function({ getService }: FtrProviderContext) { Object.defineProperty(window, 'location', { value: { hash: '#/workpad', - href: `${kibanaBaseURL}/api/security/saml/capture-url-fragment#/workpad`, + href: `${kibanaBaseURL}/internal/security/saml/capture-url-fragment#/workpad`, replace(newLocation: string) { this.href = newLocation; resolve(); @@ -149,13 +153,13 @@ export default function({ getService }: FtrProviderContext) { // Check that script that forwards URL fragment worked correctly. expect(dom.window.location.href).to.be( - '/api/security/saml/start?redirectURLFragment=%23%2Fworkpad' + '/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad' ); }); }); describe('initiating handshake', () => { - const initiateHandshakeURL = `/api/security/saml/start?redirectURLFragment=%23%2Fworkpad`; + const initiateHandshakeURL = `/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`; let captureURLCookie: Cookie; beforeEach(async () => { @@ -202,9 +206,8 @@ export default function({ getService }: FtrProviderContext) { it('AJAX requests should not initiate handshake', async () => { const ajaxResponse = await supertest - .get(initiateHandshakeURL) + .get('/abc/xyz/handshake?one=two three') .set('kbn-xsrf', 'xxx') - .set('Cookie', captureURLCookie.cookieString()) .expect(401); expect(ajaxResponse.headers['set-cookie']).to.be(undefined); @@ -222,7 +225,7 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=%23%2Fworkpad`) + .get(`/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -360,7 +363,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -515,7 +520,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -603,7 +610,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -647,7 +656,9 @@ export default function({ getService }: FtrProviderContext) { expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - expect(handshakeResponse.headers.location).to.be('/api/security/saml/capture-url-fragment'); + expect(handshakeResponse.headers.location).to.be( + '/internal/security/saml/capture-url-fragment' + ); }); }); @@ -662,7 +673,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -798,12 +811,12 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; expect(captureURLResponse.headers.location).to.be( - '/api/security/saml/capture-url-fragment' + '/internal/security/saml/capture-url-fragment' ); // 2. Initiate SAML handshake. const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=%23%2F${'workpad'.repeat(10)}`) + .get(`/internal/security/saml/start?redirectURLFragment=%23%2F${'workpad'.repeat(10)}`) .set('Cookie', captureURLCookie.cookieString()) .expect(302); diff --git a/x-pack/test/saml_api_integration/config.ts b/x-pack/test/saml_api_integration/config.ts index 29415f03d580f2..b7457d44702f36 100644 --- a/x-pack/test/saml_api_integration/config.ts +++ b/x-pack/test/saml_api_integration/config.ts @@ -37,7 +37,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { 'xpack.security.authc.token.timeout=15s', 'xpack.security.authc.realms.saml.saml1.order=0', `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`, - 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co', + '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}`, `xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`, `xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/v1/saml`, diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml index a890fe812987b8..57b9e824c9d530 100644 --- a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml +++ b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml @@ -1,6 +1,6 @@ + entityID="http://www.elastic.co/saml1"> diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml b/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml new file mode 100644 index 00000000000000..ff67779d7732ce --- /dev/null +++ b/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml @@ -0,0 +1,41 @@ + + + + + + + + MIIDOTCCAiGgAwIBAgIVANNWkg9lzNiLqNkMFhFKHcXyaZmqMA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDM0MloYDzIwNjkxMjE0MTcwMzQyWjARMQ8w +DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQ +wYYbQtbRBKJ4uNZc2+IgRU+7NNL21ZebQlEIMgK7jAqOMrsW2b5DATz41Fd+GQFU +FUYYjwo+PQj6sJHshOJo/gNb32HrydvMI7YPvevkszkuEGCfXxQ3Dw2RTACLgD0Q +OCkwHvn3TMf0loloV/ePGWaZDYZaXi3a5DdWi/HFFoJysgF0JV2f6XyKhJkGaEfJ +s9pWX269zH/XQvGNx4BEimJpYB8h4JnDYPFIiQdqj+sl2b+kS1hH9kL5gBAMXjFU +vcNnX+PmyTjyJrGo75k0ku+spBf1bMwuQt3uSmM+TQIXkvFDmS0DOVESrpA5EC1T +BUGRz6o/I88Xx4Mud771AgMBAAGjYzBhMB0GA1UdDgQWBBQLB1Eo23M3Ss8MsFaz +V+Twcb3PmDAfBgNVHSMEGDAWgBQa7SYOe8NGcF00EbwPHA91YCsHSTAUBgNVHREE +DTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADANBgkqhkiG9w0BAQsFAAOCAQEAnEl/ +z5IElIjvkK4AgMPrNcRlvIGDt2orEik7b6Jsq6/RiJQ7cSsYTZf7xbqyxNsUOTxv ++frj47MEN448H2nRvUxH29YR3XygV5aEwADSAhwaQWn0QfWTCZbJTmSoNEDtDOzX +TGDlAoCD9s9Xz9S1JpxY4H+WWRZrBSDM6SC1c6CzuEeZRuScNAjYD5mh2v6fOlSy +b8xJWSg0AFlJPCa3ZsA2SKbNqI0uNfJTnkXRm88Z2NHcgtlADbOLKauWfCrpgsCk +cZgo6yAYkOM148h/8wGla1eX+iE1R72NUABGydu8MSQKvc0emWJkGsC1/KqPlf/O +eOUsdwn1yDKHRxDHyA== + + + + + + + + + + diff --git a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts index bbe0df7ff3a2c9..a924d0964c2451 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts +++ b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts @@ -45,14 +45,21 @@ export async function getSAMLResponse({ inResponseTo, sessionIndex, username = 'a@b.c', -}: { destination?: string; inResponseTo?: string; sessionIndex?: string; username?: string } = {}) { + issuer = 'http://www.elastic.co/saml1', +}: { + destination?: string; + inResponseTo?: string; + sessionIndex?: string; + username?: string; + issuer?: string; +} = {}) { const issueInstant = new Date().toISOString(); const notOnOrAfter = new Date(Date.now() + 3600 * 1000).toISOString(); const samlAssertionTemplateXML = ` - http://www.elastic.co + ${issuer} a@b.c @@ -99,7 +106,7 @@ export async function getSAMLResponse({ ${inResponseTo ? `InResponseTo="${inResponseTo}"` : ''} Version="2.0" IssueInstant="${issueInstant}" Destination="${destination}"> - http://www.elastic.co + ${issuer} ${signature.getSignedXml()} @@ -111,9 +118,11 @@ export async function getSAMLResponse({ export async function getLogoutRequest({ destination, sessionIndex, + issuer = 'http://www.elastic.co/saml1', }: { destination: string; sessionIndex: string; + issuer?: string; }) { const issueInstant = new Date().toISOString(); const logoutRequestTemplateXML = ` @@ -121,7 +130,7 @@ export async function getLogoutRequest({ Destination="${destination}" Consent="urn:oasis:names:tc:SAML:2.0:consent:unspecified" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"> - http://www.elastic.co + ${issuer} a@b.c ${sessionIndex}