Implement Kibana Login Selector (#53010)

This commit is contained in:
Aleh Zasypkin 2020-03-23 22:45:26 +01:00 committed by GitHub
parent dd93a14fef
commit fa69765e4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 5229 additions and 1418 deletions

View file

@ -51,10 +51,7 @@ export const security = (kibana: Record<string, any>) =>
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') };
},
},

View file

@ -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;
}

View file

@ -15,7 +15,7 @@ export function mockAuthenticatedUser(user: Partial<AuthenticatedUser> = {}) {
enabled: true,
authentication_realm: { name: 'native1', type: 'native' },
lookup_realm: { name: 'native1', type: 'native' },
authentication_provider: 'basic',
authentication_provider: 'basic1',
...user,
};
}

View file

@ -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';

View file

@ -40,5 +40,5 @@ export function parseNext(href: string, basePath = '') {
return `${basePath}/`;
}
return query.next + (hash || '');
return next + (hash || '');
}

View file

@ -23,7 +23,7 @@ exports[`LoginPage disabled form states renders as expected when an unknown logi
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
defaultMessage="See the Kibana logs for details and try reloading the page."
id="xpack.security.loginPage.unknownLayoutMessage"
values={Object {}}
/>
@ -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`] = `
<DisabledLoginForm
message={
<FormattedMessage
defaultMessage="Contact your system administrator."
id="xpack.security.loginPage.noLoginMethodsAvailableMessage"
values={Object {}}
/>
}
title={
<FormattedMessage
defaultMessage="Login is disabled."
id="xpack.security.loginPage.noLoginMethodsAvailableTitle"
values={Object {}}
/>
}
/>
`;
exports[`LoginPage disabled form states renders as expected when secure connection is required but not present 1`] = `
<DisabledLoginForm
message={
@ -77,7 +96,7 @@ exports[`LoginPage disabled form states renders as expected when xpack is not av
`;
exports[`LoginPage enabled form state renders as expected 1`] = `
<BasicLoginForm
<LoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
@ -85,11 +104,32 @@ exports[`LoginPage enabled form state renders as expected 1`] = `
}
}
loginAssistanceMessage=""
notifications={
Object {
"toasts": Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
},
}
}
selector={
Object {
"enabled": false,
"providers": Array [],
}
}
showLoginForm={true}
/>
`;
exports[`LoginPage enabled form state renders as expected when info message is set 1`] = `
<BasicLoginForm
<LoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
@ -98,11 +138,32 @@ exports[`LoginPage enabled form state renders as expected when info message is s
}
infoMessage="Your session has timed out. Please log in again."
loginAssistanceMessage=""
notifications={
Object {
"toasts": Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
},
}
}
selector={
Object {
"enabled": false,
"providers": Array [],
}
}
showLoginForm={true}
/>
`;
exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = `
<BasicLoginForm
<LoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
@ -111,6 +172,27 @@ exports[`LoginPage enabled form state renders as expected when loginAssistanceMe
}
infoMessage="Your session has timed out. Please log in again."
loginAssistanceMessage="This is an *important* message"
notifications={
Object {
"toasts": Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
},
}
}
selector={
Object {
"enabled": false,
"providers": Array [],
}
}
showLoginForm={true}
/>
`;
@ -172,7 +254,7 @@ exports[`LoginPage page renders as expected 1`] = `
gutterSize="l"
>
<EuiFlexItem>
<BasicLoginForm
<LoginForm
http={
Object {
"addLoadingCountSource": [MockFunction],
@ -180,6 +262,27 @@ exports[`LoginPage page renders as expected 1`] = `
}
}
loginAssistanceMessage=""
notifications={
Object {
"toasts": Object {
"add": [MockFunction],
"addDanger": [MockFunction],
"addError": [MockFunction],
"addInfo": [MockFunction],
"addSuccess": [MockFunction],
"addWarning": [MockFunction],
"get$": [MockFunction],
"remove": [MockFunction],
},
}
}
selector={
Object {
"enabled": false,
"providers": Array [],
}
}
showLoginForm={true}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -1,95 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BasicLoginForm renders as expected 1`] = `
<Fragment>
<EuiText
size="s"
>
<ReactMarkdown
astPlugins={Array []}
escapeHtml={true}
plugins={Array []}
rawSourcePos={false}
renderers={Object {}}
skipHtml={false}
sourcePos={false}
transformLinkUri={[Function]}
/>
</EuiText>
<EuiPanel>
<form
onSubmit={[Function]}
>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Username"
id="xpack.security.login.basicLoginForm.usernameFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
aria-required={true}
data-test-subj="loginUsername"
disabled={false}
id="username"
inputRef={[Function]}
isInvalid={false}
name="username"
onChange={[Function]}
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Password"
id="xpack.security.login.basicLoginForm.passwordFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
aria-required={true}
autoComplete="off"
data-test-subj="loginPassword"
disabled={false}
id="password"
isInvalid={false}
name="password"
onChange={[Function]}
type="password"
value=""
/>
</EuiFormRow>
<EuiButton
color="primary"
data-test-subj="loginSubmit"
fill={true}
isLoading={false}
onClick={[Function]}
type="submit"
>
<FormattedMessage
defaultMessage="Log in"
id="xpack.security.login.basicLoginForm.logInButtonLabel"
values={Object {}}
/>
</EuiButton>
</form>
</EuiPanel>
</Fragment>
`;

View file

@ -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(
<BasicLoginForm http={coreMock.createStart().http} loginAssistanceMessage="" />
)
).toMatchSnapshot();
});
it('renders an info message when provided.', () => {
const wrapper = shallowWithIntl(
<BasicLoginForm
http={coreMock.createStart().http}
infoMessage={'Hey this is an info message'}
loginAssistanceMessage=""
/>
);
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(<BasicLoginForm http={mockHTTP} loginAssistanceMessage="" />);
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(<BasicLoginForm http={mockHTTP} loginAssistanceMessage="" />);
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(<BasicLoginForm http={mockHTTP} loginAssistanceMessage="" />);
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);
});
});

View file

@ -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<Props, State> {
public state = {
hasError: false,
isLoading: false,
username: '',
password: '',
message: '',
};
public render() {
return (
<Fragment>
{this.renderLoginAssistanceMessage()}
{this.renderMessage()}
<EuiPanel>
<form onSubmit={this.submit}>
<EuiFormRow
label={
<FormattedMessage
id="xpack.security.login.basicLoginForm.usernameFormRowLabel"
defaultMessage="Username"
/>
}
>
<EuiFieldText
id="username"
name="username"
data-test-subj="loginUsername"
value={this.state.username}
onChange={this.onUsernameChange}
disabled={this.state.isLoading}
isInvalid={false}
aria-required={true}
inputRef={this.setUsernameInputRef}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="xpack.security.login.basicLoginForm.passwordFormRowLabel"
defaultMessage="Password"
/>
}
>
<EuiFieldText
autoComplete="off"
id="password"
name="password"
data-test-subj="loginPassword"
type="password"
value={this.state.password}
onChange={this.onPasswordChange}
disabled={this.state.isLoading}
isInvalid={false}
aria-required={true}
/>
</EuiFormRow>
<EuiButton
fill
type="submit"
color="primary"
onClick={this.submit}
isLoading={this.state.isLoading}
data-test-subj="loginSubmit"
>
<FormattedMessage
id="xpack.security.login.basicLoginForm.logInButtonLabel"
defaultMessage="Log in"
/>
</EuiButton>
</form>
</EuiPanel>
</Fragment>
);
}
private renderLoginAssistanceMessage = () => {
return (
<Fragment>
<EuiText size="s">
<ReactMarkdown>{this.props.loginAssistanceMessage}</ReactMarkdown>
</EuiText>
</Fragment>
);
};
private renderMessage = () => {
if (this.state.message) {
return (
<Fragment>
<EuiCallOut
size="s"
color="danger"
data-test-subj="loginErrorMessage"
title={this.state.message}
role="alert"
/>
<EuiSpacer size="l" />
</Fragment>
);
}
if (this.props.infoMessage) {
return (
<Fragment>
<EuiCallOut
size="s"
color="primary"
data-test-subj="loginInfoMessage"
title={this.props.infoMessage}
role="status"
/>
<EuiSpacer size="l" />
</Fragment>
);
}
return null;
};
private setUsernameInputRef(ref: HTMLInputElement) {
if (ref) {
ref.focus();
}
}
private isFormValid = () => {
const { username, password } = this.state;
return username && password;
};
private onUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
username: e.target.value,
});
};
private onPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
password: e.target.value,
});
};
private submit = async (e: MouseEvent<HTMLButtonElement> | FormEvent<HTMLFormElement>) => {
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,
});
}
};
}

View file

@ -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';

View file

@ -0,0 +1,240 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LoginForm login selector renders as expected with login form 1`] = `
<Fragment>
<EuiButton
fullWidth={true}
isDisabled={false}
isLoading={false}
key="saml1"
onClick={[Function]}
>
Login w/SAML
</EuiButton>
<EuiSpacer
size="m"
/>
<EuiButton
fullWidth={true}
isDisabled={false}
isLoading={false}
key="pki1"
onClick={[Function]}
>
Login w/PKI
</EuiButton>
<EuiSpacer
size="m"
/>
<EuiText
color="subdued"
textAlign="center"
>
―――  
<FormattedMessage
defaultMessage="OR"
id="xpack.security.loginPage.loginSelectorOR"
values={Object {}}
/>
  ―――
</EuiText>
<EuiSpacer
size="m"
/>
<EuiPanel>
<form
onSubmit={[Function]}
>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Username"
id="xpack.security.login.basicLoginForm.usernameFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
aria-required={true}
data-test-subj="loginUsername"
disabled={false}
id="username"
inputRef={[Function]}
isInvalid={false}
name="username"
onChange={[Function]}
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Password"
id="xpack.security.login.basicLoginForm.passwordFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
aria-required={true}
autoComplete="off"
data-test-subj="loginPassword"
disabled={false}
id="password"
isInvalid={false}
name="password"
onChange={[Function]}
type="password"
value=""
/>
</EuiFormRow>
<EuiButton
color="primary"
data-test-subj="loginSubmit"
fill={true}
isDisabled={false}
isLoading={false}
onClick={[Function]}
type="submit"
>
<FormattedMessage
defaultMessage="Log in"
id="xpack.security.login.basicLoginForm.logInButtonLabel"
values={Object {}}
/>
</EuiButton>
</form>
</EuiPanel>
</Fragment>
`;
exports[`LoginForm login selector renders as expected without login form for providers with and without description 1`] = `
<Fragment>
<EuiButton
fullWidth={true}
isDisabled={false}
isLoading={false}
key="saml1"
onClick={[Function]}
>
Login w/SAML
</EuiButton>
<EuiSpacer
size="m"
/>
<EuiButton
fullWidth={true}
isDisabled={false}
isLoading={false}
key="pki1"
onClick={[Function]}
>
<FormattedMessage
defaultMessage="Login with {providerType}/{providerName}"
id="xpack.security.loginPage.loginProviderDescription"
values={
Object {
"providerName": "pki1",
"providerType": "pki",
}
}
/>
</EuiButton>
<EuiSpacer
size="m"
/>
</Fragment>
`;
exports[`LoginForm renders as expected 1`] = `
<Fragment>
<EuiPanel>
<form
onSubmit={[Function]}
>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Username"
id="xpack.security.login.basicLoginForm.usernameFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
aria-required={true}
data-test-subj="loginUsername"
disabled={false}
id="username"
inputRef={[Function]}
isInvalid={false}
name="username"
onChange={[Function]}
value=""
/>
</EuiFormRow>
<EuiFormRow
describedByIds={Array []}
display="row"
fullWidth={false}
hasChildLabel={true}
hasEmptyLabelSpace={false}
label={
<FormattedMessage
defaultMessage="Password"
id="xpack.security.login.basicLoginForm.passwordFormRowLabel"
values={Object {}}
/>
}
labelType="label"
>
<EuiFieldText
aria-required={true}
autoComplete="off"
data-test-subj="loginPassword"
disabled={false}
id="password"
isInvalid={false}
name="password"
onChange={[Function]}
type="password"
value=""
/>
</EuiFormRow>
<EuiButton
color="primary"
data-test-subj="loginSubmit"
fill={true}
isDisabled={false}
isLoading={false}
onClick={[Function]}
type="submit"
>
<FormattedMessage
defaultMessage="Log in"
id="xpack.security.login.basicLoginForm.logInButtonLabel"
values={Object {}}
/>
</EuiButton>
</form>
</EuiPanel>
</Fragment>
`;

View file

@ -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';

View file

@ -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(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
showLoginForm={true}
selector={{ enabled: false, providers: [] }}
/>
)
).toMatchSnapshot();
});
it('renders an info message when provided.', () => {
const coreStartMock = coreMock.createStart();
const wrapper = shallowWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
infoMessage={'Hey this is an info message'}
loginAssistanceMessage=""
showLoginForm={true}
selector={{ enabled: false, providers: [] }}
/>
);
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(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
showLoginForm={true}
selector={{ enabled: false, providers: [] }}
/>
);
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(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
showLoginForm={true}
selector={{ enabled: false, providers: [] }}
/>
);
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(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
showLoginForm={true}
selector={{ enabled: false, providers: [] }}
/>
);
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(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
showLoginForm={true}
selector={{
enabled: true,
providers: [
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' },
{ type: 'pki', name: 'pki1', description: 'Login w/PKI' },
],
}}
/>
)
).toMatchSnapshot();
});
it('renders as expected without login form for providers with and without description', async () => {
const coreStartMock = coreMock.createStart();
expect(
shallowWithIntl(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
showLoginForm={false}
selector={{
enabled: true,
providers: [
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' },
{ type: 'pki', name: 'pki1' },
],
}}
/>
)
).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(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
showLoginForm={true}
selector={{
enabled: true,
providers: [
{ type: 'saml', name: 'saml1', description: 'Login w/SAML' },
{ type: 'pki', name: 'pki1', description: 'Login w/PKI' },
],
}}
/>
);
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(
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
loginAssistanceMessage=""
showLoginForm={true}
selector={{ enabled: true, providers: [{ type: 'saml', name: 'saml1' }] }}
/>
);
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.',
});
});
});
});

View file

@ -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<Props, State> {
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 (
<Fragment>
{this.renderLoginAssistanceMessage()}
{this.renderMessage()}
{this.renderSelector()}
{this.renderLoginForm()}
</Fragment>
);
}
private renderLoginForm = () => {
if (!this.props.showLoginForm) {
return null;
}
return (
<EuiPanel>
<form onSubmit={this.submitLoginForm}>
<EuiFormRow
label={
<FormattedMessage
id="xpack.security.login.basicLoginForm.usernameFormRowLabel"
defaultMessage="Username"
/>
}
>
<EuiFieldText
id="username"
name="username"
data-test-subj="loginUsername"
value={this.state.username}
onChange={this.onUsernameChange}
disabled={!this.isLoadingState(LoadingStateType.None)}
isInvalid={false}
aria-required={true}
inputRef={this.setUsernameInputRef}
/>
</EuiFormRow>
<EuiFormRow
label={
<FormattedMessage
id="xpack.security.login.basicLoginForm.passwordFormRowLabel"
defaultMessage="Password"
/>
}
>
<EuiFieldText
autoComplete="off"
id="password"
name="password"
data-test-subj="loginPassword"
type="password"
value={this.state.password}
onChange={this.onPasswordChange}
disabled={!this.isLoadingState(LoadingStateType.None)}
isInvalid={false}
aria-required={true}
/>
</EuiFormRow>
<EuiButton
fill
type="submit"
color="primary"
onClick={this.submitLoginForm}
isDisabled={!this.isLoadingState(LoadingStateType.None)}
isLoading={this.isLoadingState(LoadingStateType.Form)}
data-test-subj="loginSubmit"
>
<FormattedMessage
id="xpack.security.login.basicLoginForm.logInButtonLabel"
defaultMessage="Log in"
/>
</EuiButton>
</form>
</EuiPanel>
);
};
private renderLoginAssistanceMessage = () => {
if (!this.props.loginAssistanceMessage) {
return null;
}
return (
<Fragment>
<EuiText size="s">
<ReactMarkdown>{this.props.loginAssistanceMessage}</ReactMarkdown>
</EuiText>
</Fragment>
);
};
private renderMessage = () => {
const { message } = this.state;
if (message.type === MessageType.Danger) {
return (
<Fragment>
<EuiCallOut
size="s"
color="danger"
data-test-subj="loginErrorMessage"
title={message.content}
role="alert"
/>
<EuiSpacer size="l" />
</Fragment>
);
}
if (message.type === MessageType.Info) {
return (
<Fragment>
<EuiCallOut
size="s"
color="primary"
data-test-subj="loginInfoMessage"
title={message.content}
role="status"
/>
<EuiSpacer size="l" />
</Fragment>
);
}
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 && (
<>
<EuiText textAlign="center" color="subdued">
&nbsp;&nbsp;
<FormattedMessage id="xpack.security.loginPage.loginSelectorOR" defaultMessage="OR" />
&nbsp;&nbsp;
</EuiText>
<EuiSpacer size="m" />
</>
);
return (
<>
{this.props.selector.providers.map((provider, index) => (
<Fragment key={index}>
<EuiButton
key={provider.name}
fullWidth={true}
isDisabled={!this.isLoadingState(LoadingStateType.None)}
isLoading={this.isLoadingState(LoadingStateType.Selector, provider.name)}
onClick={() => this.loginWithSelector(provider.type, provider.name)}
>
{provider.description ?? (
<FormattedMessage
id="xpack.security.loginPage.loginProviderDescription"
defaultMessage="Login with {providerType}/{providerName}"
values={{
providerType: provider.type,
providerName: provider.name,
}}
/>
)}
</EuiButton>
<EuiSpacer size="m" />
</Fragment>
))}
{loginSelectorAndLoginFormSeparator}
</>
);
};
private setUsernameInputRef(ref: HTMLInputElement) {
if (ref) {
ref.focus();
}
}
private isFormValid = () => {
const { username, password } = this.state;
return username && password;
};
private onUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
username: e.target.value,
});
};
private onPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
password: e.target.value,
});
};
private submitLoginForm = async (
e: MouseEvent<HTMLButtonElement> | FormEvent<HTMLFormElement>
) => {
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
);
}
}

View file

@ -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,
});
});
});

View file

@ -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,
});
},
});

View file

@ -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<LoginState>) => {
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(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
@ -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(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={true}
/>
);
@ -100,9 +103,9 @@ describe('LoginPage', () => {
const wrapper = shallow(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
@ -121,9 +124,9 @@ describe('LoginPage', () => {
const wrapper = shallow(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
@ -144,9 +147,30 @@ describe('LoginPage', () => {
const wrapper = shallow(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
/>
);
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(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
@ -167,9 +191,9 @@ describe('LoginPage', () => {
const wrapper = shallow(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
@ -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(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
@ -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(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage="This is an *important* message"
requiresSecureConnection={false}
/>
);
@ -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(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);
@ -261,9 +285,9 @@ describe('LoginPage', () => {
const wrapper = shallow(
<LoginPage
http={httpMock}
notifications={coreStartMock.notifications}
fatalErrors={coreStartMock.fatalErrors}
loginAssistanceMessage=""
requiresSecureConnection={false}
/>
);

View file

@ -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<Props, State> {
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<Props, State> {
}
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<Props, State> {
</header>
<div className={contentBodyClasses}>
<EuiFlexGroup gutterSize="l">
<EuiFlexItem>{this.getLoginForm({ isSecureConnection, layout })}</EuiFlexItem>
<EuiFlexItem>{this.getLoginForm({ ...loginState, isSecureConnection })}</EuiFlexItem>
</EuiFlexGroup>
</div>
</div>
@ -119,13 +116,34 @@ export class LoginPage extends Component<Props, State> {
}
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 (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.noLoginMethodsAvailableTitle"
defaultMessage="Login is disabled."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.noLoginMethodsAvailableMessage"
defaultMessage="Contact your system administrator."
/>
}
/>
);
}
if (requiresSecureConnection && !isSecureConnection) {
return (
<DisabledLoginForm
title={
@ -144,69 +162,73 @@ export class LoginPage extends Component<Props, State> {
);
}
switch (layout) {
case 'form':
return (
<BasicLoginForm
http={this.props.http}
infoMessage={infoMessageMap.get(
parse(window.location.href, true).query.msg?.toString()
)}
loginAssistanceMessage={this.props.loginAssistanceMessage}
/>
);
case 'error-es-unavailable':
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.esUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster"
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.esUnavailableMessage"
defaultMessage="See the Kibana logs for details and try reloading the page."
/>
}
/>
);
case 'error-xpack-unavailable':
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.xpackUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster currently configured for Kibana."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.xpackUnavailableMessage"
defaultMessage="To use the full set of free features in this distribution of Kibana, please update Elasticsearch to the default distribution."
/>
}
/>
);
default:
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.unknownLayoutTitle"
defaultMessage="Unsupported login form layout."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.unknownLayoutMessage"
defaultMessage="Refer to the Kibana logs for more details and refresh to try again."
/>
}
/>
);
if (layout === 'error-es-unavailable') {
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.esUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster"
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.esUnavailableMessage"
defaultMessage="See the Kibana logs for details and try reloading the page."
/>
}
/>
);
}
if (layout === 'error-xpack-unavailable') {
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.xpackUnavailableTitle"
defaultMessage="Cannot connect to the Elasticsearch cluster currently configured for Kibana."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.xpackUnavailableMessage"
defaultMessage="To use the full set of free features in this distribution of Kibana, please update Elasticsearch to the default distribution."
/>
}
/>
);
}
if (layout !== 'form') {
return (
<DisabledLoginForm
title={
<FormattedMessage
id="xpack.security.loginPage.unknownLayoutTitle"
defaultMessage="Unsupported login form layout."
/>
}
message={
<FormattedMessage
id="xpack.security.loginPage.unknownLayoutMessage"
defaultMessage="See the Kibana logs for details and try reloading the page."
/>
}
/>
);
}
return (
<LoginForm
http={this.props.http}
notifications={this.props.notifications}
showLoginForm={showLoginForm}
selector={selector}
infoMessage={infoMessageMap.get(parse(window.location.href, true).query.msg?.toString())}
loginAssistanceMessage={this.props.loginAssistanceMessage}
/>
);
};
}

View file

@ -5,6 +5,7 @@
*/
jest.mock('./providers/basic');
jest.mock('./providers/token');
jest.mock('./providers/saml');
jest.mock('./providers/http');
@ -20,33 +21,32 @@ 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, unknown> | string[];
http?: Partial<AuthenticatorOptions['config']['authc']['http']>;
selector?: AuthenticatorOptions['config']['authc']['selector'];
} = {}) {
return {
clusterClient: elasticsearchServiceMock.createClusterClient(),
basePath: httpServiceMock.createSetupContract().basePath,
loggers: loggingServiceMock.create(),
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<ProviderSession>(),
};
}
@ -56,26 +56,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(() => mockBasicAuthenticationProvider);
jest.requireMock('./providers/basic').BasicAuthenticationProvider.mockImplementation(() => ({
type: 'basic',
...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.'
);
});
@ -85,11 +95,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'),
}));
});
@ -97,9 +115,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
@ -110,11 +128,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
@ -125,11 +145,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
@ -138,10 +161,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
@ -156,14 +182,15 @@ describe('Authenticator', () => {
let mockSessionStorage: jest.Mocked<SessionStorage<ProviderSession>>;
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,
};
@ -176,17 +203,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.'
);
});
@ -198,9 +234,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 () => {
@ -211,7 +247,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 .....' } })
);
});
@ -225,9 +263,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({
@ -238,9 +276,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<PublicMethodsOf<SAMLAuthenticationProvider>>;
let mockSAMLAuthenticationProvider2: jest.Mocked<PublicMethodsOf<SAMLAuthenticationProvider>>;
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 () => {
@ -249,10 +449,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(
@ -265,17 +468,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();
@ -288,14 +541,15 @@ describe('Authenticator', () => {
let mockSessionStorage: jest.Mocked<SessionStorage<ProviderSession>>;
let mockSessVal: any;
beforeEach(() => {
mockOptions = getMockOptions({ providers: ['basic'] });
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
mockSessionStorage = sessionStorageMock.create<ProviderSession>();
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,
};
@ -430,7 +684,7 @@ describe('Authenticator', () => {
idleTimeout: duration(3600 * 24),
lifespan: null,
},
providers: ['basic'],
providers: { basic: { basic1: { order: 0 } } },
});
mockSessionStorage = sessionStorageMock.create();
@ -469,7 +723,7 @@ describe('Authenticator', () => {
idleTimeout: duration(hr * 2),
lifespan: duration(hr * 8),
},
providers: ['basic'],
providers: { basic: { basic1: { order: 0 } } },
});
mockSessionStorage = sessionStorageMock.create();
@ -521,7 +775,7 @@ describe('Authenticator', () => {
idleTimeout: null,
lifespan,
},
providers: ['basic'],
providers: { basic: { basic1: { order: 0 } } },
});
mockSessionStorage = sessionStorageMock.create();
@ -703,14 +957,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(
@ -726,9 +999,6 @@ describe('Authenticator', () => {
headers: { 'kbn-system-request': 'false' },
});
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.notHandled()
);
mockSessionStorage.get.mockResolvedValue(mockSessVal);
await expect(authenticator.authenticate(request)).resolves.toEqual(
@ -744,10 +1014,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()
@ -762,10 +1032,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()
@ -774,6 +1044,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', () => {
@ -782,14 +1116,14 @@ describe('Authenticator', () => {
let mockSessionStorage: jest.Mocked<SessionStorage<ProviderSession>>;
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,
};
@ -805,6 +1139,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()
@ -829,7 +1164,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(
@ -855,16 +1190,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();
});
});
@ -874,7 +1213,7 @@ describe('Authenticator', () => {
let mockOptions: ReturnType<typeof getMockOptions>;
let mockSessionStorage: jest.Mocked<SessionStorage<ProviderSession>>;
beforeEach(() => {
mockOptions = getMockOptions({ providers: ['basic'] });
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
mockSessionStorage = sessionStorageMock.create();
mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage);
@ -889,13 +1228,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);
@ -917,13 +1256,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);
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', 'saml'] }));
expect(authenticator.isProviderEnabled('basic')).toBe(true);
expect(authenticator.isProviderEnabled('saml')).toBe(true);
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);
});
});
});

View file

@ -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.
@ -115,11 +116,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.
@ -194,29 +226,22 @@ export class Authenticator {
}),
};
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<string, any>)[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];
];
})
);
@ -225,11 +250,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;
@ -245,60 +277,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<string, string>).type}"`
} is detected, but it isn't enabled.`
);
return AuthenticationResult.notHandled();
}
this.logger.debug(`Performing login using "${attempt.provider}" provider.`);
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;
const sessionStorage = this.options.sessionStorageFactory.asScoped(request);
// 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();
}
/**
@ -311,33 +341,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)) {
// Check if current session has been set by this provider.
const ownsSession = existingSession && existingSession.provider === providerType;
// 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}`
)}`
);
}
authenticationResult = await provider.authenticate(
for (const [providerName, provider] of this.providerIterator(existingSession)) {
// Check if current session has been set by this provider.
const ownsSession =
existingSession?.provider.name === providerName &&
existingSession?.provider.type === provider.type;
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();
}
/**
@ -349,28 +392,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();
@ -393,7 +441,7 @@ export class Authenticator {
now: Date.now(),
idleTimeoutExpiration: sessionValue.idleTimeoutExpiration,
lifespanExpiration: sessionValue.lifespanExpiration,
provider: sessionValue.provider,
provider: sessionValue.provider.name,
};
}
return null;
@ -403,8 +451,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);
}
/**
@ -428,10 +476,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 }));
}
/**
@ -447,11 +496,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];
}
}
}
@ -463,14 +512,19 @@ export class Authenticator {
* @param sessionStorage Session storage instance.
*/
private async getSessionValue(sessionStorage: SessionStorage<ProviderSession>) {
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;
@ -479,12 +533,12 @@ export class Authenticator {
private updateSessionValue(
sessionStorage: SessionStorage<ProviderSession>,
{
providerType,
provider,
authenticationResult,
existingSession,
isSystemRequest,
}: {
providerType: string;
provider: { type: string; name: string };
authenticationResult: AuthenticationResult;
existingSession: ProviderSession | null;
isSystemRequest: boolean;
@ -515,7 +569,7 @@ export class Authenticator {
state: authenticationResult.shouldUpdateState()
? authenticationResult.state
: existingSession!.state,
provider: providerType,
provider,
idleTimeoutExpiration,
lifespanExpiration,
path: this.serverBasePath,

View file

@ -10,7 +10,7 @@ export const authenticationMock = {
create: (): jest.Mocked<Authentication> => ({
login: jest.fn(),
logout: jest.fn(),
isProviderEnabled: jest.fn(),
isProviderTypeEnabled: jest.fn(),
createAPIKey: jest.fn(),
getCurrentUser: jest.fn(),
grantAPIKeyAsInternalUser: jest.fn(),

View file

@ -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,23 +50,18 @@ describe('setupAuthentication()', () => {
license: jest.Mocked<SecurityLicense>;
};
let mockScopedClusterClient: jest.Mocked<PublicMethodsOf<ScopedClusterClient>>;
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 } },
}),
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(),

View file

@ -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,
@ -169,7 +169,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),

View file

@ -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');
@ -23,5 +23,6 @@ export function mockAuthenticationProviderOptions() {
logger: loggingServiceMock.create().get(),
basePath,
tokens: { refresh: jest.fn(), invalidate: jest.fn() },
name: options?.name ?? 'basic1',
};
}

View file

@ -21,6 +21,7 @@ import { Tokens } from '../tokens';
* Represents available provider options.
*/
export interface AuthenticationProviderOptions {
name: string;
basePath: HttpServiceSetup['basePath'];
client: IClusterClient;
logger: Logger;
@ -41,6 +42,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.
*/
@ -102,9 +109,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);
}
}

View file

@ -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')

View file

@ -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();
}

View file

@ -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', () => {

View file

@ -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';

View file

@ -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<AuthenticationResult>
) {
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', () => {

View file

@ -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 {

View file

@ -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<IClusterClient>,
@ -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')
);

View file

@ -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<TokenPair> {
* 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);

View file

@ -19,6 +19,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions }
import {
ElasticsearchErrorHelpers,
IClusterClient,
KibanaRequest,
ScopeableRequest,
} from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
@ -78,13 +79,174 @@ describe('PKIAuthenticationProvider', () => {
let provider: PKIAuthenticationProvider;
let mockOptions: MockAuthenticationProviderOptions;
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions();
mockOptions = mockAuthenticationProviderOptions({ name: 'pki' });
provider = new PKIAuthenticationProvider(mockOptions);
});
afterEach(() => jest.clearAllMocks());
function defineCommonLoginAndAuthenticateTests(
operation: (request: KibanaRequest) => Promise<AuthenticationResult>
) {
it('does not handle requests without certificate.', async () => {
const request = httpServerMock.createKibanaRequest({
socket: getMockSocket({ authorized: true }),
});
await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
});
it('does not handle unauthorized requests.', async () => {
const request = httpServerMock.createKibanaRequest({
socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }),
});
await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
});
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({
headers: {},
socket: getMockSocket({
authorized: true,
peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
}),
});
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' });
await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{
authHeaders: { authorization: 'Bearer access-token' },
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
}
)
);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', {
body: {
x509_certificate_chain: [
'fingerprint:2A:7A:C2:DD:base64',
'fingerprint:3B:8B:D3:EE:base64',
],
},
});
expectAuthenticateCall(mockOptions.client, {
headers: { authorization: 'Bearer access-token' },
});
expect(request.headers).not.toHaveProperty('authorization');
});
it('gets an access token in exchange to a self-signed certificate and stores it in the state.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({
headers: {},
socket: getMockSocket({
authorized: true,
peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
}),
});
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' });
await expect(operation(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{
authHeaders: { authorization: 'Bearer access-token' },
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
}
)
);
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');
});
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' },
@ -117,25 +279,15 @@ describe('PKIAuthenticationProvider', () => {
expect(request.headers.authorization).toBe('Bearer some-token');
});
it('does not handle requests without certificate.', async () => {
it('does not exchange peer certificate to access token if request does not require authentication.', async () => {
const request = httpServerMock.createKibanaRequest({
socket: getMockSocket({ authorized: true }),
routeAuthRequired: false,
socket: getMockSocket({
authorized: true,
peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
}),
});
await expect(provider.authenticate(request, null)).resolves.toEqual(
AuthenticationResult.notHandled()
);
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
});
it('does not handle unauthorized requests.', async () => {
const request = httpServerMock.createKibanaRequest({
socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }),
});
await expect(provider.authenticate(request, null)).resolves.toEqual(
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.notHandled()
);
@ -187,85 +339,6 @@ describe('PKIAuthenticationProvider', () => {
});
});
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({
headers: {},
socket: getMockSocket({
authorized: true,
peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
}),
});
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' });
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{
authHeaders: { authorization: 'Bearer access-token' },
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
}
)
);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', {
body: {
x509_certificate_chain: [
'fingerprint:2A:7A:C2:DD:base64',
'fingerprint:3B:8B:D3:EE:base64',
],
},
});
expectAuthenticateCall(mockOptions.client, {
headers: { authorization: 'Bearer access-token' },
});
expect(request.headers).not.toHaveProperty('authorization');
});
it('gets an access token in exchange to a self-signed certificate and stores it in the state.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({
headers: {},
socket: getMockSocket({
authorized: true,
peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
}),
});
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' });
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.succeeded(
{ ...user, authentication_provider: 'pki' },
{
authHeaders: { authorization: 'Bearer access-token' },
state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
}
)
);
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');
});
it('invalidates existing token and gets a new one if fingerprints do not match.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({
@ -351,6 +424,30 @@ describe('PKIAuthenticationProvider', () => {
expect(request.headers).not.toHaveProperty('authorization');
});
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.mockRejectedValueOnce(
ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error())
);
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
await expect(provider.authenticate(request, state)).resolves.toEqual(
AuthenticationResult.notHandled()
);
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
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() });
const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
@ -370,60 +467,6 @@ 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(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'),
}),
});
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(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'] },
});
expectAuthenticateCall(mockOptions.client, {
headers: { authorization: 'Bearer access-token' },
});
expect(request.headers).not.toHaveProperty('authorization');
});
it('succeeds if state contains a valid token.', async () => {
const user = mockAuthenticatedUser();
const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };

View file

@ -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;
}

View file

