mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Implement Kibana Login Selector (#53010)
This commit is contained in:
parent
dd93a14fef
commit
fa69765e4b
74 changed files with 5229 additions and 1418 deletions
|
@ -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') };
|
||||
},
|
||||
},
|
||||
|
||||
|
|
20
x-pack/plugins/security/common/login_state.ts
Normal file
20
x-pack/plugins/security/common/login_state.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -40,5 +40,5 @@ export function parseNext(href: string, basePath = '') {
|
|||
return `${basePath}/`;
|
||||
}
|
||||
|
||||
return query.next + (hash || '');
|
||||
return next + (hash || '');
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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';
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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">
|
||||
―――
|
||||
<FormattedMessage id="xpack.security.loginPage.loginSelectorOR" defaultMessage="OR" />
|
||||
―――
|
||||
</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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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')
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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' };
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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.'
|
||||
);
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
|
|
|
@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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==';
|
||||
}
|
14
x-pack/test/login_selector_api_integration/apis/index.ts
Normal file
14
x-pack/test/login_selector_api_integration/apis/index.ts
Normal 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'));
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
141
x-pack/test/login_selector_api_integration/config.ts
Normal file
141
x-pack/test/login_selector_api_integration/config.ts
Normal 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',
|
||||
]),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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, {}>;
|
14
x-pack/test/login_selector_api_integration/services.ts
Normal file
14
x-pack/test/login_selector_api_integration/services.ts
Normal 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,
|
||||
};
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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">
|
||||
|
|
41
x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml
Normal file
41
x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml
Normal 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>
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue