Handle 401 Unauthorized errors in a more user-friendly way (#94927)

This commit is contained in:
Aleh Zasypkin 2021-04-27 19:09:54 +02:00 committed by GitHub
parent 52a90e3dc9
commit 808959e316
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 1837 additions and 545 deletions

View file

@ -1377,6 +1377,10 @@ module.exports = {
['parent', 'sibling', 'index'],
],
pathGroups: [
{
pattern: '{**,.}/*.test.mocks',
group: 'unknown',
},
{
pattern: '{@kbn/**,src/**,kibana{,/**}}',
group: 'internal',

View file

@ -33,7 +33,7 @@ export class User {
public async delete(username: string) {
this.log.debug(`deleting user ${username}`);
const { data, status, statusText } = await await this.kbnClient.request({
const { data, status, statusText } = await this.kbnClient.request({
path: `/internal/security/users/${username}`,
method: 'DELETE',
});
@ -44,4 +44,32 @@ export class User {
}
this.log.debug(`deleted user ${username}`);
}
public async disable(username: string) {
this.log.debug(`disabling user ${username}`);
const { data, status, statusText } = await this.kbnClient.request({
path: `/internal/security/users/${encodeURIComponent(username)}/_disable`,
method: 'POST',
});
if (status !== 204) {
throw new Error(
`Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}`
);
}
this.log.debug(`disabled user ${username}`);
}
public async enable(username: string) {
this.log.debug(`enabling user ${username}`);
const { data, status, statusText } = await this.kbnClient.request({
path: `/internal/security/users/${encodeURIComponent(username)}/_enable`,
method: 'POST',
});
if (status !== 204) {
throw new Error(
`Expected status code of 204, received ${status} ${statusText}: ${util.inspect(data)}`
);
}
this.log.debug(`enabled user ${username}`);
}
}

View file

@ -13,6 +13,7 @@ node scripts/build_kibana_platform_plugins \
--scan-dir "$XPACK_DIR/test/plugin_api_perf/plugins" \
--scan-dir "$XPACK_DIR/test/licensing_plugin/plugins" \
--scan-dir "$XPACK_DIR/test/usage_collection/plugins" \
--scan-dir "$XPACK_DIR/test/security_functional/fixtures/common" \
--scan-dir "$KIBANA_DIR/examples" \
--scan-dir "$XPACK_DIR/examples" \
--workers 12 \

View file

@ -7,6 +7,7 @@
import { schema } from '@kbn/config-schema';
import Boom from '@hapi/boom';
import { ROUTE_TAG_CAN_REDIRECT } from '../../../security/server';
import { ReportingCore } from '../';
import { API_BASE_URL } from '../../common/constants';
import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing';
@ -198,6 +199,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) {
docId: schema.string({ minLength: 3 }),
}),
},
options: { tags: [ROUTE_TAG_CAN_REDIRECT] },
},
userHandler(async (user, context, req, res) => {
// ensure the async dependencies are loaded

View file

@ -19,7 +19,22 @@ export const GLOBAL_RESOURCE = '*';
export const APPLICATION_PREFIX = 'kibana-';
export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*';
/**
* This is the key of a query parameter that contains the name of the authentication provider that should be used to
* authenticate request. It's also used while the user is being redirected during single-sign-on authentication flows.
* That query parameter is discarded after the authentication flow succeeds. See the `Authenticator`,
* `OIDCAuthenticationProvider`, and `SAMLAuthenticationProvider` classes for more information.
*/
export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint';
/**
* This is the key of a query parameter that contains metadata about the (client-side) URL hash while the user is being
* redirected during single-sign-on authentication flows. That query parameter is discarded after the authentication
* flow succeeds. See the `Authenticator`, `OIDCAuthenticationProvider`, and `SAMLAuthenticationProvider` classes for
* more information.
*/
export const AUTH_URL_HASH_QUERY_STRING_PARAMETER = 'auth_url_hash';
export const LOGOUT_PROVIDER_QUERY_STRING_PARAMETER = 'provider';
export const LOGOUT_REASON_QUERY_STRING_PARAMETER = 'msg';
export const NEXT_URL_QUERY_STRING_PARAMETER = 'next';

View file

@ -5,19 +5,29 @@
* 2.0.
*/
import type { AppMount, ScopedHistory } from 'src/core/public';
import { coreMock, scopedHistoryMock } from 'src/core/public/mocks';
import { coreMock } from 'src/core/public/mocks';
import { captureURLApp } from './capture_url_app';
describe('captureURLApp', () => {
let mockLocationReplace: jest.Mock;
beforeAll(() => {
mockLocationReplace = jest.fn();
Object.defineProperty(window, 'location', {
value: { href: 'https://some-host' },
value: {
href: 'https://some-host',
hash: '#/?_g=()',
origin: 'https://some-host',
replace: mockLocationReplace,
},
writable: true,
});
});
beforeEach(() => {
mockLocationReplace.mockClear();
});
it('properly registers application', () => {
const coreSetupMock = coreMock.createSetup();
@ -42,34 +52,37 @@ describe('captureURLApp', () => {
it('properly handles captured URL', async () => {
window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent(
'/mock-base-path/app/home'
)}&providerType=saml&providerName=saml1#/?_g=()`;
'/mock-base-path/app/home?auth_provider_hint=saml1'
)}#/?_g=()`;
const coreSetupMock = coreMock.createSetup();
coreSetupMock.http.post.mockResolvedValue({ location: '/mock-base-path/app/home#/?_g=()' });
captureURLApp.create(coreSetupMock);
const [[{ mount }]] = coreSetupMock.application.register.mock.calls;
await (mount as AppMount)({
element: document.createElement('div'),
appBasePath: '',
onAppLeave: jest.fn(),
setHeaderActionMenu: jest.fn(),
history: (scopedHistoryMock.create() as unknown) as ScopedHistory,
});
await mount(coreMock.createAppMountParamters());
expect(coreSetupMock.http.post).toHaveBeenCalledTimes(1);
expect(coreSetupMock.http.post).toHaveBeenCalledWith('/internal/security/login', {
body: JSON.stringify({
providerType: 'saml',
providerName: 'saml1',
currentURL: `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent(
'/mock-base-path/app/home'
)}&providerType=saml&providerName=saml1#/?_g=()`,
}),
});
expect(mockLocationReplace).toHaveBeenCalledTimes(1);
expect(mockLocationReplace).toHaveBeenCalledWith(
'https://some-host/mock-base-path/app/home?auth_provider_hint=saml1&auth_url_hash=%23%2F%3F_g%3D%28%29#/?_g=()'
);
expect(coreSetupMock.fatalErrors.add).not.toHaveBeenCalled();
});
expect(window.location.href).toBe('/mock-base-path/app/home#/?_g=()');
it('properly handles open redirects', async () => {
window.location.href = `https://host.com/mock-base-path/internal/security/capture-url?next=${encodeURIComponent(
'https://evil.com/mock-base-path/app/home?auth_provider_hint=saml1'
)}#/?_g=()`;
const coreSetupMock = coreMock.createSetup();
captureURLApp.create(coreSetupMock);
const [[{ mount }]] = coreSetupMock.application.register.mock.calls;
await mount(coreMock.createAppMountParamters());
expect(mockLocationReplace).toHaveBeenCalledTimes(1);
expect(mockLocationReplace).toHaveBeenCalledWith(
'https://some-host/?auth_url_hash=%23%2F%3F_g%3D%28%29'
);
expect(coreSetupMock.fatalErrors.add).not.toHaveBeenCalled();
});
});

View file

@ -5,10 +5,11 @@
* 2.0.
*/
import { parse } from 'url';
import type { ApplicationSetup, FatalErrorsSetup, HttpSetup } from 'src/core/public';
import { AUTH_URL_HASH_QUERY_STRING_PARAMETER } from '../../../common/constants';
import { parseNext } from '../../../common/parse_next';
interface CreateDeps {
application: ApplicationSetup;
http: HttpSetup;
@ -22,20 +23,17 @@ interface CreateDeps {
* path segment into the `next` query string parameter (so that it's not lost during redirect). And
* since browsers preserve hash fragments during redirects (assuming redirect location doesn't
* specify its own hash fragment, which is true in our case) this app can capture both path and
* hash URL segments and send them back to the authentication provider via login endpoint.
* hash URL segments and re-try request sending hash fragment in a dedicated query string parameter.
*
* The flow can look like this:
* 1. User visits `/app/kibana#/management/elasticsearch` that initiates authentication.
* 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1`.
* 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch`.
* 4. The app captures full URL and sends it back as is via login endpoint:
* {
* providerType: 'saml',
* providerName: 'saml1',
* currentURL: 'https://kibana.com/internal/security/capture-url?next=%2Fapp%2Fkibana&providerType=saml&providerName=saml1#/management/elasticsearch'
* }
* 5. Login endpoint handler parses and validates `next` parameter, joins it with the hash segment
* and finally passes it to the provider that initiated capturing.
* 1. User visits `https://kibana.com/app/kibana#/management/elasticsearch` that initiates authentication.
* 2. Provider redirect user to `/internal/security/capture-url?next=%2Fapp%2Fkibana&auth_provider_hint=saml1`.
* 3. Browser preserves hash segment and users ends up at `/internal/security/capture-url?next=%2Fapp%2Fkibana&auth_provider_hint=saml1#/management/elasticsearch`.
* 4. The app reconstructs original URL, adds `auth_url_hash` query string parameter with the captured hash fragment and redirects user to:
* https://kibana.com/app/kibana?auth_provider_hint=saml1&auth_url_hash=%23%2Fmanagement%2Felasticsearch#/management/elasticsearch
* 5. Once Kibana receives this request, it immediately picks exactly the same provider to handle authentication (based on `auth_provider_hint=saml1`),
* and, since it has full URL now (original request path, query string and hash extracted from `auth_url_hash=%23%2Fmanagement%2Felasticsearch`),
* it can proceed to a proper authentication handshake.
*/
export const captureURLApp = Object.freeze({
id: 'security_capture_url',
@ -48,19 +46,14 @@ export const captureURLApp = Object.freeze({
appRoute: '/internal/security/capture-url',
async mount() {
try {
const { providerName, providerType } = parse(window.location.href, true).query ?? {};
if (!providerName || !providerType) {
fatalErrors.add(new Error('Provider to capture URL for is not specified.'));
return () => {};
}
const { location } = await http.post<{ location: string }>('/internal/security/login', {
body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }),
});
window.location.href = location;
const url = new URL(
parseNext(window.location.href, http.basePath.serverBasePath),
window.location.origin
);
url.searchParams.append(AUTH_URL_HASH_QUERY_STRING_PARAMETER, window.location.hash);
window.location.replace(url.toString());
} catch (err) {
fatalErrors.add(new Error('Cannot login with captured URL.'));
fatalErrors.add(new Error(`Cannot parse current URL: ${err && err.message}.`));
}
return () => {};

View file

@ -5,5 +5,5 @@
* 2.0.
*/
export { LoginForm } from './login_form';
export { LoginForm, LoginFormMessageType } from './login_form';
export { DisabledLoginForm } from './disabled_login_form';

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { LoginForm } from './login_form';
export { LoginForm, MessageType as LoginFormMessageType } from './login_form';

View file

@ -14,7 +14,7 @@ import ReactMarkdown from 'react-markdown';
import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test/jest';
import { coreMock } from 'src/core/public/mocks';
import { LoginForm, PageMode } from './login_form';
import { LoginForm, MessageType, PageMode } from './login_form';
function expectPageMode(wrapper: ReactWrapper, mode: PageMode) {
const assertions: Array<[string, boolean]> =
@ -90,7 +90,7 @@ describe('LoginForm', () => {
<LoginForm
http={coreStartMock.http}
notifications={coreStartMock.notifications}
infoMessage={'Hey this is an info message'}
message={{ type: MessageType.Info, content: 'Hey this is an info message' }}
loginAssistanceMessage=""
selector={{
enabled: false,
@ -152,7 +152,7 @@ describe('LoginForm', () => {
});
expect(wrapper.find(EuiCallOut).props().title).toEqual(
`Invalid username or password. Please try again.`
`Username or password is incorrect. Please try again.`
);
});

View file

@ -40,7 +40,7 @@ interface Props {
http: HttpStart;
notifications: NotificationsStart;
selector: LoginSelector;
infoMessage?: string;
message?: { type: MessageType.Danger | MessageType.Info; content: string };
loginAssistanceMessage: string;
loginHelp?: string;
authProviderHint?: string;
@ -66,7 +66,7 @@ enum LoadingStateType {
AutoLogin,
}
enum MessageType {
export enum MessageType {
None,
Info,
Danger,
@ -106,9 +106,7 @@ export class LoginForm extends Component<Props, State> {
loadingState: { type: LoadingStateType.None },
username: '',
password: '',
message: this.props.infoMessage
? { type: MessageType.Info, content: this.props.infoMessage }
: { type: MessageType.None },
message: this.props.message || { type: MessageType.None },
mode,
previousMode: mode,
};
@ -206,7 +204,7 @@ export class LoginForm extends Component<Props, State> {
>
<FormattedMessage
id="xpack.security.loginPage.loginSelectorLinkText"
defaultMessage="See more login options"
defaultMessage="More login options"
/>
</EuiButtonEmpty>
</EuiFlexItem>
@ -480,8 +478,8 @@ export class LoginForm extends Component<Props, State> {
const message =
(error as IHttpFetchError).response?.status === 401
? i18n.translate(
'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage',
{ defaultMessage: 'Invalid username or password. Please try again.' }
'xpack.security.login.basicLoginForm.usernameOrPasswordIsIncorrectErrorMessage',
{ defaultMessage: 'Username or password is incorrect. Please try again.' }
)
: i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', {
defaultMessage: 'Oops! Error. Try again.',

View file

@ -14,7 +14,7 @@ import { coreMock } from 'src/core/public/mocks';
import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants';
import type { LoginState } from '../../../common/login_state';
import { DisabledLoginForm, LoginForm } from './components';
import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components';
import { LoginPage } from './login_page';
const createLoginState = (options?: Partial<LoginState>) => {
@ -228,9 +228,12 @@ describe('LoginPage', () => {
resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot
});
const { authProviderHint, infoMessage } = wrapper.find(LoginForm).props();
const { authProviderHint, message } = wrapper.find(LoginForm).props();
expect(authProviderHint).toBe('basic1');
expect(infoMessage).toBe('Your session has timed out. Please log in again.');
expect(message).toEqual({
type: LoginFormMessageType.Info,
content: 'Your session has timed out. Please log in again.',
});
});
it('renders as expected when loginAssistanceMessage is set', async () => {

View file

@ -23,7 +23,7 @@ import {
LOGOUT_REASON_QUERY_STRING_PARAMETER,
} from '../../../common/constants';
import type { LoginState } from '../../../common/login_state';
import { DisabledLoginForm, LoginForm } from './components';
import { DisabledLoginForm, LoginForm, LoginFormMessageType } from './components';
interface Props {
http: HttpStart;
@ -36,18 +36,34 @@ interface State {
loginState: LoginState | null;
}
const infoMessageMap = new Map([
const messageMap = new Map([
[
'SESSION_EXPIRED',
i18n.translate('xpack.security.login.sessionExpiredDescription', {
defaultMessage: 'Your session has timed out. Please log in again.',
}),
{
type: LoginFormMessageType.Info,
content: i18n.translate('xpack.security.login.sessionExpiredDescription', {
defaultMessage: 'Your session has timed out. Please log in again.',
}),
},
],
[
'LOGGED_OUT',
i18n.translate('xpack.security.login.loggedOutDescription', {
defaultMessage: 'You have logged out of Elastic.',
}),
{
type: LoginFormMessageType.Info,
content: i18n.translate('xpack.security.login.loggedOutDescription', {
defaultMessage: 'You have logged out of Elastic.',
}),
},
],
[
'UNAUTHENTICATED',
{
type: LoginFormMessageType.Danger,
content: i18n.translate('xpack.security.unauthenticated.errorDescription', {
defaultMessage:
"We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator.",
}),
},
],
]);
@ -226,7 +242,7 @@ export class LoginPage extends Component<Props, State> {
notifications={this.props.notifications}
selector={selector}
// @ts-expect-error Map.get is ok with getting `undefined`
infoMessage={infoMessageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())}
message={messageMap.get(query[LOGOUT_REASON_QUERY_STRING_PARAMETER]?.toString())}
loginAssistanceMessage={this.props.loginAssistanceMessage}
loginHelp={loginHelp}
authProviderHint={query[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]?.toString()}

View file

@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PromptPage renders as expected with additional scripts 1`] = `"<html lang=\\"en\\"><head><title>Elastic</title><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.v7.light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/node_modules/@kbn/ui-framework/dist/kui_light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/ui/legacy_light_theme.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/mock-server-basepath/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/mock-server-basepath/ui/favicons/favicon.svg\\"/><script src=\\"/mock-basepath/some/script1.js\\"></script><script src=\\"/mock-basepath/some/script2.js\\"></script><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage euiPage--grow\\" style=\\"min-height:100vh\\" data-test-subj=\\"promptPage\\"><main class=\\"euiPageBody euiPageBody--borderRadiusNone\\"><div class=\\"euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter\\" role=\\"main\\"><div class=\\"euiEmptyPrompt\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span><div class=\\"euiSpacer euiSpacer--s\\"></div><span class=\\"euiTextColor euiTextColor--subdued\\"><h2 class=\\"euiTitle euiTitle--medium\\">Some Title</h2><div class=\\"euiSpacer euiSpacer--m\\"></div><div class=\\"euiText euiText--medium\\"><div>Some Body</div></div></span><div class=\\"euiSpacer euiSpacer--l\\"></div><div class=\\"euiSpacer euiSpacer--s\\"></div><div class=\\"euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentCenter euiFlexGroup--directionColumn euiFlexGroup--responsive\\"><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><span>Action#1</span></div><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><span>Action#2</span></div></div></div></div></main></div></body></html>"`;
exports[`PromptPage renders as expected without additional scripts 1`] = `"<html lang=\\"en\\"><head><title>Elastic</title><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.v7.light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/node_modules/@kbn/ui-framework/dist/kui_light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/ui/legacy_light_theme.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/mock-server-basepath/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/mock-server-basepath/ui/favicons/favicon.svg\\"/><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage euiPage--grow\\" style=\\"min-height:100vh\\" data-test-subj=\\"promptPage\\"><main class=\\"euiPageBody euiPageBody--borderRadiusNone\\"><div class=\\"euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter\\" role=\\"main\\"><div class=\\"euiEmptyPrompt\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span><div class=\\"euiSpacer euiSpacer--s\\"></div><span class=\\"euiTextColor euiTextColor--subdued\\"><h2 class=\\"euiTitle euiTitle--medium\\">Some Title</h2><div class=\\"euiSpacer euiSpacer--m\\"></div><div class=\\"euiText euiText--medium\\"><div>Some Body</div></div></span><div class=\\"euiSpacer euiSpacer--l\\"></div><div class=\\"euiSpacer euiSpacer--s\\"></div><div class=\\"euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentCenter euiFlexGroup--directionColumn euiFlexGroup--responsive\\"><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><span>Action#1</span></div><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><span>Action#2</span></div></div></div></div></main></div></body></html>"`;

View file

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UnauthenticatedPage renders as expected 1`] = `"<html lang=\\"en\\"><head><title>Elastic</title><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.v7.light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/node_modules/@kbn/ui-framework/dist/kui_light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/ui/legacy_light_theme.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/mock-server-basepath/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/mock-server-basepath/ui/favicons/favicon.svg\\"/><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage euiPage--grow\\" style=\\"min-height:100vh\\" data-test-subj=\\"promptPage\\"><main class=\\"euiPageBody euiPageBody--borderRadiusNone\\"><div class=\\"euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter\\" role=\\"main\\"><div class=\\"euiEmptyPrompt\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span><div class=\\"euiSpacer euiSpacer--s\\"></div><span class=\\"euiTextColor euiTextColor--subdued\\"><h2 class=\\"euiTitle euiTitle--medium\\">We couldn&#x27;t log you in</h2><div class=\\"euiSpacer euiSpacer--m\\"></div><div class=\\"euiText euiText--medium\\"><p>We hit an authentication error. Please check your credentials and try again. If you still can&#x27;t log in, contact your system administrator.</p></div></span><div class=\\"euiSpacer euiSpacer--l\\"></div><div class=\\"euiSpacer euiSpacer--s\\"></div><div class=\\"euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentCenter euiFlexGroup--directionColumn euiFlexGroup--responsive\\"><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><a class=\\"euiButton euiButton--primary euiButton--fill\\" href=\\"/some/url?some-query=some-value#some-hash\\" rel=\\"noreferrer\\" data-test-subj=\\"logInButton\\"><span class=\\"euiButtonContent euiButton__content\\"><span class=\\"euiButton__text\\">Log in</span></span></a></div></div></div></div></main></div></body></html>"`;

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const mockCanRedirectRequest = jest.fn();
jest.mock('./can_redirect_request', () => ({ canRedirectRequest: mockCanRedirectRequest }));

View file

@ -6,6 +6,9 @@
*/
jest.mock('./authenticator');
jest.mock('./unauthenticated_page');
import { mockCanRedirectRequest } from './authentication_service.test.mocks';
import Boom from '@hapi/boom';
@ -18,6 +21,7 @@ import type {
KibanaRequest,
Logger,
LoggerFactory,
OnPreResponseToolkit,
} from 'src/core/server';
import {
coreMock,
@ -37,6 +41,7 @@ import type { ConfigType } from '../config';
import { ConfigSchema, createConfig } from '../config';
import type { SecurityFeatureUsageServiceStart } from '../feature_usage';
import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock';
import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags';
import type { Session } from '../session_management';
import { sessionMock } from '../session_management/session.mock';
import { AuthenticationResult } from './authentication_result';
@ -47,15 +52,60 @@ describe('AuthenticationService', () => {
let logger: jest.Mocked<Logger>;
let mockSetupAuthenticationParams: {
http: jest.Mocked<HttpServiceSetup>;
config: ConfigType;
license: jest.Mocked<SecurityLicense>;
buildNumber: number;
};
let mockStartAuthenticationParams: {
legacyAuditLogger: jest.Mocked<SecurityAuditLogger>;
audit: jest.Mocked<AuditServiceSetup>;
config: ConfigType;
loggers: LoggerFactory;
http: jest.Mocked<HttpServiceStart>;
clusterClient: ReturnType<typeof elasticsearchServiceMock.createClusterClient>;
featureUsageService: jest.Mocked<SecurityFeatureUsageServiceStart>;
session: jest.Mocked<PublicMethodsOf<Session>>;
};
beforeEach(() => {
logger = loggingSystemMock.createLogger();
const httpMock = coreMock.createSetup().http;
(httpMock.basePath.prepend as jest.Mock).mockImplementation(
(path) => `${httpMock.basePath.serverBasePath}${path}`
);
(httpMock.basePath.get as jest.Mock).mockImplementation(() => httpMock.basePath.serverBasePath);
mockSetupAuthenticationParams = {
http: coreMock.createSetup().http,
http: httpMock,
config: createConfig(ConfigSchema.validate({}), loggingSystemMock.create().get(), {
isTLSEnabled: false,
}),
license: licenseMock.create(),
buildNumber: 100500,
};
mockCanRedirectRequest.mockReturnValue(false);
const coreStart = coreMock.createStart();
mockStartAuthenticationParams = {
legacyAuditLogger: securityAuditLoggerMock.create(),
audit: auditServiceMock.create(),
config: createConfig(
ConfigSchema.validate({
encryptionKey: 'ab'.repeat(16),
secureCookies: true,
cookieName: 'my-sid-cookie',
}),
loggingSystemMock.create().get(),
{ isTLSEnabled: false }
),
http: coreStart.http,
clusterClient: elasticsearchServiceMock.createClusterClient(),
loggers: loggingSystemMock.create(),
featureUsageService: securityFeatureUsageServiceMock.createStartContract(),
session: sessionMock.create(),
};
(mockStartAuthenticationParams.http.basePath.get as jest.Mock).mockImplementation(
() => mockStartAuthenticationParams.http.basePath.serverBasePath
);
service = new AuthenticationService(logger);
});
@ -71,40 +121,19 @@ describe('AuthenticationService', () => {
expect.any(Function)
);
});
it('properly registers onPreResponse handler', () => {
service.setup(mockSetupAuthenticationParams);
expect(mockSetupAuthenticationParams.http.registerOnPreResponse).toHaveBeenCalledTimes(1);
expect(mockSetupAuthenticationParams.http.registerOnPreResponse).toHaveBeenCalledWith(
expect.any(Function)
);
});
});
describe('#start()', () => {
let mockStartAuthenticationParams: {
legacyAuditLogger: jest.Mocked<SecurityAuditLogger>;
audit: jest.Mocked<AuditServiceSetup>;
config: ConfigType;
loggers: LoggerFactory;
http: jest.Mocked<HttpServiceStart>;
clusterClient: ReturnType<typeof elasticsearchServiceMock.createClusterClient>;
featureUsageService: jest.Mocked<SecurityFeatureUsageServiceStart>;
session: jest.Mocked<PublicMethodsOf<Session>>;
};
beforeEach(() => {
const coreStart = coreMock.createStart();
mockStartAuthenticationParams = {
legacyAuditLogger: securityAuditLoggerMock.create(),
audit: auditServiceMock.create(),
config: createConfig(
ConfigSchema.validate({
encryptionKey: 'ab'.repeat(16),
secureCookies: true,
cookieName: 'my-sid-cookie',
}),
loggingSystemMock.create().get(),
{ isTLSEnabled: false }
),
http: coreStart.http,
clusterClient: elasticsearchServiceMock.createClusterClient(),
loggers: loggingSystemMock.create(),
featureUsageService: securityFeatureUsageServiceMock.createStartContract(),
session: sessionMock.create(),
};
service.setup(mockSetupAuthenticationParams);
});
@ -318,4 +347,371 @@ describe('AuthenticationService', () => {
});
});
});
describe('onPreResponse handler', () => {
function getService({ runStart = true }: { runStart?: boolean } = {}) {
service.setup(mockSetupAuthenticationParams);
if (runStart) {
service.start(mockStartAuthenticationParams);
}
const onPreResponseHandler =
mockSetupAuthenticationParams.http.registerOnPreResponse.mock.calls[0][0];
const [authenticator] = jest.requireMock('./authenticator').Authenticator.mock.instances;
return { authenticator, onPreResponseHandler };
}
it('ignores responses with non-401 status code', async () => {
const mockReturnedValue = { type: 'next' as any };
const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit();
mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue);
const { onPreResponseHandler } = getService();
for (const statusCode of [200, 400, 403, 404]) {
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest(),
{ statusCode },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
}
});
it('ignores responses to requests that cannot handle redirects', async () => {
const mockReturnedValue = { type: 'next' as any };
const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit();
mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue);
mockCanRedirectRequest.mockReturnValue(false);
const { onPreResponseHandler } = getService();
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest(),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
});
it('ignores responses if authenticator is not initialized', async () => {
// Run `setup`, but not `start` to simulate non-initialized `Authenticator`.
const { onPreResponseHandler } = getService({ runStart: false });
const mockReturnedValue = { type: 'next' as any };
const mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit();
mockOnPreResponseToolkit.next.mockReturnValue(mockReturnedValue);
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest(),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
});
describe('when login form is available', () => {
let mockReturnedValue: { type: any; body: string };
let mockOnPreResponseToolkit: jest.Mocked<OnPreResponseToolkit>;
beforeEach(() => {
mockReturnedValue = { type: 'render' as any, body: 'body' };
mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit();
mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue);
});
it('redirects to the login page when user does not have an active session', async () => {
mockCanRedirectRequest.mockReturnValue(true);
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: '<div/>',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
Refresh:
'0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome',
},
});
});
it('performs logout if user has an active session', async () => {
mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid');
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: '<div/>',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
Refresh:
'0;url=/mock-server-basepath/logout?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome',
},
});
});
it('does not preserve path for the authentication flow paths', async () => {
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({
path: '/api/security/saml/callback',
query: { param: 'one two' },
routeTags: [ROUTE_TAG_AUTH_FLOW],
}),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: '<div/>',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
Refresh:
'0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2F',
},
});
});
});
describe('when login selector is available', () => {
let mockReturnedValue: { type: any; body: string };
let mockOnPreResponseToolkit: jest.Mocked<OnPreResponseToolkit>;
beforeEach(() => {
mockReturnedValue = { type: 'render' as any, body: 'body' };
mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit();
mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue);
mockSetupAuthenticationParams.config = createConfig(
ConfigSchema.validate({
authc: {
providers: {
saml: { saml1: { order: 0, realm: 'saml1' } },
basic: { basic1: { order: 1 } },
},
},
}),
loggingSystemMock.create().get(),
{ isTLSEnabled: false }
);
});
it('redirects to the login page when user does not have an active session', async () => {
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: '<div/>',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
Refresh:
'0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome',
},
});
});
it('performs logout if user has an active session', async () => {
mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid');
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: '<div/>',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
Refresh:
'0;url=/mock-server-basepath/logout?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2Fapp%2Fsome',
},
});
});
it('does not preserve path for the authentication flow paths', async () => {
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({
path: '/api/security/saml/callback',
query: { param: 'one two' },
routeTags: [ROUTE_TAG_AUTH_FLOW],
}),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: '<div/>',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
Refresh:
'0;url=/mock-server-basepath/login?msg=UNAUTHENTICATED&next=%2Fmock-server-basepath%2F',
},
});
});
});
describe('when neither login selector nor login form is available', () => {
let mockReturnedValue: { type: any; body: string };
let mockOnPreResponseToolkit: jest.Mocked<OnPreResponseToolkit>;
beforeEach(() => {
mockReturnedValue = { type: 'render' as any, body: 'body' };
mockOnPreResponseToolkit = httpServiceMock.createOnPreResponseToolkit();
mockOnPreResponseToolkit.render.mockReturnValue(mockReturnedValue);
mockSetupAuthenticationParams.config = createConfig(
ConfigSchema.validate({
authc: { providers: { saml: { saml1: { order: 0, realm: 'saml1' } } } },
}),
loggingSystemMock.create().get(),
{ isTLSEnabled: false }
);
});
it('renders unauthenticated page if user does not have an active session', async () => {
const mockRenderUnauthorizedPage = jest
.requireMock('./unauthenticated_page')
.renderUnauthenticatedPage.mockReturnValue('rendered-view');
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: 'rendered-view',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
},
});
expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({
basePath: mockSetupAuthenticationParams.http.basePath,
buildNumber: 100500,
originalURL: '/mock-server-basepath/app/some',
});
});
it('renders unauthenticated page if user has an active session', async () => {
const mockRenderUnauthorizedPage = jest
.requireMock('./unauthenticated_page')
.renderUnauthenticatedPage.mockReturnValue('rendered-view');
mockStartAuthenticationParams.session.getSID.mockResolvedValue('some-sid');
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({ path: '/app/some', query: { param: 'one two' } }),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: 'rendered-view',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
},
});
expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({
basePath: mockSetupAuthenticationParams.http.basePath,
buildNumber: 100500,
originalURL: '/mock-server-basepath/app/some',
});
});
it('does not preserve path for the authentication flow paths', async () => {
const mockRenderUnauthorizedPage = jest
.requireMock('./unauthenticated_page')
.renderUnauthenticatedPage.mockReturnValue('rendered-view');
const { authenticator, onPreResponseHandler } = getService();
authenticator.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/app/some');
mockCanRedirectRequest.mockReturnValue(true);
await expect(
onPreResponseHandler(
httpServerMock.createKibanaRequest({
path: '/api/security/saml/callback',
query: { param: 'one two' },
routeTags: [ROUTE_TAG_AUTH_FLOW],
}),
{ statusCode: 401 },
mockOnPreResponseToolkit
)
).resolves.toBe(mockReturnedValue);
expect(mockOnPreResponseToolkit.render).toHaveBeenCalledWith({
body: 'rendered-view',
headers: {
'Content-Security-Policy': `script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`,
},
});
expect(mockRenderUnauthorizedPage).toHaveBeenCalledWith({
basePath: mockSetupAuthenticationParams.http.basePath,
buildNumber: 100500,
originalURL: '/mock-server-basepath/',
});
});
});
});
});

View file

@ -15,22 +15,29 @@ import type {
LoggerFactory,
} from 'src/core/server';
import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../common/constants';
import type { SecurityLicense } from '../../common/licensing';
import type { AuthenticatedUser } from '../../common/model';
import { shouldProviderUseLoginForm } from '../../common/model';
import type { AuditServiceSetup, SecurityAuditLogger } from '../audit';
import type { ConfigType } from '../config';
import { getErrorStatusCode } from '../errors';
import { getDetailedErrorMessage, getErrorStatusCode } from '../errors';
import type { SecurityFeatureUsageServiceStart } from '../feature_usage';
import { ROUTE_TAG_AUTH_FLOW } from '../routes/tags';
import type { Session } from '../session_management';
import { APIKeys } from './api_keys';
import type { AuthenticationResult } from './authentication_result';
import type { ProviderLoginAttempt } from './authenticator';
import { Authenticator } from './authenticator';
import { canRedirectRequest } from './can_redirect_request';
import type { DeauthenticationResult } from './deauthentication_result';
import { renderUnauthenticatedPage } from './unauthenticated_page';
interface AuthenticationServiceSetupParams {
http: Pick<HttpServiceSetup, 'registerAuth'>;
http: Pick<HttpServiceSetup, 'basePath' | 'csp' | 'registerAuth' | 'registerOnPreResponse'>;
config: ConfigType;
license: SecurityLicense;
buildNumber: number;
}
interface AuthenticationServiceStartParams {
@ -62,12 +69,23 @@ export interface AuthenticationServiceStart {
export class AuthenticationService {
private license!: SecurityLicense;
private authenticator?: Authenticator;
private session?: PublicMethodsOf<Session>;
constructor(private readonly logger: Logger) {}
setup({ http, license }: AuthenticationServiceSetupParams) {
setup({ config, http, license, buildNumber }: AuthenticationServiceSetupParams) {
this.license = license;
// If we cannot automatically authenticate users we should redirect them straight to the login
// page if possible, so that they can try other methods to log in. If not possible, we should
// render a dedicated `Unauthenticated` page from which users can explicitly trigger a new
// login attempt. There are two cases when we can redirect to the login page:
// 1. Login selector is enabled
// 2. Login selector is disabled, but the provider with the lowest `order` uses login form
const isLoginPageAvailable =
config.authc.selector.enabled ||
shouldProviderUseLoginForm(config.authc.sortedProviders[0].type);
http.registerAuth(async (request, response, t) => {
if (!license.isLicenseAvailable()) {
this.logger.error('License is not available, authentication is not possible.');
@ -118,8 +136,9 @@ export class AuthenticationService {
}
if (authenticationResult.failed()) {
this.logger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`);
const error = authenticationResult.error!;
this.logger.info(`Authentication attempt failed: ${getDetailedErrorMessage(error)}`);
// proxy Elasticsearch "native" errors
const statusCode = getErrorStatusCode(error);
if (typeof statusCode === 'number') {
@ -139,7 +158,49 @@ export class AuthenticationService {
return t.notHandled();
});
this.logger.debug('Successfully registered core authentication handler.');
http.registerOnPreResponse(async (request, preResponse, toolkit) => {
if (preResponse.statusCode !== 401 || !canRedirectRequest(request)) {
return toolkit.next();
}
if (!this.authenticator) {
// Core doesn't allow returning error here.
this.logger.error('Authentication sub-system is not fully initialized yet.');
return toolkit.next();
}
// If users can eventually re-login we want to redirect them directly to the page they tried
// to access initially, but we only want to do that for routes that aren't part of the various
// authentication flows that wouldn't make any sense after successful authentication.
const originalURL = !request.route.options.tags.includes(ROUTE_TAG_AUTH_FLOW)
? this.authenticator.getRequestOriginalURL(request)
: `${http.basePath.get(request)}/`;
if (!isLoginPageAvailable) {
return toolkit.render({
body: renderUnauthenticatedPage({ buildNumber, basePath: http.basePath, originalURL }),
headers: { 'Content-Security-Policy': http.csp.header },
});
}
const needsToLogout = (await this.session?.getSID(request)) !== undefined;
if (needsToLogout) {
this.logger.warn('Could not authenticate user with the existing session. Forcing logout.');
}
return toolkit.render({
body: '<div/>',
headers: {
'Content-Security-Policy': http.csp.header,
Refresh: `0;url=${http.basePath.prepend(
`${
needsToLogout ? '/logout' : '/login'
}?msg=UNAUTHENTICATED&${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent(
originalURL
)}`
)}`,
},
});
});
}
start({
@ -161,6 +222,7 @@ export class AuthenticationService {
const getCurrentUser = (request: KibanaRequest) =>
http.auth.get<AuthenticatedUser>(request).state ?? null;
this.session = session;
this.authenticator = new Authenticator({
legacyAuditLogger,
audit,

View file

@ -20,6 +20,10 @@ import {
loggingSystemMock,
} from 'src/core/server/mocks';
import {
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
} from '../../common/constants';
import type { SecurityLicenseFeatures } from '../../common/licensing';
import { licenseMock } from '../../common/licensing/index.mock';
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
@ -1780,13 +1784,13 @@ describe('Authenticator', () => {
);
});
it('returns `notHandled` if session does not exist.', async () => {
it('redirects to login form if session does not exist.', async () => {
const request = httpServerMock.createKibanaRequest();
mockOptions.session.get.mockResolvedValue(null);
mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled());
await expect(authenticator.logout(request)).resolves.toEqual(
DeauthenticationResult.notHandled()
DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT')
);
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
@ -1843,12 +1847,12 @@ describe('Authenticator', () => {
expect(mockOptions.session.invalidate).not.toHaveBeenCalled();
});
it('returns `notHandled` if session does not exist and provider name is invalid', async () => {
it('redirects to login form if session does not exist and provider name is invalid', async () => {
const request = httpServerMock.createKibanaRequest({ query: { provider: 'foo' } });
mockOptions.session.get.mockResolvedValue(null);
await expect(authenticator.logout(request)).resolves.toEqual(
DeauthenticationResult.notHandled()
DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT')
);
expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled();
@ -1937,4 +1941,64 @@ describe('Authenticator', () => {
);
});
});
describe('`getRequestOriginalURL` method', () => {
let authenticator: Authenticator;
let mockOptions: ReturnType<typeof getMockOptions>;
beforeEach(() => {
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
authenticator = new Authenticator(mockOptions);
});
it('filters out auth specific query parameters', () => {
expect(authenticator.getRequestOriginalURL(httpServerMock.createKibanaRequest())).toBe(
'/mock-server-basepath/path'
);
expect(
authenticator.getRequestOriginalURL(
httpServerMock.createKibanaRequest({
query: {
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1',
},
})
)
).toBe('/mock-server-basepath/path');
expect(
authenticator.getRequestOriginalURL(
httpServerMock.createKibanaRequest({
query: {
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1',
[AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-hash',
},
})
)
).toBe('/mock-server-basepath/path');
});
it('allows to include additional query parameters', () => {
expect(
authenticator.getRequestOriginalURL(httpServerMock.createKibanaRequest(), [
['some-param', 'some-value'],
['some-param2', 'some-value2'],
])
).toBe('/mock-server-basepath/path?some-param=some-value&some-param2=some-value2');
expect(
authenticator.getRequestOriginalURL(
httpServerMock.createKibanaRequest({
query: {
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER]: 'saml1',
[AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-hash',
},
}),
[
['some-param', 'some-value'],
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc1'],
]
)
).toBe('/mock-server-basepath/path?some-param=some-value&auth_provider_hint=oidc1');
});
});
});

View file

@ -11,6 +11,7 @@ import type { IBasePath, IClusterClient, LoggerFactory } from 'src/core/server';
import { KibanaRequest } from '../../../../../src/core/server';
import {
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
LOGOUT_PROVIDER_QUERY_STRING_PARAMETER,
LOGOUT_REASON_QUERY_STRING_PARAMETER,
NEXT_URL_QUERY_STRING_PARAMETER,
@ -45,6 +46,15 @@ import {
} from './providers';
import { Tokens } from './tokens';
/**
* List of query string parameters used to pass various authentication related metadata that should
* be stripped away from URL as soon as they are no longer needed.
*/
const AUTH_METADATA_QUERY_STRING_PARAMETERS = [
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
];
/**
* The shape of the login attempt.
*/
@ -201,6 +211,7 @@ export class Authenticator {
const providerCommonOptions = {
client: this.options.clusterClient,
basePath: this.options.basePath,
getRequestOriginalURL: this.getRequestOriginalURL.bind(this),
tokens: new Tokens({
client: this.options.clusterClient.asInternalUser,
logger: this.options.loggers.get('tokens'),
@ -419,7 +430,9 @@ export class Authenticator {
}
}
return DeauthenticationResult.notHandled();
// If none of the configured providers could perform a logout, we should redirect user to the
// default logout location.
return DeauthenticationResult.redirectTo(this.getLoggedOutURL(request));
}
/**
@ -452,6 +465,24 @@ export class Authenticator {
this.options.featureUsageService.recordPreAccessAgreementUsage();
}
getRequestOriginalURL(
request: KibanaRequest,
additionalQueryStringParameters?: Array<[string, string]>
) {
const originalURLSearchParams = [
...[...request.url.searchParams.entries()].filter(
([key]) => !AUTH_METADATA_QUERY_STRING_PARAMETERS.includes(key)
),
...(additionalQueryStringParameters ?? []),
];
return `${this.options.basePath.get(request)}${request.url.pathname}${
originalURLSearchParams.length > 0
? `?${new URLSearchParams(originalURLSearchParams).toString()}`
: ''
}`;
}
/**
* Initializes HTTP Authentication provider and appends it to the end of the list of enabled
* authentication providers.
@ -762,9 +793,13 @@ export class Authenticator {
/**
* Creates a logged out URL for the specified request and provider.
* @param request Request that initiated logout.
* @param providerType Type of the provider that handles logout.
* @param providerType Type of the provider that handles logout. If not specified, then the first
* provider in the chain (default) is assumed.
*/
private getLoggedOutURL(request: KibanaRequest, providerType: string) {
private getLoggedOutURL(
request: KibanaRequest,
providerType: string = this.options.config.authc.sortedProviders[0].type
) {
// The app that handles logout needs to know the reason of the logout and the URL we may need to
// redirect user to once they log in again (e.g. when session expires).
const searchParams = new URLSearchParams();

View file

@ -7,6 +7,7 @@
import { httpServerMock } from 'src/core/server/mocks';
import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags';
import { canRedirectRequest } from './can_redirect_request';
describe('can_redirect_request', () => {
@ -24,4 +25,33 @@ describe('can_redirect_request', () => {
expect(canRedirectRequest(request)).toBe(false);
});
it('returns false for api routes', () => {
expect(
canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/api/security/some' }))
).toBe(false);
});
it('returns false for internal routes', () => {
expect(
canRedirectRequest(httpServerMock.createKibanaRequest({ path: '/internal/security/some' }))
).toBe(false);
});
it('returns true for the routes with the `security:canRedirect` tag', () => {
for (const request of [
httpServerMock.createKibanaRequest({ routeTags: [ROUTE_TAG_CAN_REDIRECT] }),
httpServerMock.createKibanaRequest({ routeTags: [ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT] }),
httpServerMock.createKibanaRequest({
path: '/api/security/some',
routeTags: [ROUTE_TAG_CAN_REDIRECT],
}),
httpServerMock.createKibanaRequest({
path: '/internal/security/some',
routeTags: [ROUTE_TAG_CAN_REDIRECT],
}),
]) {
expect(canRedirectRequest(request)).toBe(true);
}
});
});

View file

@ -7,7 +7,8 @@
import type { KibanaRequest } from 'src/core/server';
const ROUTE_TAG_API = 'api';
import { ROUTE_TAG_API, ROUTE_TAG_CAN_REDIRECT } from '../routes/tags';
const KIBANA_XSRF_HEADER = 'kbn-xsrf';
const KIBANA_VERSION_HEADER = 'kbn-version';
@ -24,9 +25,9 @@ export function canRedirectRequest(request: KibanaRequest) {
const isApiRoute =
route.options.tags.includes(ROUTE_TAG_API) ||
(route.path.startsWith('/api/') && route.path !== '/api/security/logout') ||
route.path.startsWith('/api/') ||
route.path.startsWith('/internal/');
const isAjaxRequest = hasVersionHeader || hasXsrfHeader;
return !isApiRoute && !isAjaxRequest;
return !isAjaxRequest && (!isApiRoute || route.options.tags.includes(ROUTE_TAG_CAN_REDIRECT));
}

View file

@ -20,6 +20,7 @@ export function mockAuthenticationProviderOptions(options?: { name: string }) {
client: elasticsearchServiceMock.createClusterClient(),
logger: loggingSystemMock.create().get(),
basePath: httpServiceMock.createBasePath(),
getRequestOriginalURL: jest.fn(),
tokens: { refresh: jest.fn(), invalidate: jest.fn() },
name: options?.name ?? 'basic1',
urls: {

View file

@ -27,6 +27,10 @@ import type { Tokens } from '../tokens';
export interface AuthenticationProviderOptions {
name: string;
basePath: HttpServiceSetup['basePath'];
getRequestOriginalURL: (
request: KibanaRequest,
additionalQueryStringParameters?: Array<[string, string]>
) => string;
client: IClusterClient;
logger: Logger;
tokens: PublicMethodsOf<Tokens>;

View file

@ -11,6 +11,10 @@ import Boom from '@hapi/boom';
import type { KibanaRequest } from 'src/core/server';
import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks';
import {
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
} from '../../../common/constants';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { securityMock } from '../../mocks';
import { AuthenticationResult } from '../authentication_result';
@ -376,18 +380,78 @@ describe('OIDCAuthenticationProvider', () => {
});
it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => {
mockOptions.getRequestOriginalURL.mockReturnValue(
'/mock-server-basepath/s/foo/some-path?auth_provider_hint=oidc'
);
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
await expect(provider.authenticate(request, null)).resolves.toEqual(
AuthenticationResult.redirectTo(
'/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc',
'/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc',
{ state: null }
)
);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc'],
]);
expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled();
});
it('initiates OIDC handshake for non-AJAX request that can not be authenticated, but includes URL hash fragment.', async () => {
mockOptions.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/s/foo/some-path');
mockOptions.client.asInternalUser.transport.request.mockResolvedValue(
securityMock.createApiResponse({
body: {
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',
},
})
);
const request = httpServerMock.createKibanaRequest({
path: '/s/foo/some-path',
query: { [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-fragment' },
});
await expect(provider.authenticate(request)).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',
redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment',
realm: 'oidc1',
},
}
)
);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request);
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledTimes(1);
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/oidc/prepare',
body: { realm: 'oidc1' },
});
});
it('succeeds if state contains a valid token.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const tokenPair = {
@ -520,6 +584,9 @@ describe('OIDCAuthenticationProvider', () => {
});
it('redirects non-AJAX requests to the "capture URL" page if refresh token is expired or already refreshed.', async () => {
mockOptions.getRequestOriginalURL.mockReturnValue(
'/mock-server-basepath/s/foo/some-path?auth_provider_hint=oidc'
);
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
const authorization = `Bearer ${tokenPair.accessToken}`;
@ -534,11 +601,16 @@ describe('OIDCAuthenticationProvider', () => {
provider.authenticate(request, { ...tokenPair, realm: 'oidc1' })
).resolves.toEqual(
AuthenticationResult.redirectTo(
'/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=oidc&providerName=oidc',
'/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Doidc',
{ state: null }
)
);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'oidc'],
]);
expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1);
expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken);

View file

@ -10,8 +10,13 @@ import type from 'type-detect';
import type { KibanaRequest } from 'src/core/server';
import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants';
import {
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
NEXT_URL_QUERY_STRING_PARAMETER,
} from '../../../common/constants';
import type { AuthenticationInfo } from '../../elasticsearch';
import { getDetailedErrorMessage } from '../../errors';
import { AuthenticationResult } from '../authentication_result';
import { canRedirectRequest } from '../can_redirect_request';
import { DeauthenticationResult } from '../deauthentication_result';
@ -201,7 +206,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
// We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in
// another tab)
return authenticationResult.notHandled() && canStartNewSession(request)
? await this.captureRedirectURL(request)
? await this.initiateAuthenticationHandshake(request)
: authenticationResult;
}
@ -264,7 +269,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
})
).body as any;
} catch (err) {
this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`);
this.logger.debug(
`Failed to authenticate request via OpenID Connect: ${getDetailedErrorMessage(err)}`
);
return AuthenticationResult.failed(err);
}
@ -313,7 +320,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
{ state: { state, nonce, redirectURL, realm: this.realm } }
);
} catch (err) {
this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`);
this.logger.debug(
`Failed to initiate OpenID Connect authentication: ${getDetailedErrorMessage(err)}`
);
return AuthenticationResult.failed(err);
}
}
@ -341,7 +350,9 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user, { authHeaders });
} catch (err) {
this.logger.debug(`Failed to authenticate request via state: ${err.message}`);
this.logger.debug(
`Failed to authenticate request via state: ${getDetailedErrorMessage(err)}`
);
return AuthenticationResult.failed(err);
}
}
@ -379,7 +390,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug(
'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.'
);
return this.captureRedirectURL(request);
return this.initiateAuthenticationHandshake(request);
}
return AuthenticationResult.failed(
@ -440,7 +451,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
return DeauthenticationResult.redirectTo(redirect);
}
} catch (err) {
this.logger.debug(`Failed to deauthenticate user: ${err.message}`);
this.logger.debug(`Failed to deauthenticate user: ${getDetailedErrorMessage(err)}`);
return DeauthenticationResult.failed(err);
}
}
@ -457,22 +468,29 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
}
/**
* Tries to capture full redirect URL (both path and fragment) and initiate OIDC handshake.
* Tries to initiate OIDC authentication handshake. If the request already includes user URL hash fragment, we will
* initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment
* first and only then initiate SAML handshake.
* @param request Request instance.
*/
private captureRedirectURL(request: KibanaRequest) {
const searchParams = new URLSearchParams([
[
NEXT_URL_QUERY_STRING_PARAMETER,
`${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`,
],
['providerType', this.type],
['providerName', this.options.name],
]);
private initiateAuthenticationHandshake(request: KibanaRequest) {
const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER);
if (originalURLHash != null) {
return this.initiateOIDCAuthentication(
request,
{ realm: this.realm },
`${this.options.getRequestOriginalURL(request)}${originalURLHash}`
);
}
return AuthenticationResult.redirectTo(
`${
this.options.basePath.serverBasePath
}/internal/security/capture-url?${searchParams.toString()}`,
}/internal/security/capture-url?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent(
this.options.getRequestOriginalURL(request, [
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, this.options.name],
])
)}`,
// Here we indicate that current session, if any, should be invalidated. It is a no-op for the
// initial handshake, but is essential when both access and refresh tokens are expired.
{ state: null }

View file

@ -10,6 +10,10 @@ import Boom from '@hapi/boom';
import { elasticsearchServiceMock, httpServerMock } from 'src/core/server/mocks';
import {
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
} from '../../../common/constants';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { securityMock } from '../../mocks';
import { AuthenticationResult } from '../authentication_result';
@ -848,18 +852,63 @@ describe('SAMLAuthenticationProvider', () => {
});
it('redirects non-AJAX request that can not be authenticated to the "capture URL" page.', async () => {
mockOptions.getRequestOriginalURL.mockReturnValue(
'/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml'
);
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.redirectTo(
'/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml',
'/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml',
{ state: null }
)
);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'saml'],
]);
expect(mockOptions.client.asInternalUser.transport.request).not.toHaveBeenCalled();
});
it('initiates SAML handshake for non-AJAX request that can not be authenticated, but includes URL hash fragment.', async () => {
mockOptions.getRequestOriginalURL.mockReturnValue('/mock-server-basepath/s/foo/some-path');
mockOptions.client.asInternalUser.transport.request.mockResolvedValue(
securityMock.createApiResponse({
body: {
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
},
})
);
const request = httpServerMock.createKibanaRequest({
path: '/s/foo/some-path',
query: { [AUTH_URL_HASH_QUERY_STRING_PARAMETER]: '#some-fragment' },
});
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.redirectTo(
'https://idp-host/path/login?SAMLRequest=some%20request%20',
{
state: {
requestId: 'some-request-id',
redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment',
realm: 'test-realm',
},
}
)
);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request);
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
body: { realm: 'test-realm' },
});
});
it('succeeds if state contains a valid token.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const state = {
@ -1024,6 +1073,9 @@ describe('SAMLAuthenticationProvider', () => {
});
it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => {
mockOptions.getRequestOriginalURL.mockReturnValue(
'/mock-server-basepath/s/foo/some-path?auth_provider_hint=saml'
);
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} });
const state = {
accessToken: 'expired-token',
@ -1040,11 +1092,16 @@ describe('SAMLAuthenticationProvider', () => {
await expect(provider.authenticate(request, state)).resolves.toEqual(
AuthenticationResult.redirectTo(
'/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path&providerType=saml&providerName=saml',
'/mock-server-basepath/internal/security/capture-url?next=%2Fmock-server-basepath%2Fs%2Ffoo%2Fsome-path%3Fauth_provider_hint%3Dsaml',
{ state: null }
)
);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledTimes(1);
expect(mockOptions.getRequestOriginalURL).toHaveBeenCalledWith(request, [
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, 'saml'],
]);
expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1);
expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken);

View file

@ -9,9 +9,14 @@ import Boom from '@hapi/boom';
import type { KibanaRequest } from 'src/core/server';
import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants';
import {
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
NEXT_URL_QUERY_STRING_PARAMETER,
} from '../../../common/constants';
import { isInternalURL } from '../../../common/is_internal_url';
import type { AuthenticationInfo } from '../../elasticsearch';
import { getDetailedErrorMessage } from '../../errors';
import { AuthenticationResult } from '../authentication_result';
import { canRedirectRequest } from '../can_redirect_request';
import { DeauthenticationResult } from '../deauthentication_result';
@ -185,7 +190,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
} else {
this.logger.debug(
`Failed to perform a login: ${
authenticationResult.error && authenticationResult.error.message
authenticationResult.error && getDetailedErrorMessage(authenticationResult.error)
}`
);
}
@ -230,7 +235,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() && canStartNewSession(request)
? this.captureRedirectURL(request)
? this.initiateAuthenticationHandshake(request)
: authenticationResult;
}
@ -283,7 +288,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
return DeauthenticationResult.redirectTo(redirect);
}
} catch (err) {
this.logger.debug(`Failed to deauthenticate user: ${err.message}`);
this.logger.debug(`Failed to deauthenticate user: ${getDetailedErrorMessage(err)}`);
return DeauthenticationResult.failed(err);
}
}
@ -362,7 +367,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
})
).body as any;
} catch (err) {
this.logger.debug(`Failed to log in with SAML response: ${err.message}`);
this.logger.debug(`Failed to log in with SAML response: ${getDetailedErrorMessage(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
@ -452,7 +457,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
refreshToken: existingState.refreshToken!,
});
} catch (err) {
this.logger.debug(`Failed to perform IdP initiated local logout: ${err.message}`);
this.logger.debug(
`Failed to perform IdP initiated local logout: ${getDetailedErrorMessage(err)}`
);
return AuthenticationResult.failed(err);
}
@ -483,7 +490,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user, { authHeaders });
} catch (err) {
this.logger.debug(`Failed to authenticate request via state: ${err.message}`);
this.logger.debug(
`Failed to authenticate request via state: ${getDetailedErrorMessage(err)}`
);
return AuthenticationResult.failed(err);
}
}
@ -520,7 +529,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug(
'Both access and refresh tokens are expired. Capturing redirect URL and re-initiating SAML handshake.'
);
return this.captureRedirectURL(request);
return this.initiateAuthenticationHandshake(request);
}
return AuthenticationResult.failed(
@ -569,7 +578,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
state: { requestId, redirectURL, realm: this.realm },
});
} catch (err) {
this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`);
this.logger.debug(`Failed to initiate SAML handshake: ${getDetailedErrorMessage(err)}`);
return AuthenticationResult.failed(err);
}
}
@ -629,22 +638,28 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
/**
* Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake.
* Tries to initiate SAML authentication handshake. If the request already includes user URL hash fragment, we will
* initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment
* first and only then initiate SAML handshake.
* @param request Request instance.
*/
private captureRedirectURL(request: KibanaRequest) {
const searchParams = new URLSearchParams([
[
NEXT_URL_QUERY_STRING_PARAMETER,
`${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`,
],
['providerType', this.type],
['providerName', this.options.name],
]);
private initiateAuthenticationHandshake(request: KibanaRequest) {
const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER);
if (originalURLHash != null) {
return this.authenticateViaHandshake(
request,
`${this.options.getRequestOriginalURL(request)}${originalURLHash}`
);
}
return AuthenticationResult.redirectTo(
`${
this.options.basePath.serverBasePath
}/internal/security/capture-url?${searchParams.toString()}`,
}/internal/security/capture-url?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent(
this.options.getRequestOriginalURL(request, [
[AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER, this.options.name],
])
)}`,
// Here we indicate that current session, if any, should be invalidated. It is a no-op for the
// initial handshake, but is essential when both access and refresh tokens are expired.
{ state: null }

View file

@ -8,7 +8,7 @@
import type { ElasticsearchClient, Logger } from 'src/core/server';
import type { AuthenticationInfo } from '../elasticsearch';
import { getErrorStatusCode } from '../errors';
import { getDetailedErrorMessage, getErrorStatusCode } from '../errors';
/**
* Represents a pair of access and refresh tokens.
@ -73,11 +73,11 @@ export class Tokens {
return {
accessToken,
refreshToken,
// @ts-expect-error @elastic/elasticsearch decalred GetUserAccessTokenResponse.authentication: string
// @ts-expect-error @elastic/elasticsearch declared GetUserAccessTokenResponse.authentication: string
authenticationInfo: authenticationInfo as AuthenticationInfo,
};
} catch (err) {
this.logger.debug(`Failed to refresh access token: ${err.message}`);
this.logger.debug(`Failed to refresh access token: ${getDetailedErrorMessage(err)}`);
// There are at least two common cases when refresh token request can fail:
// 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires.
@ -123,7 +123,7 @@ export class Tokens {
})
).body.invalidated_tokens;
} catch (err) {
this.logger.debug(`Failed to invalidate refresh token: ${err.message}`);
this.logger.debug(`Failed to invalidate refresh token: ${getDetailedErrorMessage(err)}`);
// When using already deleted refresh token, Elasticsearch responds with 404 and a body that
// shows that no tokens were invalidated.
@ -155,7 +155,7 @@ export class Tokens {
})
).body.invalidated_tokens;
} catch (err) {
this.logger.debug(`Failed to invalidate access token: ${err.message}`);
this.logger.debug(`Failed to invalidate access token: ${getDetailedErrorMessage(err)}`);
// When using already deleted access token, Elasticsearch responds with 404 and a body that
// shows that no tokens were invalidated.

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { coreMock } from '../../../../../src/core/server/mocks';
import { UnauthenticatedPage } from './unauthenticated_page';
jest.mock('src/core/server/rendering/views/fonts', () => ({
Fonts: () => <>MockedFonts</>,
}));
describe('UnauthenticatedPage', () => {
it('renders as expected', async () => {
const mockCoreSetup = coreMock.createSetup();
(mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation(
(path) => `/mock-basepath${path}`
);
const body = renderToStaticMarkup(
<UnauthenticatedPage
originalURL="/some/url?some-query=some-value#some-hash"
buildNumber={100500}
basePath={mockCoreSetup.http.basePath}
/>
);
expect(body).toMatchSnapshot();
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// @ts-expect-error no definitions in component folder
import { EuiButton } from '@elastic/eui/lib/components/button';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IBasePath } from 'src/core/server';
import { PromptPage } from '../prompt_page';
interface Props {
originalURL: string;
buildNumber: number;
basePath: IBasePath;
}
export function UnauthenticatedPage({ basePath, originalURL, buildNumber }: Props) {
return (
<PromptPage
buildNumber={buildNumber}
basePath={basePath}
title={i18n.translate('xpack.security.unauthenticated.pageTitle', {
defaultMessage: "We couldn't log you in",
})}
body={
<p>
<FormattedMessage
id="xpack.security.unauthenticated.errorDescription"
defaultMessage="We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator."
/>
</p>
}
actions={[
<EuiButton color="primary" fill href={originalURL} data-test-subj="logInButton">
<FormattedMessage
id="xpack.security.unauthenticated.loginButtonLabel"
defaultMessage="Log in"
/>
</EuiButton>,
]}
/>
);
}
export function renderUnauthenticatedPage(props: Props) {
return renderToStaticMarkup(<UnauthenticatedPage {...props} />);
}

View file

@ -1,3 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ResetSessionPage renders as expected 1`] = `"<html lang=\\"en\\"><head><link href=\\"/some-css-file.css\\" rel=\\"stylesheet\\"/><link href=\\"/some-other-css-file.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/path/to/base/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/path/to/base/ui/favicons/favicon.svg\\"/><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage euiPage--grow\\" style=\\"min-height:100vh\\"><main class=\\"euiPageBody euiPageBody--borderRadiusNone\\"><div class=\\"euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter\\" role=\\"main\\"><div class=\\"euiEmptyPrompt\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span><div class=\\"euiSpacer euiSpacer--s\\"></div><span class=\\"euiTextColor euiTextColor--subdued\\"><h2 class=\\"euiTitle euiTitle--medium\\">You do not have permission to access the requested page</h2><div class=\\"euiSpacer euiSpacer--m\\"></div><div class=\\"euiText euiText--medium\\"><p>Either go back to the previous page or log in as a different user.</p></div></span><div class=\\"euiSpacer euiSpacer--l\\"></div><div class=\\"euiSpacer euiSpacer--s\\"></div><div class=\\"euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentCenter euiFlexGroup--directionColumn euiFlexGroup--responsive\\"><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><a class=\\"euiButton euiButton--primary euiButton--fill\\" href=\\"/path/to/logout\\" rel=\\"noreferrer\\" data-test-subj=\\"ResetSessionButton\\"><span class=\\"euiButtonContent euiButton__content\\"><span class=\\"euiButton__text\\">Log in as different user</span></span></a></div><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><button class=\\"euiButtonEmpty euiButtonEmpty--primary\\" type=\\"button\\" id=\\"goBackButton\\"><span class=\\"euiButtonContent euiButtonEmpty__content\\"><span class=\\"euiButtonEmpty__text\\">Go back</span></span></button></div></div></div></div></main></div></body></html>"`;
exports[`ResetSessionPage renders as expected 1`] = `"<html lang=\\"en\\"><head><title>Elastic</title><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/100500/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.v7.light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/node_modules/@kbn/ui-framework/dist/kui_light.css\\" rel=\\"stylesheet\\"/><link href=\\"/mock-server-basepath/ui/legacy_light_theme.css\\" rel=\\"stylesheet\\"/>MockedFonts<link rel=\\"alternate icon\\" type=\\"image/png\\" href=\\"/mock-server-basepath/ui/favicons/favicon.png\\"/><link rel=\\"icon\\" type=\\"image/svg+xml\\" href=\\"/mock-server-basepath/ui/favicons/favicon.svg\\"/><script src=\\"/mock-basepath/internal/security/reset_session_page.js\\"></script><meta name=\\"theme-color\\" content=\\"#ffffff\\"/><meta name=\\"color-scheme\\" content=\\"light dark\\"/></head><body><div class=\\"euiPage euiPage--grow\\" style=\\"min-height:100vh\\" data-test-subj=\\"promptPage\\"><main class=\\"euiPageBody euiPageBody--borderRadiusNone\\"><div class=\\"euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPageContent euiPageContent--verticalCenter euiPageContent--horizontalCenter\\" role=\\"main\\"><div class=\\"euiEmptyPrompt\\"><span data-euiicon-type=\\"alert\\" color=\\"danger\\"></span><div class=\\"euiSpacer euiSpacer--s\\"></div><span class=\\"euiTextColor euiTextColor--subdued\\"><h2 class=\\"euiTitle euiTitle--medium\\">You do not have permission to access the requested page</h2><div class=\\"euiSpacer euiSpacer--m\\"></div><div class=\\"euiText euiText--medium\\"><p>Either go back to the previous page or log in as a different user.</p></div></span><div class=\\"euiSpacer euiSpacer--l\\"></div><div class=\\"euiSpacer euiSpacer--s\\"></div><div class=\\"euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentCenter euiFlexGroup--directionColumn euiFlexGroup--responsive\\"><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><a class=\\"euiButton euiButton--primary euiButton--fill\\" href=\\"/path/to/logout\\" rel=\\"noreferrer\\" data-test-subj=\\"ResetSessionButton\\"><span class=\\"euiButtonContent euiButton__content\\"><span class=\\"euiButton__text\\">Log in as different user</span></span></a></div><div class=\\"euiFlexItem euiFlexItem--flexGrowZero\\"><button class=\\"euiButtonEmpty euiButtonEmpty--primary\\" type=\\"button\\" id=\\"goBackButton\\"><span class=\\"euiButtonContent euiButtonEmpty__content\\"><span class=\\"euiButtonEmpty__text\\">Go back</span></span></button></div></div></div></div></main></div></body></html>"`;

View file

@ -10,7 +10,6 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import type { Observable, Subscription } from 'rxjs';
import * as UiSharedDeps from '@kbn/ui-shared-deps';
import type {
CapabilitiesSetup,
HttpServiceSetup,
@ -163,25 +162,14 @@ export class AuthorizationService {
http.registerOnPreResponse((request, preResponse, toolkit) => {
if (preResponse.statusCode === 403 && canRedirectRequest(request)) {
const basePath = http.basePath.get(request);
const next = `${basePath}${request.url.pathname}${request.url.search}`;
const regularBundlePath = `${basePath}/${buildNumber}/bundles`;
const logoutUrl = http.basePath.prepend(
`/api/security/logout?${querystring.stringify({ next })}`
);
const styleSheetPaths = [
`${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`,
`${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`,
`${basePath}/ui/legacy_light_theme.css`,
];
const next = `${http.basePath.get(request)}${request.url.pathname}${request.url.search}`;
const body = renderToStaticMarkup(
<ResetSessionPage
logoutUrl={logoutUrl}
styleSheetPaths={styleSheetPaths}
basePath={basePath}
buildNumber={buildNumber}
basePath={http.basePath}
logoutUrl={http.basePath.prepend(
`/api/security/logout?${querystring.stringify({ next })}`
)}
/>
);

View file

@ -8,6 +8,7 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { coreMock } from '../../../../../src/core/server/mocks';
import { ResetSessionPage } from './reset_session_page';
jest.mock('src/core/server/rendering/views/fonts', () => ({
@ -16,11 +17,16 @@ jest.mock('src/core/server/rendering/views/fonts', () => ({
describe('ResetSessionPage', () => {
it('renders as expected', async () => {
const mockCoreSetup = coreMock.createSetup();
(mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation(
(path) => `/mock-basepath${path}`
);
const body = renderToStaticMarkup(
<ResetSessionPage
logoutUrl="/path/to/logout"
styleSheetPaths={['/some-css-file.css', '/some-other-css-file.css']}
basePath="/path/to/base"
buildNumber={100500}
basePath={mockCoreSetup.http.basePath}
/>
);

View file

@ -7,101 +7,53 @@
// @ts-expect-error no definitions in component folder
import { EuiButton, EuiButtonEmpty } from '@elastic/eui/lib/components/button';
// @ts-expect-error no definitions in component folder
import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt';
// @ts-expect-error no definitions in component folder
import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert';
// @ts-expect-error no definitions in component folder
import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon';
// @ts-expect-error no definitions in component folder
import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import { FormattedMessage } from '@kbn/i18n/react';
import type { IBasePath } from 'src/core/server';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { Fonts } from '../../../../../src/core/server/rendering/views/fonts';
// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded
// in advance the first time this page is rendered server-side. If not, the
// icon svg wouldn't contain any paths the first time the page was rendered.
appendIconComponentCache({
alert: EuiIconAlert,
});
import { PromptPage } from '../prompt_page';
export function ResetSessionPage({
logoutUrl,
styleSheetPaths,
buildNumber,
basePath,
}: {
logoutUrl: string;
styleSheetPaths: string[];
basePath: string;
buildNumber: number;
basePath: IBasePath;
}) {
const uiPublicUrl = `${basePath}/ui`;
return (
<html lang={i18n.getLocale()}>
<head>
{styleSheetPaths.map((path) => (
<link href={path} rel="stylesheet" key={path} />
))}
<Fonts url={uiPublicUrl} />
{/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */}
<link rel="alternate icon" type="image/png" href={`${uiPublicUrl}/favicons/favicon.png`} />
<link rel="icon" type="image/svg+xml" href={`${uiPublicUrl}/favicons/favicon.svg`} />
<meta name="theme-color" content="#ffffff" />
<meta name="color-scheme" content="light dark" />
</head>
<body>
<I18nProvider>
<EuiPage paddingSize="none" style={{ minHeight: '100vh' }}>
<EuiPageBody>
<EuiPageContent verticalPosition="center" horizontalPosition="center">
<EuiEmptyPrompt
iconType="alert"
iconColor="danger"
title={
<h2>
<FormattedMessage
id="xpack.security.resetSession.title"
defaultMessage="You do not have permission to access the requested page"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.security.resetSession.description"
defaultMessage="Either go back to the previous page or log in as a different user."
/>
</p>
}
actions={[
<EuiButton
color="primary"
fill
href={logoutUrl}
data-test-subj="ResetSessionButton"
>
<FormattedMessage
id="xpack.security.resetSession.logOutButtonLabel"
defaultMessage="Log in as different user"
/>
</EuiButton>,
<EuiButtonEmpty id="goBackButton">
<FormattedMessage
id="xpack.security.resetSession.goBackButtonLabel"
defaultMessage="Go back"
/>
</EuiButtonEmpty>,
]}
/>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</I18nProvider>
</body>
</html>
<PromptPage
buildNumber={buildNumber}
basePath={basePath}
scriptPaths={['/internal/security/reset_session_page.js']}
title={i18n.translate('xpack.security.resetSession.title', {
defaultMessage: 'You do not have permission to access the requested page',
})}
body={
<p>
<FormattedMessage
id="xpack.security.resetSession.description"
defaultMessage="Either go back to the previous page or log in as a different user."
/>
</p>
}
actions={[
<EuiButton color="primary" fill href={logoutUrl} data-test-subj="ResetSessionButton">
<FormattedMessage
id="xpack.security.resetSession.logOutButtonLabel"
defaultMessage="Log in as different user"
/>
</EuiButton>,
<EuiButtonEmpty id="goBackButton">
<FormattedMessage
id="xpack.security.resetSession.goBackButtonLabel"
defaultMessage="Go back"
/>
</EuiButtonEmpty>,
]}
/>
);
}

View file

@ -30,6 +30,7 @@ export type { CheckPrivilegesPayload } from './authorization';
export { LegacyAuditLogger, AuditLogger, AuditEvent } from './audit';
export type { SecurityPluginSetup, SecurityPluginStart };
export type { AuthenticatedUser } from '../common/model';
export { ROUTE_TAG_CAN_REDIRECT } from './routes/tags';
export const config: PluginConfigDescriptor<TypeOf<typeof ConfigSchema>> = {
schema: ConfigSchema,

View file

@ -246,7 +246,12 @@ export class SecurityPlugin
this.elasticsearchService.setup({ license, status: core.status });
this.featureUsageService.setup({ featureUsage: licensing.featureUsage });
this.sessionManagementService.setup({ config, http: core.http, taskManager });
this.authenticationService.setup({ http: core.http, license });
this.authenticationService.setup({
http: core.http,
config,
license,
buildNumber: this.initializerContext.env.packageInfo.buildNum,
});
registerSecurityUsageCollector({ usageCollection, config, license });

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { coreMock } from '../../../../src/core/server/mocks';
import { PromptPage } from './prompt_page';
jest.mock('src/core/server/rendering/views/fonts', () => ({
Fonts: () => <>MockedFonts</>,
}));
describe('PromptPage', () => {
it('renders as expected without additional scripts', async () => {
const mockCoreSetup = coreMock.createSetup();
(mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation(
(path) => `/mock-basepath${path}`
);
const body = renderToStaticMarkup(
<PromptPage
buildNumber={100500}
basePath={mockCoreSetup.http.basePath}
title="Some Title"
body={<div>Some Body</div>}
actions={[<span>Action#1</span>, <span>Action#2</span>]}
/>
);
expect(body).toMatchSnapshot();
});
it('renders as expected with additional scripts', async () => {
const mockCoreSetup = coreMock.createSetup();
(mockCoreSetup.http.basePath.prepend as jest.Mock).mockImplementation(
(path) => `/mock-basepath${path}`
);
const body = renderToStaticMarkup(
<PromptPage
buildNumber={100500}
basePath={mockCoreSetup.http.basePath}
scriptPaths={['/some/script1.js', '/some/script2.js']}
title="Some Title"
body={<div>Some Body</div>}
actions={[<span>Action#1</span>, <span>Action#2</span>]}
/>
);
expect(body).toMatchSnapshot();
});
});

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// @ts-expect-error no definitions in component folder
import { EuiEmptyPrompt } from '@elastic/eui/lib/components/empty_prompt';
// @ts-expect-error no definitions in component folder
import { icon as EuiIconAlert } from '@elastic/eui/lib/components/icon/assets/alert';
// @ts-expect-error no definitions in component folder
import { appendIconComponentCache } from '@elastic/eui/lib/components/icon/icon';
// @ts-expect-error no definitions in component folder
import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui/lib/components/page';
import type { ReactNode } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { I18nProvider } from '@kbn/i18n/react';
import * as UiSharedDeps from '@kbn/ui-shared-deps';
import type { IBasePath } from 'src/core/server';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { Fonts } from '../../../../src/core/server/rendering/views/fonts';
// Preload the alert icon used by `EuiEmptyPrompt` to ensure that it's loaded
// in advance the first time this page is rendered server-side. If not, the
// icon svg wouldn't contain any paths the first time the page was rendered.
appendIconComponentCache({
alert: EuiIconAlert,
});
interface Props {
buildNumber: number;
basePath: IBasePath;
scriptPaths?: string[];
title: ReactNode;
body: ReactNode;
actions: ReactNode;
}
export function PromptPage({
basePath,
buildNumber,
scriptPaths = [],
title,
body,
actions,
}: Props) {
const uiPublicURL = `${basePath.serverBasePath}/ui`;
const regularBundlePath = `${basePath.serverBasePath}/${buildNumber}/bundles`;
const styleSheetPaths = [
`${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`,
`${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`,
`${basePath.serverBasePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`,
`${basePath.serverBasePath}/ui/legacy_light_theme.css`,
];
return (
<html lang={i18n.getLocale()}>
<head>
<title>Elastic</title>
{styleSheetPaths.map((path) => (
<link href={path} rel="stylesheet" key={path} />
))}
<Fonts url={uiPublicURL} />
{/* The alternate icon is a fallback for Safari which does not yet support SVG favicons */}
<link rel="alternate icon" type="image/png" href={`${uiPublicURL}/favicons/favicon.png`} />
<link rel="icon" type="image/svg+xml" href={`${uiPublicURL}/favicons/favicon.svg`} />
{scriptPaths.map((path) => (
<script src={basePath.prepend(path)} key={path} />
))}
<meta name="theme-color" content="#ffffff" />
<meta name="color-scheme" content="light dark" />
</head>
<body>
<I18nProvider>
<EuiPage paddingSize="none" style={{ minHeight: '100vh' }} data-test-subj="promptPage">
<EuiPageBody>
<EuiPageContent verticalPosition="center" horizontalPosition="center">
<EuiEmptyPrompt
iconType="alert"
iconColor="danger"
title={<h2>{title}</h2>}
body={body}
actions={actions}
/>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</I18nProvider>
</body>
</html>
);
}

View file

@ -23,6 +23,7 @@ import {
import { authenticationServiceMock } from '../../authentication/authentication_service.mock';
import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types';
import { routeDefinitionParamsMock } from '../index.mock';
import { ROUTE_TAG_AUTH_FLOW, ROUTE_TAG_CAN_REDIRECT } from '../tags';
import { defineCommonRoutes } from './common';
describe('Common authentication routes', () => {
@ -64,7 +65,10 @@ describe('Common authentication routes', () => {
});
it('correctly defines route.', async () => {
expect(routeConfig.options).toEqual({ authRequired: false });
expect(routeConfig.options).toEqual({
authRequired: false,
tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW],
});
expect(routeConfig.validate).toEqual({
body: undefined,
query: expect.any(Type),

View file

@ -21,6 +21,7 @@ import {
} from '../../authentication';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { ROUTE_TAG_AUTH_FLOW, ROUTE_TAG_CAN_REDIRECT } from '../tags';
/**
* Defines routes that are common to various authentication mechanisms.
@ -40,7 +41,7 @@ export function defineCommonRoutes({
// Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any
// set of query string parameters (e.g. SAML/OIDC logout request/response parameters).
validate: { query: schema.object({}, { unknowns: 'allow' }) },
options: { authRequired: false },
options: { authRequired: false, tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW] },
},
async (context, request, response) => {
const serverBasePath = basePath.serverBasePath;

View file

@ -10,11 +10,11 @@ import { i18n } from '@kbn/i18n';
import type { KibanaRequest, KibanaResponseFactory } from 'src/core/server';
import type { RouteDefinitionParams } from '../';
import { OIDCLogin } from '../../authentication';
import { OIDCAuthenticationProvider, OIDCLogin } from '../../authentication';
import type { ProviderLoginAttempt } from '../../authentication/providers/oidc';
import { OIDCAuthenticationProvider } from '../../authentication/providers/oidc';
import { wrapIntoCustomErrorResponse } from '../../errors';
import { createLicensedRouteHandler } from '../licensed_route_handler';
import { ROUTE_TAG_AUTH_FLOW, ROUTE_TAG_CAN_REDIRECT } from '../tags';
/**
* Defines routes required for SAML authentication.
@ -106,7 +106,7 @@ export function defineOIDCRoutes({
{ unknowns: 'allow' }
),
},
options: { authRequired: false },
options: { authRequired: false, tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW] },
},
createLicensedRouteHandler(async (context, request, response) => {
const serverBasePath = basePath.serverBasePath;
@ -183,7 +183,11 @@ export function defineOIDCRoutes({
{ unknowns: 'allow' }
),
},
options: { authRequired: false, xsrfRequired: false },
options: {
authRequired: false,
xsrfRequired: false,
tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW],
},
},
createLicensedRouteHandler(async (context, request, response) => {
const serverBasePath = basePath.serverBasePath;
@ -222,7 +226,10 @@ export function defineOIDCRoutes({
{ unknowns: 'allow' }
),
},
options: { authRequired: false },
options: {
authRequired: false,
tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW],
},
},
createLicensedRouteHandler(async (context, request, response) => {
return performOIDCLogin(request, response, {

View file

@ -16,6 +16,7 @@ import { AuthenticationResult, SAMLLogin } from '../../authentication';
import { authenticationServiceMock } from '../../authentication/authentication_service.mock';
import type { SecurityRouter } from '../../types';
import { routeDefinitionParamsMock } from '../index.mock';
import { ROUTE_TAG_AUTH_FLOW, ROUTE_TAG_CAN_REDIRECT } from '../tags';
import { defineSAMLRoutes } from './saml';
describe('SAML authentication routes', () => {
@ -43,7 +44,11 @@ describe('SAML authentication routes', () => {
});
it('correctly defines route.', () => {
expect(routeConfig.options).toEqual({ authRequired: false, xsrfRequired: false });
expect(routeConfig.options).toEqual({
authRequired: false,
xsrfRequired: false,
tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW],
});
expect(routeConfig.validate).toEqual({
body: expect.any(Type),
query: undefined,

View file

@ -8,17 +8,13 @@
import { schema } from '@kbn/config-schema';
import type { RouteDefinitionParams } from '../';
import { SAMLLogin } from '../../authentication';
import { SAMLAuthenticationProvider } from '../../authentication/providers';
import { SAMLAuthenticationProvider, SAMLLogin } from '../../authentication';
import { ROUTE_TAG_AUTH_FLOW, ROUTE_TAG_CAN_REDIRECT } from '../tags';
/**
* Defines routes required for SAML authentication.
*/
export function defineSAMLRoutes({
router,
logger,
getAuthenticationService,
}: RouteDefinitionParams) {
export function defineSAMLRoutes({ router, getAuthenticationService }: RouteDefinitionParams) {
router.post(
{
path: '/api/security/saml/callback',
@ -28,7 +24,11 @@ export function defineSAMLRoutes({
{ unknowns: 'ignore' }
),
},
options: { authRequired: false, xsrfRequired: false },
options: {
authRequired: false,
xsrfRequired: false,
tags: [ROUTE_TAG_CAN_REDIRECT, ROUTE_TAG_AUTH_FLOW],
},
},
async (context, request, response) => {
// When authenticating using SAML we _expect_ to redirect to the Kibana target location.

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/**
* If, for whatever reason, the API route path doesn't follow the API naming convention and doesn't
* start with `/api` or `/internal` prefix, it should be marked with this tag explicitly to let
* Security know that it should be handled as any other API route.
*/
export const ROUTE_TAG_API = 'api';
/**
* If the route is marked with this tag Security can safely assume that the calling party that sends
* request to this route can handle redirect responses. It's particularly important if we want the
* specific route to be able to initiate or participate in the authentication handshake that may
* involve redirects and will eventually redirect authenticated user to this route.
*/
export const ROUTE_TAG_CAN_REDIRECT = 'security:canRedirect';
/**
* The routes that are involved into authentication flows, especially if they are used by the 3rd
* parties, require special handling.
*/
export const ROUTE_TAG_AUTH_FLOW = 'security:authFlow';

View file

@ -43,46 +43,10 @@ describe('Capture URL view routes', () => {
});
const queryValidator = (routeConfig.validate as any).query as Type<any>;
expect(
queryValidator.validate({ providerType: 'basic', providerName: 'basic1', next: '/some-url' })
).toEqual({ providerType: 'basic', providerName: 'basic1', next: '/some-url' });
expect(queryValidator.validate({ providerType: 'basic', providerName: 'basic1' })).toEqual({
providerType: 'basic',
providerName: 'basic1',
expect(queryValidator.validate({})).toEqual({});
expect(queryValidator.validate({ next: '/some-url', something: 'something' })).toEqual({
next: '/some-url',
});
expect(() => queryValidator.validate({ providerType: '' })).toThrowErrorMatchingInlineSnapshot(
`"[providerType]: value has length [0] but it must have a minimum length of [1]."`
);
expect(() =>
queryValidator.validate({ providerType: 'basic' })
).toThrowErrorMatchingInlineSnapshot(
`"[providerName]: expected value of type [string] but got [undefined]"`
);
expect(() => queryValidator.validate({ providerName: '' })).toThrowErrorMatchingInlineSnapshot(
`"[providerType]: expected value of type [string] but got [undefined]"`
);
expect(() =>
queryValidator.validate({ providerName: 'basic1' })
).toThrowErrorMatchingInlineSnapshot(
`"[providerType]: expected value of type [string] but got [undefined]"`
);
expect(() =>
queryValidator.validate({ providerType: 'basic', providerName: '' })
).toThrowErrorMatchingInlineSnapshot(
`"[providerName]: value has length [0] but it must have a minimum length of [1]."`
);
expect(() =>
queryValidator.validate({ providerType: '', providerName: 'basic1' })
).toThrowErrorMatchingInlineSnapshot(
`"[providerType]: value has length [0] but it must have a minimum length of [1]."`
);
});
it('renders view.', async () => {

View file

@ -17,11 +17,7 @@ export function defineCaptureURLRoutes({ httpResources }: RouteDefinitionParams)
{
path: '/internal/security/capture-url',
validate: {
query: schema.object({
providerType: schema.string({ minLength: 1 }),
providerName: schema.string({ minLength: 1 }),
next: schema.maybe(schema.string()),
}),
query: schema.object({ next: schema.maybe(schema.string()) }, { unknowns: 'ignore' }),
},
options: { authRequired: false },
},

View file

@ -17161,7 +17161,6 @@
"xpack.security.loggedOut.login": "ログイン",
"xpack.security.loggedOut.title": "ログアウト完了",
"xpack.security.loggedOutAppTitle": "ログアウト",
"xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage": "無効なユーザー名またはパスワードです。再試行してください。",
"xpack.security.login.basicLoginForm.logInButtonLabel": "ログイン",
"xpack.security.login.basicLoginForm.passwordFormRowLabel": "パスワード",
"xpack.security.login.basicLoginForm.unknownErrorMessage": "おっと!エラー。再試行してください。",

View file

@ -17400,7 +17400,6 @@
"xpack.security.loggedOut.login": "登录",
"xpack.security.loggedOut.title": "已成功退出",
"xpack.security.loggedOutAppTitle": "已注销",
"xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage": "用户名或密码无效。请重试。",
"xpack.security.login.basicLoginForm.logInButtonLabel": "登录",
"xpack.security.login.basicLoginForm.passwordFormRowLabel": "密码",
"xpack.security.login.basicLoginForm.unknownErrorMessage": "糟糕!错误。请重试。",

View file

@ -303,11 +303,11 @@ export default function ({ getService }) {
expect(loginViewResponse.headers.location).to.be('/');
});
it('should redirect to home page if cookie is not provided', async () => {
it('should redirect to login page if cookie is not provided', async () => {
const logoutResponse = await supertest.get('/api/security/logout').expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
expect(logoutResponse.headers.location).to.be('/login?msg=LOGGED_OUT');
});
});
});

View file

@ -41,7 +41,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expectSuccess: false,
});
const errorMessage = await PageObjects.security.loginPage.getErrorMessage();
expect(errorMessage).to.be('Invalid username or password. Please try again.');
expect(errorMessage).to.be('Username or password is incorrect. Please try again.');
});
it('displays message acknowledging logout', async () => {

View file

@ -112,11 +112,16 @@ export default function ({ getService }: FtrProviderContext) {
});
it('should fail if `Authorization` header is present, but not valid', async () => {
const response = await supertest
const unauthenticatedResponse = await supertest
.get('/security/account')
.set('Authorization', 'Basic wow')
.expect(401);
expect(response.headers['set-cookie']).to.be(undefined);
expect(unauthenticatedResponse.headers['set-cookie']).to.be(undefined);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.text).to.contain('We couldn&#x27;t log you in');
});
});
@ -156,9 +161,14 @@ export default function ({ getService }: FtrProviderContext) {
const apiResponse = await supertest
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.set('Authorization', 'Basic a3JiNTprcmI1')
.set('Authorization', 'Basic ZHVtbXlfaGFja2VyOnBhc3M=')
.set('Cookie', sessionCookie.cookieString())
.expect(401);
.expect(401, {
statusCode: 401,
error: 'Unauthorized',
message:
'[security_exception]: unable to authenticate user [dummy_hacker] for REST request [/_security/_authenticate]',
});
expect(apiResponse.headers['set-cookie']).to.be(undefined);
});
@ -203,7 +213,7 @@ export default function ({ getService }: FtrProviderContext) {
const logoutResponse = await supertest.get('/api/security/logout').expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
expect(logoutResponse.headers.location).to.be('/security/logged_out?msg=LOGGED_OUT');
});
});
});

View file

@ -53,7 +53,10 @@ export default function ({ getService }: FtrProviderContext) {
});
it('should reject API requests if client is not authenticated', async () => {
await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401);
await supertest
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.expect(401, { statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' });
});
it('does not prevent basic login', async () => {
@ -92,6 +95,13 @@ export default function ({ getService }: FtrProviderContext) {
expect(spnegoResponse.headers['set-cookie']).to.be(undefined);
expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate');
// If browser and Kibana can successfully negotiate this HTML won't rendered, but if not
// users will see a proper `Unauthenticated` page.
expect(spnegoResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(spnegoResponse.text).to.contain('We couldn&#x27;t log you in');
});
it('AJAX requests should not initiate SPNEGO', async () => {
@ -285,7 +295,7 @@ export default function ({ getService }: FtrProviderContext) {
const logoutResponse = await supertest.get('/api/security/logout').expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
expect(logoutResponse.headers.location).to.be('/security/logged_out?msg=LOGGED_OUT');
});
});

View file

@ -394,7 +394,7 @@ export default function ({ getService }: FtrProviderContext) {
)!;
// And now try to login with `saml2`.
await supertest
const unauthenticatedResponse = await supertest
.post('/api/security/saml/callback')
.ca(CA_CERT)
.set('Cookie', saml1HandshakeCookie.cookieString())
@ -402,6 +402,30 @@ export default function ({ getService }: FtrProviderContext) {
SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }),
})
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.headers.refresh).to.be(
`0;url=/logout?msg=UNAUTHENTICATED&next=%2F`
);
});
it('should fail if SAML response is not valid', async () => {
const unauthenticatedResponse = await supertest
.post('/api/security/saml/callback')
.ca(CA_CERT)
.send({
SAMLResponse: await createSAMLResponse({ inResponseTo: 'some-invalid-request-id' }),
})
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.headers.refresh).to.be(
`0;url=/login?msg=UNAUTHENTICATED&next=%2F`
);
});
it('should be able to log in via SP initiated login with any configured realm', async () => {
@ -654,6 +678,41 @@ export default function ({ getService }: FtrProviderContext) {
);
});
it('should fail IdP initiated login if state is not matching', 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])!;
const unauthenticatedResponse = await supertest
.get('/api/security/oidc/callback?code=code2&state=someothervalue')
.ca(CA_CERT)
.set('Cookie', handshakeCookie.cookieString())
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.headers.refresh).to.be(
`0;url=/logout?msg=UNAUTHENTICATED&next=%2F`
);
});
it('should fail IdP initiated login if issuer is not known', async () => {
const unauthenticatedResponse = await supertest
.get('/api/security/oidc/initiate_login?iss=https://dummy.hacker.co')
.ca(CA_CERT)
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.headers.refresh).to.be(
`0;url=/login?msg=UNAUTHENTICATED&next=%2F`
);
});
it('should be able to log in via SP initiated login', async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')

View file

@ -19,7 +19,10 @@ export default function ({ getService }: FtrProviderContext) {
describe('OpenID Connect authentication', () => {
it('should reject API requests if client is not authenticated', async () => {
await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401);
await supertest
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.expect(401, { statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' });
});
it('does not prevent basic login', async () => {
@ -57,21 +60,16 @@ export default function ({ getService }: FtrProviderContext) {
expect(handshakeResponse.headers['set-cookie']).to.be(undefined);
expect(handshakeResponse.headers.location).to.be(
'/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc'
'/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2Bthree%26auth_provider_hint%3Doidc'
);
});
it('should properly set cookie, return all parameters and redirect user', async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'oidc',
providerName: 'oidc',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
@ -82,7 +80,10 @@ export default function ({ getService }: FtrProviderContext) {
expect(handshakeCookie.path).to.be('/');
expect(handshakeCookie.httpOnly).to.be(true);
const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */);
const redirectURL = url.parse(
handshakeResponse.headers.location,
true /* parseQueryString */
);
expect(
redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)
).to.be(true);
@ -126,15 +127,10 @@ export default function ({ getService }: FtrProviderContext) {
it('should not allow access to the API with the handshake cookie', async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'oidc',
providerName: 'oidc',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
await supertest
@ -160,18 +156,13 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'oidc',
providerName: 'oidc',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
stateAndNonce = getStateAndNonce(handshakeResponse.body.location);
stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
@ -181,30 +172,37 @@ export default function ({ getService }: FtrProviderContext) {
});
it('should fail if OpenID Connect response is not complemented with handshake cookie', async () => {
await supertest
const unauthenticatedResponse = await supertest
.get(`/api/security/oidc/callback?code=thisisthecode&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.text).to.contain('We couldn&#x27;t log you in');
});
it('should fail if state is not matching', async () => {
await supertest
const unauthenticatedResponse = await supertest
.get(`/api/security/oidc/callback?code=thisisthecode&state=someothervalue`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.text).to.contain('We couldn&#x27;t log you in');
});
it('should succeed if both the OpenID Connect response and the cookie are provided', async () => {
const oidcAuthenticationResponse = await supertest
.get(`/api/security/oidc/callback?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
// User should be redirected to the URL that initiated handshake.
expect(oidcAuthenticationResponse.headers.location).to.be(
'/abc/xyz/handshake?one=two%20three#/workpad'
'/abc/xyz/handshake?one=two+three#/workpad'
);
const cookies = oidcAuthenticationResponse.headers['set-cookie'];
@ -258,7 +256,6 @@ export default function ({ getService }: FtrProviderContext) {
const oidcAuthenticationResponse = await supertest
.get(`/api/security/oidc/callback?code=code2&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
const cookies = oidcAuthenticationResponse.headers['set-cookie'];
@ -301,18 +298,13 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'oidc',
providerName: 'oidc',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
sessionCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
stateAndNonce = getStateAndNonce(handshakeResponse.body.location);
stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
@ -322,7 +314,6 @@ export default function ({ getService }: FtrProviderContext) {
const oidcAuthenticationResponse = await supertest
.get(`/api/security/oidc/callback?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.expect(302);
@ -383,18 +374,13 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'oidc',
providerName: 'oidc',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
const stateAndNonce = getStateAndNonce(handshakeResponse.body.location);
const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
@ -404,7 +390,6 @@ export default function ({ getService }: FtrProviderContext) {
const oidcAuthenticationResponse = await supertest
.get(`/api/security/oidc/callback?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
@ -418,7 +403,7 @@ export default function ({ getService }: FtrProviderContext) {
const logoutResponse = await supertest.get('/api/security/logout').expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
expect(logoutResponse.headers.location).to.be('/security/logged_out?msg=LOGGED_OUT');
});
it('should redirect to the OPs endsession endpoint to complete logout', async () => {
@ -472,18 +457,13 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'oidc',
providerName: 'oidc',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
const stateAndNonce = getStateAndNonce(handshakeResponse.body.location);
const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
@ -493,7 +473,6 @@ export default function ({ getService }: FtrProviderContext) {
const oidcAuthenticationResponse = await supertest
.get(`/api/security/oidc/callback?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
@ -569,18 +548,13 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'oidc',
providerName: 'oidc',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
const stateAndNonce = getStateAndNonce(handshakeResponse.body.location);
const stateAndNonce = getStateAndNonce(handshakeResponse.headers.location);
// Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens
await supertest
.post('/api/oidc_provider/setup')
@ -590,7 +564,6 @@ export default function ({ getService }: FtrProviderContext) {
const oidcAuthenticationResponse = await supertest
.get(`/api/security/oidc/callback?code=code1&state=${stateAndNonce.state}`)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);
@ -612,16 +585,11 @@ export default function ({ getService }: FtrProviderContext) {
expect(esResponse.body).to.have.property('deleted').greaterThan(0);
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.set('Cookie', sessionCookie.cookieString())
.send({
providerType: 'oidc',
providerName: 'oidc',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=oidc&providerName=oidc#/workpad',
})
.expect(200);
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
@ -632,7 +600,10 @@ export default function ({ getService }: FtrProviderContext) {
expect(handshakeCookie.path).to.be('/');
expect(handshakeCookie.httpOnly).to.be(true);
const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */);
const redirectURL = url.parse(
handshakeResponse.headers.location,
true /* parseQueryString */
);
expect(
redirectURL.href!.startsWith(`https://test-op.elastic.co/oauth2/v1/authorize`)
).to.be(true);

View file

@ -83,32 +83,39 @@ export default function ({ getService }: FtrProviderContext) {
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`;
await supertest
const unauthenticatedResponse = await supertest
.get(
`/api/security/oidc/callback?authenticationResponseURI=${encodeURIComponent(
authenticationResponse
)}`
)
.set('kbn-xsrf', 'xxx')
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.text).to.contain('We couldn&#x27;t log you in');
});
it('should fail if state is not matching', async () => {
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=$someothervalue&token_type=bearer&access_token=${accessToken}`;
await supertest
const unauthenticatedResponse = await supertest
.get(
`/api/security/oidc/callback?authenticationResponseURI=${encodeURIComponent(
authenticationResponse
)}`
)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.text).to.contain('We couldn&#x27;t log you in');
});
// FLAKY: https://github.com/elastic/kibana/issues/43938
it('should succeed if both the OpenID Connect response and the cookie are provided', async () => {
const { idToken, accessToken } = createTokens('1', stateAndNonce.nonce);
const authenticationResponse = `https://kibana.com/api/security/oidc/implicit#id_token=${idToken}&state=${stateAndNonce.state}&token_type=bearer&access_token=${accessToken}`;
@ -119,7 +126,6 @@ export default function ({ getService }: FtrProviderContext) {
authenticationResponse
)}`
)
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.expect(302);

View file

@ -62,7 +62,21 @@ export default function ({ getService }: FtrProviderContext) {
.ca(CA_CERT)
.pfx(UNTRUSTED_CLIENT_CERT)
.set('kbn-xsrf', 'xxx')
.expect(401, { statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' });
});
it('should fail and redirect if untrusted certificate is used', async () => {
// Unlike the call to '/internal/security/me' above, this route can be redirected (see pre-response in `AuthenticationService`).
const unauthenticatedResponse = await supertest
.get('/security/account')
.ca(CA_CERT)
.pfx(UNTRUSTED_CLIENT_CERT)
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.text).to.contain('We couldn&#x27;t log you in');
});
it('does not prevent basic login', async () => {
@ -319,7 +333,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
expect(logoutResponse.headers.location).to.be('/security/logged_out?msg=LOGGED_OUT');
});
});

View file

@ -74,7 +74,10 @@ export default function ({ getService }: FtrProviderContext) {
describe('SAML authentication', () => {
it('should reject API requests if client is not authenticated', async () => {
await supertest.get('/internal/security/me').set('kbn-xsrf', 'xxx').expect(401);
await supertest
.get('/internal/security/me')
.set('kbn-xsrf', 'xxx')
.expect(401, { statusCode: 401, error: 'Unauthorized', message: 'Unauthorized' });
});
it('does not prevent basic login', async () => {
@ -112,20 +115,16 @@ export default function ({ getService }: FtrProviderContext) {
expect(handshakeResponse.headers['set-cookie']).to.be(undefined);
expect(handshakeResponse.headers.location).to.be(
'/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml'
'/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2Bthree%26auth_provider_hint%3Dsaml'
);
});
it('should properly set cookie and redirect user to IdP', async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'saml',
providerName: 'saml',
currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`,
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
@ -136,21 +135,20 @@ export default function ({ getService }: FtrProviderContext) {
expect(handshakeCookie.path).to.be('/');
expect(handshakeCookie.httpOnly).to.be(true);
const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */);
const redirectURL = url.parse(
handshakeResponse.headers.location,
true /* parseQueryString */
);
expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true);
expect(redirectURL.query.SAMLRequest).to.not.be.empty();
});
it('should not allow access to the API with the handshake cookie', async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'saml',
providerName: 'saml',
currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`,
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
await supertest
@ -176,39 +174,37 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'saml',
providerName: 'saml',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
samlRequestId = await getSAMLRequestId(handshakeResponse.body.location);
samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location);
});
it('should fail if SAML response is not complemented with handshake cookie', async () => {
await supertest
const unauthenticatedResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) })
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.text).to.contain('We couldn&#x27;t log you in');
});
it('should succeed if both SAML response and handshake cookie are provided', async () => {
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) })
.expect(302);
// User should be redirected to the URL that initiated handshake.
expect(samlAuthenticationResponse.headers.location).to.be(
'/abc/xyz/handshake?one=two%20three#/workpad'
'/abc/xyz/handshake?one=two+three#/workpad'
);
const cookies = samlAuthenticationResponse.headers['set-cookie'];
@ -221,7 +217,6 @@ export default function ({ getService }: FtrProviderContext) {
// Don't pass handshake cookie and don't include `inResponseTo` into SAML response to simulate IdP initiated login.
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.send({ SAMLResponse: await createSAMLResponse() })
.expect(302);
@ -235,14 +230,18 @@ export default function ({ getService }: FtrProviderContext) {
});
it('should fail if SAML response is not valid', async () => {
await supertest
const unauthenticatedResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.send({
SAMLResponse: await createSAMLResponse({ inResponseTo: 'some-invalid-request-id' }),
})
.expect(401);
expect(unauthenticatedResponse.headers['content-security-policy']).to.be(
`script-src 'unsafe-eval' 'self'; worker-src blob: 'self'; style-src 'unsafe-inline' 'self'`
);
expect(unauthenticatedResponse.text).to.contain('We couldn&#x27;t log you in');
});
});
@ -253,7 +252,6 @@ export default function ({ getService }: FtrProviderContext) {
// Imitate IdP initiated login.
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.send({ SAMLResponse: await createSAMLResponse() })
.expect(302);
@ -315,23 +313,17 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'saml',
providerName: 'saml',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location);
const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location);
idpSessionIndex = String(randomness.naturalNumber());
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.send({
SAMLResponse: await createSAMLResponse({
@ -372,11 +364,11 @@ export default function ({ getService }: FtrProviderContext) {
.expect(401);
});
it('should redirect to home page if session cookie is not provided', async () => {
it('should redirect to `logged_out` page if session cookie is not provided', async () => {
const logoutResponse = await supertest.get('/api/security/logout').expect(302);
expect(logoutResponse.headers['set-cookie']).to.be(undefined);
expect(logoutResponse.headers.location).to.be('/');
expect(logoutResponse.headers.location).to.be('/security/logged_out?msg=LOGGED_OUT');
});
it('should reject AJAX requests', async () => {
@ -459,22 +451,16 @@ export default function ({ getService }: FtrProviderContext) {
this.timeout(40000);
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'saml',
providerName: 'saml',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location);
const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location);
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) })
.expect(302);
@ -559,22 +545,16 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'saml',
providerName: 'saml',
currentURL:
'https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad',
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location);
const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location);
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.send({ SAMLResponse: await createSAMLResponse({ inResponseTo: samlRequestId }) })
.expect(302);
@ -609,21 +589,16 @@ export default function ({ getService }: FtrProviderContext) {
expect(handshakeCookie.maxAge).to.be(0);
expect(handshakeResponse.headers.location).to.be(
'/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml'
'/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2Bthree%26auth_provider_hint%3Dsaml'
);
});
it('should properly set cookie and redirect user to IdP', async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.set('Cookie', sessionCookie.cookieString())
.send({
providerType: 'saml',
providerName: 'saml',
currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`,
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const cookies = handshakeResponse.headers['set-cookie'];
expect(cookies).to.have.length(1);
@ -634,7 +609,10 @@ export default function ({ getService }: FtrProviderContext) {
expect(handshakeCookie.path).to.be('/');
expect(handshakeCookie.httpOnly).to.be(true);
const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */);
const redirectURL = url.parse(
handshakeResponse.headers.location,
true /* parseQueryString */
);
expect(redirectURL.href!.startsWith(`https://elastic.co/sso/saml`)).to.be(true);
expect(redirectURL.query.SAMLRequest).to.not.be.empty();
});
@ -680,21 +658,16 @@ export default function ({ getService }: FtrProviderContext) {
beforeEach(async () => {
const handshakeResponse = await supertest
.post('/internal/security/login')
.set('kbn-xsrf', 'xxx')
.send({
providerType: 'saml',
providerName: 'saml',
currentURL: `https://kibana.com/internal/security/capture-url?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three&providerType=saml&providerName=saml#/workpad`,
})
.expect(200);
.get(
'/abc/xyz/handshake?one=two three&auth_provider_hint=saml&auth_url_hash=%23%2Fworkpad'
)
.expect(302);
const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!;
const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location);
const samlRequestId = await getSAMLRequestId(handshakeResponse.headers.location);
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.set('Cookie', handshakeCookie.cookieString())
.send({
SAMLResponse: await createSAMLResponse({
@ -715,7 +688,6 @@ export default function ({ getService }: FtrProviderContext) {
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.set('Cookie', existingSessionCookie.cookieString())
.send({ SAMLResponse: await createSAMLResponse({ username: existingUsername }) })
.expect(302);
@ -745,7 +717,6 @@ export default function ({ getService }: FtrProviderContext) {
const newUsername = 'c@d.e';
const samlAuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.set('kbn-xsrf', 'xxx')
.set('Cookie', existingSessionCookie.cookieString())
.send({ SAMLResponse: await createSAMLResponse({ username: newUsername }) })
.expect(302);

View file

@ -0,0 +1,7 @@
{
"id": "securityTestEndpoints",
"version": "8.0.0",
"kibanaVersion": "kibana",
"server": true,
"ui": true
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TestEndpointsPlugin } from './plugin';
export const plugin = () => new TestEndpointsPlugin();

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreSetup, Plugin } from 'src/core/public';
import ReactDOM from 'react-dom';
import React from 'react';
export class TestEndpointsPlugin implements Plugin {
public setup(core: CoreSetup) {
core.application.register({
id: 'authentication_app',
title: 'Authentication app',
appRoute: '/authentication/app',
async mount({ element }) {
ReactDOM.render(
<div data-test-subj="testEndpointsAuthenticationApp">Authenticated!</div>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
},
});
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { PluginInitializer } from '../../../../../../../src/core/server';
import { initRoutes } from './init_routes';
export const plugin: PluginInitializer<void, void> = () => ({
setup: (core) => initRoutes(core),
start: () => {},
stop: () => {},
});

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
import { CoreSetup } from '../../../../../../../src/core/server';
export function initRoutes(core: CoreSetup) {
const authenticationAppOptions = { simulateUnauthorized: false };
core.http.resources.register(
{
path: '/authentication/app',
validate: false,
},
async (context, request, response) => {
if (authenticationAppOptions.simulateUnauthorized) {
return response.unauthorized();
}
return response.renderCoreApp();
}
);
const router = core.http.createRouter();
router.post(
{
path: '/authentication/app/setup',
validate: { body: schema.object({ simulateUnauthorized: schema.boolean() }) },
options: { authRequired: false, xsrfRequired: false },
},
(context, request, response) => {
authenticationAppOptions.simulateUnauthorized = request.body.simulateUnauthorized;
return response.ok();
}
);
}

View file

@ -30,6 +30,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'../security_api_integration/fixtures/saml/saml_provider'
);
const testEndpointsPlugin = resolve(__dirname, './fixtures/common/test_endpoints');
return {
testFiles: [resolve(__dirname, './tests/login_selector')],
@ -59,6 +61,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
serverArgs: [
...kibanaCommonConfig.get('kbnTestServer.serverArgs'),
`--plugin-path=${samlIdPPlugin}`,
`--plugin-path=${testEndpointsPlugin}`,
'--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d',
'--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
`--xpack.security.loginHelp="Some-login-help."`,

View file

@ -27,6 +27,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'../security_api_integration/fixtures/oidc/oidc_provider'
);
const testEndpointsPlugin = resolve(__dirname, './fixtures/common/test_endpoints');
return {
testFiles: [resolve(__dirname, './tests/oidc')],
@ -61,6 +63,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
serverArgs: [
...kibanaCommonConfig.get('kbnTestServer.serverArgs'),
`--plugin-path=${oidcOpPPlugin}`,
`--plugin-path=${testEndpointsPlugin}`,
'--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d',
'--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
'--xpack.security.authc.selector.enabled=false',

View file

@ -30,6 +30,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'../security_api_integration/fixtures/saml/saml_provider'
);
const testEndpointsPlugin = resolve(__dirname, './fixtures/common/test_endpoints');
return {
testFiles: [resolve(__dirname, './tests/saml')],
@ -58,6 +60,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
serverArgs: [
...kibanaCommonConfig.get('kbnTestServer.serverArgs'),
`--plugin-path=${samlIdPPlugin}`,
`--plugin-path=${testEndpointsPlugin}`,
'--server.uuid=5b2de169-2785-441b-ae8c-186a1936b17d',
'--xpack.security.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
'--xpack.security.authc.selector.enabled=false',

View file

@ -20,17 +20,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
describe('Basic functionality', function () {
this.tags('includeFirefox');
const testCredentials = { username: 'admin_user', password: 'change_me' };
before(async () => {
await getService('esSupertest')
.post('/_security/role_mapping/saml1')
.send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } })
.expect(200);
await security.user.create(testCredentials.username, {
password: testCredentials.password,
roles: ['kibana_admin'],
full_name: 'Admin',
});
await esArchiver.load('../../functional/es_archives/empty_kibana');
await PageObjects.security.forceLogout();
});
after(async () => {
await security.user.delete(testCredentials.username);
await esArchiver.unload('../../functional/es_archives/empty_kibana');
});
@ -95,6 +103,58 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(currentURL.pathname).to.eql('/app/management/security/users');
});
it('can login after `Unauthorized` error after request authentication preserving original URL', async () => {
await getService('supertest')
.post('/authentication/app/setup')
.send({ simulateUnauthorized: true })
.expect(200);
await PageObjects.security.loginSelector.login('basic', 'basic1');
await browser.get(`${deployment.getHostPort()}/authentication/app?one=two`);
await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible();
expect(await PageObjects.security.loginPage.getErrorMessage()).to.be(
"We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator."
);
await getService('supertest')
.post('/authentication/app/setup')
.send({ simulateUnauthorized: false })
.expect(200);
await PageObjects.security.loginSelector.login('basic', 'basic1');
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.path).to.eql('/authentication/app?one=two');
});
it('can login after `Unauthorized` error during request authentication preserving original URL', async () => {
// 1. Navigate to Kibana to make sure user is properly authenticated.
await PageObjects.common.navigateToUrl('management', 'security/users', {
ensureCurrentUrl: false,
shouldLoginIfPrompted: false,
shouldUseHashForSubUrl: false,
});
await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible();
await PageObjects.security.loginSelector.login('basic', 'basic1', testCredentials);
expect(parse(await browser.getCurrentUrl()).pathname).to.eql(
'/app/management/security/users'
);
// 2. Now disable user and try to refresh page causing authentication to fail.
await security.user.disable(testCredentials.username);
await browser.refresh();
await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible();
expect(await PageObjects.security.loginPage.getErrorMessage()).to.be(
"We hit an authentication error. Please check your credentials and try again. If you still can't log in, contact your system administrator."
);
// 3. Re-enable user and try to login again.
await security.user.enable(testCredentials.username);
await PageObjects.security.loginSelector.login('basic', 'basic1', testCredentials);
expect(parse(await browser.getCurrentUrl()).pathname).to.eql(
'/app/management/security/users'
);
});
it('should show toast with error if SSO fails', async () => {
await PageObjects.security.loginSelector.selectLoginMethod('saml', 'unknown_saml');

View file

@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const find = getService('find');
const browser = getService('browser');
const deployment = getService('deployment');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common']);
describe('URL capture', function () {
@ -52,5 +53,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(currentURL.pathname).to.eql('/app/management/security/users');
expect(currentURL.hash).to.eql('#some=hash-value');
});
it('can login after `Unauthorized` error preserving original URL', async () => {
await getService('supertest')
.post('/authentication/app/setup')
.send({ simulateUnauthorized: true })
.expect(200);
await browser.get(`${deployment.getHostPort()}/authentication/app?one=two`);
await find.byCssSelector('[data-test-subj="promptPage"]', 20000);
await getService('supertest')
.post('/authentication/app/setup')
.send({ simulateUnauthorized: false })
.expect(200);
await testSubjects.click('logInButton');
await find.byCssSelector('[data-test-subj="testEndpointsAuthenticationApp"]', 20000);
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.path).to.eql('/authentication/app?one=two');
});
});
}

View file

@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const find = getService('find');
const browser = getService('browser');
const deployment = getService('deployment');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common']);
describe('URL capture', function () {
@ -52,5 +53,25 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(currentURL.pathname).to.eql('/app/management/security/users');
expect(currentURL.hash).to.eql('#some=hash-value');
});
it('can login after `Unauthorized` error preserving original URL', async () => {
await getService('supertest')
.post('/authentication/app/setup')
.send({ simulateUnauthorized: true })
.expect(200);
await browser.get(`${deployment.getHostPort()}/authentication/app?one=two`);
await find.byCssSelector('[data-test-subj="promptPage"]', 20000);
await getService('supertest')
.post('/authentication/app/setup')
.send({ simulateUnauthorized: false })
.expect(200);
await testSubjects.click('logInButton');
await find.byCssSelector('[data-test-subj="testEndpointsAuthenticationApp"]', 20000);
const currentURL = parse(await browser.getCurrentUrl());
expect(currentURL.path).to.eql('/authentication/app?one=two');
});
});
}