@ -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<IClusterClient>,
@ -36,7 +36,7 @@ describe('SAMLAuthenticationProvider', () => {
let provider: SAMLAuthenticationProvider;
let mockOptions: MockAuthenticationProviderOptions;
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions();
mockOptions = mockAuthenticationProviderOptions({ name: 'saml' });
provider = new SAMLAuthenticationProvider(mockOptions, {
realm: 'test-realm',
maxRedirectURLSize: new ByteSizeValue(100),
@ -86,8 +86,12 @@ describe('SAMLAuthenticationProvider', () => {
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', {
@ -95,6 +99,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
realm: 'test-realm',
},
})
);
@ -111,8 +116,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(
@ -123,6 +128,26 @@ 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();
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(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
});
it('redirects to the default location if state contains empty redirect URL.', async () => {
const request = httpServerMock.createKibanaRequest();
@ -134,14 +159,15 @@ describe('SAMLAuthenticationProvider', () => {
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: {
accessToken: 'user-initiated-login-token',
refreshToken: 'user-initiated-login-refresh-token',
realm: 'test-realm',
},
})
);
@ -162,7 +188,7 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(request, {
step: SAMLLoginStep.SAMLResponseReceived,
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
})
).resolves.toEqual(
@ -170,6 +196,7 @@ describe('SAMLAuthenticationProvider', () => {
state: {
accessToken: 'idp-initiated-login-token',
refreshToken: 'idp-initiated-login-refresh-token',
realm: 'test-realm',
},
})
);
@ -189,8 +216,12 @@ 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));
@ -201,7 +232,7 @@ describe('SAMLAuthenticationProvider', () => {
});
describe('IdP initiated login with existing session', () => {
it('fails if new SAML Response is rejected.', async () => {
it('returns `notHandled` if new SAML Response is rejected.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const authorization = 'Bearer some-valid-token';
@ -216,14 +247,15 @@ 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));
).resolves.toEqual(AuthenticationResult.notHandled());
expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
@ -241,6 +273,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;
@ -261,7 +294,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));
@ -288,6 +321,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;
@ -307,7 +341,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(
@ -316,6 +350,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'new-valid-token',
refreshToken: 'new-valid-refresh-token',
realm: 'test-realm',
},
})
);
@ -342,6 +377,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;
@ -361,7 +397,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(
@ -370,6 +406,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'new-user',
accessToken: 'new-valid-token',
refreshToken: 'new-valid-refresh-token',
realm: 'test-realm',
},
})
);
@ -392,41 +429,24 @@ 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' } });
await expect(
provider.login(
request,
{
step: SAMLLoginStep.RedirectURLFragmentCaptured,
redirectURLFragment: '#some-fragment',
},
{ redirectURL: '/test-base-path/some-path' }
)
).resolves.toEqual(AuthenticationResult.notHandled());
expect(mockOptions.client.callAsInternalUser).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.', async () => {
const request = httpServerMock.createKibanaRequest();
mockOptions.client.callAsInternalUser.mockResolvedValue({
@ -438,10 +458,10 @@ describe('SAMLAuthenticationProvider', () => {
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(
@ -450,6 +470,45 @@ describe('SAMLAuthenticationProvider', () => {
state: {
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path#some-fragment',
realm: 'test-realm',
},
}
)
);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', {
body: { realm: 'test-realm' },
});
expect(mockOptions.logger.warn).not.toHaveBeenCalled();
});
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',
});
await expect(
provider.login(
request,
{
type: SAMLLogin.LoginInitiatedByUser,
redirectURLPath: '/test-base-path/some-path',
redirectURLFragment: '#some-fragment',
},
null
)
).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',
},
}
)
@ -474,10 +533,10 @@ describe('SAMLAuthenticationProvider', () => {
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(
@ -486,6 +545,7 @@ describe('SAMLAuthenticationProvider', () => {
state: {
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path#../some-fragment',
realm: 'test-realm',
},
}
)
@ -501,7 +561,7 @@ 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({
@ -513,10 +573,10 @@ describe('SAMLAuthenticationProvider', () => {
provider.login(
request,
{
step: SAMLLoginStep.RedirectURLFragmentCaptured,
type: SAMLLogin.LoginInitiatedByUser,
redirectURLFragment: '#some-fragment'.repeat(10),
},
{ redirectURL: '/test-base-path/some-path' }
{ redirectURL: '/test-base-path/some-path', realm: 'test-realm' }
)
).resolves.toEqual(
AuthenticationResult.redirectTo(
@ -525,6 +585,7 @@ describe('SAMLAuthenticationProvider', () => {
state: {
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path',
realm: 'test-realm',
},
}
)
@ -540,6 +601,40 @@ describe('SAMLAuthenticationProvider', () => {
);
});
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',
});
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: { realm: 'test-realm' },
});
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();
@ -550,10 +645,10 @@ describe('SAMLAuthenticationProvider', () => {
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.failed(failureReason));
@ -573,6 +668,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' },
@ -596,6 +698,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
realm: 'test-realm',
})
).resolves.toEqual(AuthenticationResult.notHandled());
@ -613,8 +716,8 @@ describe('SAMLAuthenticationProvider', () => {
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.redirectTo(
'/mock-server-basepath/api/security/saml/capture-url-fragment',
{ state: { redirectURL: '/base-path/s/foo/some-path' } }
'/mock-server-basepath/internal/security/saml/capture-url-fragment',
{ state: { redirectURL: '/base-path/s/foo/some-path', realm: 'test-realm' } }
)
);
@ -634,7 +737,7 @@ describe('SAMLAuthenticationProvider', () => {
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' } }
)
);
@ -672,6 +775,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;
@ -697,6 +801,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;
@ -721,6 +826,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'valid-refresh-token',
realm: 'test-realm',
};
mockOptions.client.asScoped.mockImplementation(scopeableRequest => {
@ -755,6 +861,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
realm: 'test-realm',
},
}
)
@ -772,6 +879,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'invalid-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;
@ -805,6 +913,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'expired-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;
@ -830,12 +939,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}`;
@ -849,8 +991,8 @@ describe('SAMLAuthenticationProvider', () => {
await expect(provider.authenticate(request, state)).resolves.toEqual(
AuthenticationResult.redirectTo(
'/mock-server-basepath/api/security/saml/capture-url-fragment',
{ state: { redirectURL: '/base-path/s/foo/some-path' } }
'/mock-server-basepath/internal/security/saml/capture-url-fragment',
{ state: { redirectURL: '/base-path/s/foo/some-path', realm: 'test-realm' } }
)
);
@ -871,6 +1013,7 @@ describe('SAMLAuthenticationProvider', () => {
username: 'user',
accessToken: 'expired-token',
refreshToken: 'expired-refresh-token',
realm: 'test-realm',
};
const authorization = `Bearer ${state.accessToken}`;
@ -890,7 +1033,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' } }
)
);
@ -908,6 +1051,17 @@ 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();
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', () => {
@ -934,7 +1088,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);
@ -967,7 +1126,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')
);
@ -986,7 +1150,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')
);
@ -1007,7 +1176,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')
);
@ -1028,6 +1202,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')
@ -1079,7 +1254,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')
);
@ -1099,6 +1279,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')

View file

@ -32,37 +32,53 @@ interface ProviderState extends Partial<TokenPair> {
* 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.
*/
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.
*/
@ -113,31 +129,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?.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;
@ -186,6 +194,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?.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);
@ -199,7 +215,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;
}
@ -212,15 +228,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)
const redirect = isIdPInitiatedSLO
? await this.performIdPInitiatedSingleLogout(request)
: await this.performUserInitiatedSingleLogout(state!.accessToken!, state!.refreshToken!);
: 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
@ -283,8 +307,9 @@ 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.'
);
@ -298,7 +323,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
refresh_token: refreshToken,
} = await this.options.client.callAsInternalUser('shield.samlAuthenticate', {
body: {
ids: stateRequestId ? [stateRequestId] : [],
ids: !isIdPInitiatedLogin ? [stateRequestId] : [],
content: samlResponse,
realm: this.realm,
},
@ -307,11 +332,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
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: this.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
? AuthenticationResult.notHandled()
: AuthenticationResult.failed(err);
}
}
@ -336,7 +367,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// 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;
}
@ -434,7 +465,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.'
);
@ -458,7 +489,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, ...refreshedTokenPair },
});
} catch (err) {
this.logger.debug(
@ -476,12 +507,6 @@ 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 {
// 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`.
@ -495,7 +520,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: this.realm },
});
} catch (err) {
this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`);
return AuthenticationResult.failed(err);
@ -545,18 +572,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.`
@ -564,9 +596,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);
}
}

View file

@ -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();

View file

@ -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.

View file

@ -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,
@ -54,9 +66,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,
@ -83,9 +108,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,
@ -148,6 +186,7 @@ describe('config schema', () => {
"providers": Array [
"oidc",
],
"selector": Object {},
}
`);
});
@ -181,6 +220,7 @@ describe('config schema', () => {
"oidc",
"basic",
],
"selector": Object {},
}
`);
});
@ -228,6 +268,7 @@ describe('config schema', () => {
},
"realm": "realm-1",
},
"selector": Object {},
}
`);
});
@ -305,27 +346,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",
@ -335,10 +825,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.",
@ -348,10 +839,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.",
@ -361,9 +855,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",
},
]
`);
});
});

View file

@ -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<typeof createConfig$> extends Observable<infer P>
? P
: ReturnType<typeof createConfig$>;
export type ConfigType = ReturnType<typeof createConfig>;
const providerOptionsSchema = (providerType: string, optionsSchema: Type<any>) =>
schema.conditional(
@ -24,6 +20,114 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type<any>) =
schema.never()
);
type ProvidersCommonConfigType = Record<
'enabled' | 'showInSelector' | 'order' | 'description',
Type<any>
>;
function getCommonProviderSchemaProperties(overrides: Partial<ProvidersCommonConfigType> = {}) {
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<ProvidersCommonConfigType>
) {
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<typeof providersConfigSchema>;
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<number, string>(), sameName: new Map<string, string>() };
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: '' }),
@ -40,7 +144,17 @@ export const ConfigSchema = schema.object({
}),
secureCookies: schema.boolean({ defaultValue: false }),
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',
@ -60,42 +174,96 @@ export const ConfigSchema = schema.object({
}),
});
export function createConfig$(context: PluginInitializerContext, isTLSEnabled: boolean) {
return context.config.create<TypeOf<typeof ConfigSchema>>().pipe(
map(config => {
const logger = context.logger.get('config');
export function createConfig(
config: TypeOf<typeof ConfigSchema>,
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;
}
const isUsingLegacyProvidersFormat = Array.isArray(config.authc.providers);
const providers = (isUsingLegacyProvidersFormat
? [...new Set(config.authc.providers as Array<keyof ProvidersConfigType>)].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<string, unknown>
)
: 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 },
});
}
}
}
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;
}
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,
};
}

View file

@ -23,6 +23,8 @@ export {
CreateAPIKeyResult,
InvalidateAPIKeyParams,
InvalidateAPIKeyResult,
SAMLLogin,
OIDCLogin,
} from './authentication';
export { SecurityPluginSetup };
export { AuthenticatedUser } from '../common/model';
@ -32,11 +34,29 @@ export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
deprecations: ({ rename, unused }) => [
rename('sessionTimeout', 'session.idleTimeout'),
unused('authorization.legacyFallback.enabled'),
// Deprecation warning for the old array-based format of `xpack.security.authc.providers`.
(settings, fromPath, log) => {
const hasProvider = (provider: string) =>
settings?.xpack?.security?.authc?.providers?.includes(provider) ?? false;
if (Array.isArray(settings?.xpack?.security?.authc?.providers)) {
log(
'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.'
);
}
if (hasProvider('basic') && hasProvider('token')) {
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(
'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.'
);

View file

@ -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],
},

View file

@ -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<TypeOf<typeof ConfigSchema>>().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 },
},
});
}

View file

@ -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' },
});
});

View file

@ -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);

View file

@ -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<any, any, any>;
let routeConfig: RouteConfig<any, any, any, any>;
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<any>;
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' },
});
});
});
});

View file

@ -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();
}
})
);
}

View file

@ -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);
}
}

View file

@ -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,
});

View file

@ -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';
@ -37,7 +37,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,
@ -84,9 +84,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',
},
});
@ -163,9 +163,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',
},
});

View file

@ -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
<!DOCTYPE html>
<title>Kibana SAML Login</title>
<link rel="icon" href="data:,">
<script src="${basePath.serverBasePath}/api/security/saml/capture-url-fragment.js"></script>
<script src="${basePath.serverBasePath}/internal/security/saml/capture-url-fragment.js"></script>
`,
'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,
},
});
@ -97,17 +98,14 @@ 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 {
// When authenticating using SAML we _expect_ to redirect to the SAML Identity provider.
// When authenticating using SAML we _expect_ to redirect to the Kibana target location.
const authenticationResult = await authc.login(request, {
provider: 'saml',
value: {
step: SAMLLoginStep.SAMLResponseReceived,
samlResponse: request.body.SAMLResponse,
},
provider: { type: SAMLAuthenticationProvider.type },
value: { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: request.body.SAMLResponse },
});
if (authenticationResult.redirected()) {

View file

@ -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<string, unknown> = {}) => ({
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(),

View file

@ -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' },
});
});

View file

@ -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 },
});

View file

@ -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);

View file

@ -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);
}

View file

@ -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<Authentication>;
let router: jest.Mocked<IRouter>;
let license: jest.Mocked<SecurityLicense>;
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: { allowLogin: true, layout: 'form', showLogin: true } },
payload: { allowLogin: true, layout: 'form', showLogin: true },
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: 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,
});
}
});
});
});

View file

@ -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 });
}
);
}

View file

@ -26,6 +26,7 @@ const onlyNotInCoverageTests = [
require.resolve('../test/oidc_api_integration/config.ts'),
require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'),
require.resolve('../test/pki_api_integration/config.ts'),
require.resolve('../test/login_selector_api_integration/config.ts'),
require.resolve('../test/spaces_api_integration/spaces_only/config.ts'),
require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial.ts'),
require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic.ts'),

View file

@ -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'];

View file

@ -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==';
}

View file

@ -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'));
});
}

View file

@ -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');
});
});
});
}

View file

@ -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',
]),
],
},
};
}

View file

@ -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<typeof services, {}>;

View file

@ -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,
};

View file

@ -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';

View file

@ -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';

View file

@ -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);

View file

@ -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/saml/callback`,

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="http://www.elastic.co">
entityID="http://www.elastic.co/saml1">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="http://www.elastic.co/saml2">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<!-- This certificate is extracted from KBN_CERT_PATH in @kbn/dev-utils and should always be in sync with it -->
<ds:X509Certificate>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==
</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://elastic.co/slo/saml"/>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://elastic.co/slo/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://elastic.co/sso/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://elastic.co/sso/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>

View file

@ -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 = `
<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0"
ID="_RPs1WfOkul8lZ72DtJtes0BKyPgaCamg" IssueInstant="${issueInstant}">
<saml:Issuer>http://www.elastic.co</saml:Issuer>
<saml:Issuer>${issuer}</saml:Issuer>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">a@b.c</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
@ -99,7 +106,7 @@ export async function getSAMLResponse({
${inResponseTo ? `InResponseTo="${inResponseTo}"` : ''} Version="2.0"
IssueInstant="${issueInstant}"
Destination="${destination}">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">http://www.elastic.co</saml:Issuer>
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${issuer}</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>${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">
<Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">http://www.elastic.co</Issuer>
<Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">${issuer}</Issuer>
<NameID xmlns="urn:oasis:names:tc:SAML:2.0:assertion"
Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">a@b.c</NameID>
<samlp:SessionIndex>${sessionIndex}</samlp:SessionIndex>