Make all providers to preserve original URL when session expires. (#84229)

This commit is contained in:
Aleh Zasypkin 2020-12-02 11:32:22 +01:00 committed by GitHub
parent 30f8e41d45
commit 59a405dc80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 322 additions and 168 deletions

View file

@ -19,3 +19,6 @@ export const APPLICATION_PREFIX = 'kibana-';
export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*';
export const AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER = 'auth_provider_hint';
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

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import type { AuthenticationProvider } from '../types';
import { User } from './user';
import type { AuthenticationProvider, User } from '.';
const REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE = ['reserved', 'native'];

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { shouldProviderUseLoginForm } from './authentication_provider';
describe('#shouldProviderUseLoginForm', () => {
['basic', 'token'].forEach((providerType) => {
it(`returns "true" for "${providerType}" provider`, () => {
expect(shouldProviderUseLoginForm(providerType)).toEqual(true);
});
});
['anonymous', 'http', 'kerberos', 'oidc', 'pki', 'saml'].forEach((providerType) => {
it(`returns "false" for "${providerType}" provider`, () => {
expect(shouldProviderUseLoginForm(providerType)).toEqual(false);
});
});
});

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* Type and name tuple to identify provider used to authenticate user.
*/
export interface AuthenticationProvider {
type: string;
name: string;
}
/**
* Checks whether authentication provider with the specified type uses Kibana's native login form.
* @param providerType Type of the authentication provider.
*/
export function shouldProviderUseLoginForm(providerType: string) {
return providerType === 'basic' || providerType === 'token';
}

View file

@ -7,6 +7,7 @@
export { ApiKey, ApiKeyToInvalidate } from './api_key';
export { User, EditUser, getUserDisplayName } from './user';
export { AuthenticatedUser, canUserChangePassword } from './authenticated_user';
export { AuthenticationProvider, shouldProviderUseLoginForm } from './authentication_provider';
export { BuiltinESPrivileges } from './builtin_es_privileges';
export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges';
export { FeaturesPrivileges } from './features_privileges';

View file

@ -5,19 +5,21 @@
*/
import { parse } from 'url';
import { NEXT_URL_QUERY_STRING_PARAMETER } from './constants';
import { isInternalURL } from './is_internal_url';
export function parseNext(href: string, basePath = '') {
const { query, hash } = parse(href, true);
if (!query.next) {
let next = query[NEXT_URL_QUERY_STRING_PARAMETER];
if (!next) {
return `${basePath}/`;
}
let next: string;
if (Array.isArray(query.next) && query.next.length > 0) {
next = query.next[0];
if (Array.isArray(next) && next.length > 0) {
next = next[0];
} else {
next = query.next as string;
next = next as string;
}
// validate that `next` is not attempting a redirect to somewhere

View file

@ -4,13 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
/**
* Type and name tuple to identify provider used to authenticate user.
*/
export interface AuthenticationProvider {
type: string;
name: string;
}
import type { AuthenticationProvider } from './model';
export interface SessionInfo {
now: number;

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiButton } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test/jest';
import { LoggedOutPage } from './logged_out_page';
import { coreMock } from '../../../../../../src/core/public/mocks';
describe('LoggedOutPage', () => {
beforeAll(() => {
Object.defineProperty(window, 'location', {
value: { href: 'https://some-host' },
writable: true,
});
});
it('points to a base path if `next` parameter is not provided', async () => {
const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath;
const wrapper = mountWithIntl(<LoggedOutPage basePath={basePathMock} />);
expect(wrapper.find(EuiButton).prop('href')).toBe('/mock-base-path/');
});
it('properly parses `next` parameter', async () => {
window.location.href = `https://host.com/mock-base-path/security/logged_out?next=${encodeURIComponent(
'/mock-base-path/app/home#/?_g=()'
)}`;
const basePathMock = coreMock.createStart({ basePath: '/mock-base-path' }).http.basePath;
const wrapper = mountWithIntl(<LoggedOutPage basePath={basePathMock} />);
expect(wrapper.find(EuiButton).prop('href')).toBe('/mock-base-path/app/home#/?_g=()');
});
});

View file

@ -9,6 +9,7 @@ import ReactDOM from 'react-dom';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { CoreStart, IBasePath } from 'src/core/public';
import { parseNext } from '../../../common/parse_next';
import { AuthenticationStatePage } from '../components';
interface Props {
@ -25,7 +26,7 @@ export function LoggedOutPage({ basePath }: Props) {
/>
}
>
<EuiButton href={basePath.prepend('/')}>
<EuiButton href={parseNext(window.location.href, basePath.serverBasePath)}>
<FormattedMessage id="xpack.security.loggedOut.login" defaultMessage="Log in" />
</EuiButton>
</AuthenticationStatePage>

View file

@ -15,7 +15,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiTitle } from '@elasti
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public';
import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../../common/constants';
import {
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
LOGOUT_REASON_QUERY_STRING_PARAMETER,
} from '../../../common/constants';
import { LoginState } from '../../../common/login_state';
import { LoginForm, DisabledLoginForm } from './components';
@ -219,7 +222,7 @@ export class LoginPage extends Component<Props, State> {
http={this.props.http}
notifications={this.props.notifications}
selector={selector}
infoMessage={infoMessageMap.get(query.msg?.toString())}
infoMessage={infoMessageMap.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

@ -4,6 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
LOGOUT_PROVIDER_QUERY_STRING_PARAMETER,
LOGOUT_REASON_QUERY_STRING_PARAMETER,
NEXT_URL_QUERY_STRING_PARAMETER,
} from '../../common/constants';
export interface ISessionExpired {
logout(): void;
}
@ -11,13 +17,15 @@ export interface ISessionExpired {
const getNextParameter = () => {
const { location } = window;
const next = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`);
return `&next=${next}`;
return `&${NEXT_URL_QUERY_STRING_PARAMETER}=${next}`;
};
const getProviderParameter = (tenant: string) => {
const key = `${tenant}/session_provider`;
const providerName = sessionStorage.getItem(key);
return providerName ? `&provider=${encodeURIComponent(providerName)}` : '';
return providerName
? `&${LOGOUT_PROVIDER_QUERY_STRING_PARAMETER}=${encodeURIComponent(providerName)}`
: '';
};
export class SessionExpired {
@ -26,6 +34,8 @@ export class SessionExpired {
logout() {
const next = getNextParameter();
const provider = getProviderParameter(this.tenant);
window.location.assign(`${this.logoutUrl}?msg=SESSION_EXPIRED${next}${provider}`);
window.location.assign(
`${this.logoutUrl}?${LOGOUT_REASON_QUERY_STRING_PARAMETER}=SESSION_EXPIRED${next}${provider}`
);
}
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AuthenticationProvider } from '../../common/types';
import type { AuthenticationProvider } from '../../common/model';
import { LegacyAuditLogger } from './audit_service';
/**

View file

@ -111,31 +111,78 @@ describe('Authenticator', () => {
).toThrowError('Provider name "__http__" is reserved.');
});
it('properly sets `loggedOut` URL.', () => {
const basicAuthenticationProviderMock = jest.requireMock('./providers/basic')
.BasicAuthenticationProvider;
describe('#options.urls.loggedOut', () => {
it('points to /login if provider requires login form', () => {
const authenticationProviderMock = jest.requireMock(`./providers/basic`)
.BasicAuthenticationProvider;
authenticationProviderMock.mockClear();
new Authenticator(getMockOptions());
const getLoggedOutURL = authenticationProviderMock.mock.calls[0][0].urls.loggedOut;
basicAuthenticationProviderMock.mockClear();
new Authenticator(getMockOptions());
expect(basicAuthenticationProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
urls: {
loggedOut: '/mock-server-basepath/security/logged_out',
},
}),
expect.anything()
);
expect(getLoggedOutURL(httpServerMock.createKibanaRequest())).toBe(
'/mock-server-basepath/login?msg=LOGGED_OUT'
);
basicAuthenticationProviderMock.mockClear();
new Authenticator(getMockOptions({ selector: { enabled: true } }));
expect(basicAuthenticationProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
urls: {
loggedOut: `/mock-server-basepath/login?msg=LOGGED_OUT`,
},
}),
expect.anything()
);
expect(
getLoggedOutURL(
httpServerMock.createKibanaRequest({
query: { next: '/app/ml/encode me', msg: 'SESSION_EXPIRED' },
})
)
).toBe('/mock-server-basepath/login?next=%2Fapp%2Fml%2Fencode+me&msg=SESSION_EXPIRED');
});
it('points to /login if login selector is enabled', () => {
const authenticationProviderMock = jest.requireMock(`./providers/saml`)
.SAMLAuthenticationProvider;
authenticationProviderMock.mockClear();
new Authenticator(
getMockOptions({
selector: { enabled: true },
providers: { saml: { saml1: { order: 0, realm: 'realm' } } },
})
);
const getLoggedOutURL = authenticationProviderMock.mock.calls[0][0].urls.loggedOut;
expect(getLoggedOutURL(httpServerMock.createKibanaRequest())).toBe(
'/mock-server-basepath/login?msg=LOGGED_OUT'
);
expect(
getLoggedOutURL(
httpServerMock.createKibanaRequest({
query: { next: '/app/ml/encode me', msg: 'SESSION_EXPIRED' },
})
)
).toBe('/mock-server-basepath/login?next=%2Fapp%2Fml%2Fencode+me&msg=SESSION_EXPIRED');
});
it('points to /security/logged_out if login selector is NOT enabled', () => {
const authenticationProviderMock = jest.requireMock(`./providers/saml`)
.SAMLAuthenticationProvider;
authenticationProviderMock.mockClear();
new Authenticator(
getMockOptions({
selector: { enabled: false },
providers: { saml: { saml1: { order: 0, realm: 'realm' } } },
})
);
const getLoggedOutURL = authenticationProviderMock.mock.calls[0][0].urls.loggedOut;
expect(getLoggedOutURL(httpServerMock.createKibanaRequest())).toBe(
'/mock-server-basepath/security/logged_out?msg=LOGGED_OUT'
);
expect(
getLoggedOutURL(
httpServerMock.createKibanaRequest({
query: { next: '/app/ml/encode me', msg: 'SESSION_EXPIRED' },
})
)
).toBe(
'/mock-server-basepath/security/logged_out?next=%2Fapp%2Fml%2Fencode+me&msg=SESSION_EXPIRED'
);
});
});
describe('HTTP authentication provider', () => {
@ -1769,7 +1816,9 @@ describe('Authenticator', () => {
});
it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => {
const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic1' } });
const request = httpServerMock.createKibanaRequest({
query: { provider: 'basic1' },
});
mockOptions.session.get.mockResolvedValue(null);
mockBasicAuthenticationProvider.logout.mockResolvedValue(
@ -1782,7 +1831,7 @@ describe('Authenticator', () => {
expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1);
expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledWith(request, null);
expect(mockOptions.session.clear).not.toHaveBeenCalled();
expect(mockOptions.session.clear).toHaveBeenCalled();
});
it('if session does not exist and provider name is not available, returns whatever authentication provider returns.', async () => {
@ -1811,7 +1860,7 @@ describe('Authenticator', () => {
);
expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled();
expect(mockOptions.session.clear).not.toHaveBeenCalled();
expect(mockOptions.session.clear).toHaveBeenCalled();
});
});

View file

@ -10,10 +10,15 @@ import {
ILegacyClusterClient,
IBasePath,
} from '../../../../../src/core/server';
import { AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER } from '../../common/constants';
import {
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
LOGOUT_PROVIDER_QUERY_STRING_PARAMETER,
LOGOUT_REASON_QUERY_STRING_PARAMETER,
NEXT_URL_QUERY_STRING_PARAMETER,
} from '../../common/constants';
import type { SecurityLicense } from '../../common/licensing';
import type { AuthenticatedUser } from '../../common/model';
import type { AuthenticationProvider } from '../../common/types';
import type { AuthenticatedUser, AuthenticationProvider } from '../../common/model';
import { shouldProviderUseLoginForm } from '../../common/model';
import { SecurityAuditLogger, AuditServiceSetup, userLoginEvent } from '../audit';
import type { ConfigType } from '../config';
import { getErrorStatusCode } from '../errors';
@ -199,11 +204,6 @@ export class Authenticator {
client: this.options.clusterClient,
logger: this.options.loggers.get('tokens'),
}),
urls: {
loggedOut: options.config.authc.selector.enabled
? `${options.basePath.serverBasePath}/login?msg=LOGGED_OUT`
: `${options.basePath.serverBasePath}/security/logged_out`,
},
};
this.providers = new Map(
@ -218,6 +218,7 @@ export class Authenticator {
...providerCommonOptions,
name,
logger: options.loggers.get(type, name),
urls: { loggedOut: (request) => this.getLoggedOutURL(request, type) },
}),
this.options.config.authc.providers[type]?.[name]
),
@ -232,6 +233,9 @@ export class Authenticator {
...providerCommonOptions,
name: '__http__',
logger: options.loggers.get(HTTPAuthenticationProvider.type),
urls: {
loggedOut: (request) => this.getLoggedOutURL(request, HTTPAuthenticationProvider.type),
},
})
);
}
@ -338,7 +342,9 @@ export class Authenticator {
if (this.shouldRedirectToLoginSelector(request, existingSessionValue)) {
this.logger.debug('Redirecting request to Login Selector.');
return AuthenticationResult.redirectTo(
`${this.options.basePath.serverBasePath}/login?next=${encodeURIComponent(
`${
this.options.basePath.serverBasePath
}/login?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent(
`${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`
)}${
suggestedProviderName && !existingSessionValue
@ -385,20 +391,17 @@ export class Authenticator {
assertRequest(request);
const sessionValue = await this.getSessionValue(request);
if (sessionValue) {
const suggestedProviderName =
sessionValue?.provider.name ??
request.url.searchParams.get(LOGOUT_PROVIDER_QUERY_STRING_PARAMETER);
if (suggestedProviderName) {
await this.session.clear(request);
return this.providers
.get(sessionValue.provider.name)!
.logout(request, sessionValue.state ?? null);
}
const queryStringProviderName = (request.query as Record<string, string>)?.provider;
if (queryStringProviderName) {
// provider name is passed in a query param and sourced from the browser's local storage;
// hence, we can't assume that this provider exists, so we have to check it
const provider = this.providers.get(queryStringProviderName);
// Provider name may be passed in a query param and sourced from the browser's local storage;
// hence, we can't assume that this provider exists, so we have to check it.
const provider = this.providers.get(suggestedProviderName);
if (provider) {
return provider.logout(request, null);
return provider.logout(request, sessionValue?.state ?? null);
}
} else {
// In case logout is called and we cannot figure out what provider is supposed to handle it,
@ -737,7 +740,7 @@ export class Authenticator {
// redirect URL in the `next` parameter. Redirect URL provided in authentication result, if any,
// always takes precedence over what is specified in `redirectURL` parameter.
if (preAccessRedirectURL) {
preAccessRedirectURL = `${preAccessRedirectURL}?next=${encodeURIComponent(
preAccessRedirectURL = `${preAccessRedirectURL}?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent(
authenticationResult.redirectURL ||
redirectURL ||
`${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`
@ -754,4 +757,30 @@ export class Authenticator {
})
: authenticationResult;
}
/**
* 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.
*/
private getLoggedOutURL(request: KibanaRequest, providerType: string) {
// 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();
for (const [key, defaultValue] of [
[NEXT_URL_QUERY_STRING_PARAMETER, null],
[LOGOUT_REASON_QUERY_STRING_PARAMETER, 'LOGGED_OUT'],
] as Array<[string, string | null]>) {
const value = request.url.searchParams.get(key) || defaultValue;
if (value) {
searchParams.append(key, value);
}
}
// Query string may contain the path where logout has been called or
// logout reason that login page may need to know.
return this.options.config.authc.selector.enabled || shouldProviderUseLoginForm(providerType)
? `${this.options.basePath.serverBasePath}/login?${searchParams.toString()}`
: `${this.options.basePath.serverBasePath}/security/logged_out?${searchParams.toString()}`;
}
}

View file

@ -162,7 +162,7 @@ export class AnonymousAuthenticationProvider extends BaseAuthenticationProvider
return DeauthenticationResult.notHandled();
}
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut);
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut(request));
}
/**

View file

@ -22,7 +22,7 @@ export function mockAuthenticationProviderOptions(options?: { name: string }) {
tokens: { refresh: jest.fn(), invalidate: jest.fn() },
name: options?.name ?? 'basic1',
urls: {
loggedOut: '/mock-server-basepath/security/logged_out',
loggedOut: jest.fn().mockReturnValue('/mock-server-basepath/security/logged_out'),
},
};
}

View file

@ -29,7 +29,7 @@ export interface AuthenticationProviderOptions {
logger: Logger;
tokens: PublicMethodsOf<Tokens>;
urls: {
loggedOut: string;
loggedOut: (request: KibanaRequest) => string;
};
}

View file

@ -34,6 +34,8 @@ describe('BasicAuthenticationProvider', () => {
let mockOptions: ReturnType<typeof mockAuthenticationProviderOptions>;
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions();
mockOptions.urls.loggedOut.mockReturnValue('/some-logged-out-page');
provider = new BasicAuthenticationProvider(mockOptions);
});
@ -184,30 +186,13 @@ describe('BasicAuthenticationProvider', () => {
);
});
it('redirects to login view if state is `null`.', async () => {
await expect(provider.logout(httpServerMock.createKibanaRequest(), null)).resolves.toEqual(
DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT')
);
});
it('always redirects to the login page.', async () => {
it('redirects to the logged out URL.', async () => {
await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual(
DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT')
DeauthenticationResult.redirectTo('/some-logged-out-page')
);
});
it('passes query string parameters to the login page.', async () => {
await expect(
provider.logout(
httpServerMock.createKibanaRequest({
query: { next: '/app/ml', msg: 'SESSION_EXPIRED' },
}),
{}
)
).resolves.toEqual(
DeauthenticationResult.redirectTo(
'/mock-server-basepath/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED'
)
await expect(provider.logout(httpServerMock.createKibanaRequest(), null)).resolves.toEqual(
DeauthenticationResult.redirectTo('/some-logged-out-page')
);
});
});

View file

@ -5,6 +5,7 @@
*/
import { KibanaRequest } from '../../../../../../src/core/server';
import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants';
import { canRedirectRequest } from '../can_redirect_request';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
@ -108,7 +109,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug('Redirecting request to Login page.');
const basePath = this.options.basePath.get(request);
return AuthenticationResult.redirectTo(
`${basePath}/login?next=${encodeURIComponent(
`${basePath}/login?${NEXT_URL_QUERY_STRING_PARAMETER}=${encodeURIComponent(
`${basePath}${request.url.pathname}${request.url.search}`
)}`
);
@ -131,12 +132,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider {
return DeauthenticationResult.notHandled();
}
// Query string may contain the path where logout has been called or
// logout reason that login page may need to know.
const queryString = request.url.search || `?msg=LOGGED_OUT`;
return DeauthenticationResult.redirectTo(
`${this.options.basePath.get(request)}/login${queryString}`
);
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut(request));
}
/**

View file

@ -470,7 +470,7 @@ describe('KerberosAuthenticationProvider', () => {
const request = httpServerMock.createKibanaRequest();
await expect(provider.logout(request, null)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled();
@ -501,7 +501,7 @@ describe('KerberosAuthenticationProvider', () => {
mockOptions.tokens.invalidate.mockResolvedValue(undefined);
await expect(provider.logout(request, tokenPair)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);

View file

@ -124,7 +124,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider {
}
}
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut);
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut(request));
}
/**

View file

@ -611,10 +611,10 @@ describe('OIDCAuthenticationProvider', () => {
const request = httpServerMock.createKibanaRequest();
await expect(provider.logout(request, null)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
await expect(provider.logout(request, { nonce: 'x', realm: 'oidc1' })).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
@ -647,7 +647,7 @@ describe('OIDCAuthenticationProvider', () => {
await expect(
provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' })
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut));
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)));
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', {

View file

@ -7,6 +7,7 @@
import Boom from '@hapi/boom';
import type from 'type-detect';
import { KibanaRequest } from '../../../../../../src/core/server';
import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants';
import type { AuthenticationInfo } from '../../elasticsearch';
import { AuthenticationResult } from '../authentication_result';
import { canRedirectRequest } from '../can_redirect_request';
@ -434,7 +435,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
}
}
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut);
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut(request));
}
/**
@ -450,14 +451,18 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* @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],
]);
return AuthenticationResult.redirectTo(
`${
this.options.basePath.serverBasePath
}/internal/security/capture-url?next=${encodeURIComponent(
`${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`
)}&providerType=${encodeURIComponent(this.type)}&providerName=${encodeURIComponent(
this.options.name
)}`,
}/internal/security/capture-url?${searchParams.toString()}`,
// 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

@ -544,7 +544,7 @@ describe('PKIAuthenticationProvider', () => {
const request = httpServerMock.createKibanaRequest();
await expect(provider.logout(request, null)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled();
@ -572,7 +572,7 @@ describe('PKIAuthenticationProvider', () => {
mockOptions.tokens.invalidate.mockResolvedValue(undefined);
await expect(provider.logout(request, state)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);

View file

@ -128,7 +128,7 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider {
}
}
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut);
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut(request));
}
/**

View file

@ -1022,10 +1022,10 @@ describe('SAMLAuthenticationProvider', () => {
const request = httpServerMock.createKibanaRequest();
await expect(provider.logout(request, null)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
await expect(provider.logout(request, { somethingElse: 'x' } as any)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
@ -1082,7 +1082,7 @@ describe('SAMLAuthenticationProvider', () => {
refreshToken,
realm: 'test-realm',
})
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut));
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)));
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', {
@ -1103,7 +1103,7 @@ describe('SAMLAuthenticationProvider', () => {
refreshToken,
realm: 'test-realm',
})
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut));
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)));
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', {
@ -1126,7 +1126,7 @@ describe('SAMLAuthenticationProvider', () => {
refreshToken,
realm: 'test-realm',
})
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut));
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)));
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlLogout', {
@ -1145,7 +1145,7 @@ describe('SAMLAuthenticationProvider', () => {
refreshToken: 'x-saml-refresh-token',
realm: 'test-realm',
})
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut));
).resolves.toEqual(DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request)));
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlInvalidate', {
@ -1159,7 +1159,7 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null });
await expect(provider.logout(request)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
@ -1174,7 +1174,7 @@ describe('SAMLAuthenticationProvider', () => {
mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined });
await expect(provider.logout(request)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
@ -1187,7 +1187,7 @@ describe('SAMLAuthenticationProvider', () => {
const request = httpServerMock.createKibanaRequest({ query: { SAMLResponse: 'xxx yyy' } });
await expect(provider.logout(request)).resolves.toEqual(
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut)
DeauthenticationResult.redirectTo(mockOptions.urls.loggedOut(request))
);
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();

View file

@ -7,6 +7,7 @@
import Boom from '@hapi/boom';
import { KibanaRequest } from '../../../../../../src/core/server';
import { isInternalURL } from '../../../common/is_internal_url';
import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants';
import type { AuthenticationInfo } from '../../elasticsearch';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
@ -282,7 +283,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
}
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut);
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut(request));
}
/**
@ -606,14 +607,18 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* @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],
]);
return AuthenticationResult.redirectTo(
`${
this.options.basePath.serverBasePath
}/internal/security/capture-url?next=${encodeURIComponent(
`${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`
)}&providerType=${encodeURIComponent(this.type)}&providerName=${encodeURIComponent(
this.options.name
)}`,
}/internal/security/capture-url?${searchParams.toString()}`,
// 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

@ -37,6 +37,8 @@ describe('TokenAuthenticationProvider', () => {
let mockOptions: MockAuthenticationProviderOptions;
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions({ name: 'token' });
mockOptions.urls.loggedOut.mockReturnValue('/some-logged-out-page');
provider = new TokenAuthenticationProvider(mockOptions);
});
@ -347,11 +349,9 @@ describe('TokenAuthenticationProvider', () => {
expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled();
});
it('redirects to login view if state is `null`.', async () => {
const request = httpServerMock.createKibanaRequest();
await expect(provider.logout(request, null)).resolves.toEqual(
DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT')
it('redirects to the logged out URL if state is `null`.', async () => {
await expect(provider.logout(httpServerMock.createKibanaRequest(), null)).resolves.toEqual(
DeauthenticationResult.redirectTo('/some-logged-out-page')
);
expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled();
@ -372,28 +372,14 @@ describe('TokenAuthenticationProvider', () => {
expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair);
});
it('redirects to /login if tokens are invalidated successfully', async () => {
it('redirects to the logged out URL if tokens are invalidated successfully.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
mockOptions.tokens.invalidate.mockResolvedValue(undefined);
await expect(provider.logout(request, tokenPair)).resolves.toEqual(
DeauthenticationResult.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT')
);
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith(tokenPair);
});
it('redirects to /login with optional search parameters if tokens are invalidated successfully', async () => {
const request = httpServerMock.createKibanaRequest({ query: { yep: 'nope' } });
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
mockOptions.tokens.invalidate.mockResolvedValue(undefined);
await expect(provider.logout(request, tokenPair)).resolves.toEqual(
DeauthenticationResult.redirectTo('/mock-server-basepath/login?yep=nope')
DeauthenticationResult.redirectTo('/some-logged-out-page')
);
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);

View file

@ -6,6 +6,7 @@
import Boom from '@hapi/boom';
import { KibanaRequest } from '../../../../../../src/core/server';
import { NEXT_URL_QUERY_STRING_PARAMETER } from '../../../common/constants';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { canRedirectRequest } from '../can_redirect_request';
@ -145,10 +146,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider {
}
}
const queryString = request.url.search || `?msg=LOGGED_OUT`;
return DeauthenticationResult.redirectTo(
`${this.options.basePath.get(request)}/login${queryString}`
);
return DeauthenticationResult.redirectTo(this.options.urls.loggedOut(request));
}
/**
@ -235,6 +233,8 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider {
const nextURL = encodeURIComponent(
`${this.options.basePath.get(request)}${request.url.pathname}${request.url.search}`
);
return `${this.options.basePath.get(request)}/login?next=${nextURL}`;
return `${this.options.basePath.get(
request
)}/login?${NEXT_URL_QUERY_STRING_PARAMETER}=${nextURL}`;
}
}

View file

@ -9,7 +9,7 @@ import type { Duration } from 'moment';
import { schema, Type, TypeOf } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { Logger, config as coreConfig } from '../../../../src/core/server';
import type { AuthenticationProvider } from '../common/types';
import type { AuthenticationProvider } from '../common/model';
export type ConfigType = ReturnType<typeof createConfig>;
type RawConfigType = TypeOf<typeof ConfigSchema>;

View file

@ -14,7 +14,7 @@ import {
RequestHandlerContext,
} from '../../../../../../src/core/server';
import { SecurityLicense, SecurityLicenseFeatures } from '../../../common/licensing';
import { AuthenticationProvider } from '../../../common/types';
import type { AuthenticationProvider } from '../../../common/model';
import { ConfigType } from '../../config';
import { Session } from '../../session_management';
import { defineAccessAgreementRoutes } from './access_agreement';

View file

@ -7,6 +7,11 @@
import { schema } from '@kbn/config-schema';
import { parseNext } from '../../../common/parse_next';
import { LoginState } from '../../../common/login_state';
import { shouldProviderUseLoginForm } from '../../../common/model';
import {
LOGOUT_REASON_QUERY_STRING_PARAMETER,
NEXT_URL_QUERY_STRING_PARAMETER,
} from '../../../common/constants';
import { RouteDefinitionParams } from '..';
/**
@ -26,8 +31,8 @@ export function defineLoginRoutes({
validate: {
query: schema.object(
{
next: schema.maybe(schema.string()),
msg: schema.maybe(schema.string()),
[NEXT_URL_QUERY_STRING_PARAMETER]: schema.maybe(schema.string()),
[LOGOUT_REASON_QUERY_STRING_PARAMETER]: schema.maybe(schema.string()),
},
{ unknowns: 'allow' }
),
@ -59,7 +64,7 @@ export function defineLoginRoutes({
// Since `config.authc.sortedProviders` is based on `config.authc.providers` config we can
// be sure that config is present for every provider in `config.authc.sortedProviders`.
const { showInSelector, description, hint, icon } = config.authc.providers[type]?.[name]!;
const usesLoginForm = type === 'basic' || type === 'token';
const usesLoginForm = shouldProviderUseLoginForm(type);
return {
type,
name,

View file

@ -9,7 +9,7 @@ import { randomBytes, createHash } from 'crypto';
import nodeCrypto, { Crypto } from '@elastic/node-crypto';
import type { PublicMethodsOf } from '@kbn/utility-types';
import type { KibanaRequest, Logger } from '../../../../../src/core/server';
import type { AuthenticationProvider } from '../../common/types';
import type { AuthenticationProvider } from '../../common/model';
import type { ConfigType } from '../config';
import type { SessionIndex, SessionIndexValue } from './session_index';
import type { SessionCookie } from './session_cookie';

View file

@ -5,7 +5,7 @@
*/
import type { ILegacyClusterClient, Logger } from '../../../../../src/core/server';
import type { AuthenticationProvider } from '../../common/types';
import type { AuthenticationProvider } from '../../common/model';
import type { ConfigType } from '../config';
export interface SessionIndexOptions {

View file

@ -182,7 +182,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(cookies).to.have.length(1);
checkCookieIsCleared(request.cookie(cookies[0])!);
expect(logoutResponse.headers.location).to.be('/security/logged_out');
expect(logoutResponse.headers.location).to.be('/security/logged_out?msg=LOGGED_OUT');
// Old cookie should be invalidated and not allow API access.
const apiResponse = await supertest

View file

@ -259,7 +259,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(cookies).to.have.length(1);
checkCookieIsCleared(request.cookie(cookies[0])!);
expect(logoutResponse.headers.location).to.be('/security/logged_out');
expect(logoutResponse.headers.location).to.be('/security/logged_out?msg=LOGGED_OUT');
// Token that was stored in the previous cookie should be invalidated as well and old
// session cookie should not allow API access.

View file

@ -10,7 +10,7 @@ import { resolve } from 'path';
import url from 'url';
import { CA_CERT_PATH } from '@kbn/dev-utils';
import expect from '@kbn/expect';
import type { AuthenticationProvider } from '../../../../plugins/security/common/types';
import type { AuthenticationProvider } from '../../../../plugins/security/common/model';
import { getStateAndNonce } from '../../fixtures/oidc/oidc_tools';
import {
getMutualAuthenticationResponseToken,

View file

@ -307,7 +307,7 @@ export default function ({ getService }: FtrProviderContext) {
expect(cookies).to.have.length(1);
checkCookieIsCleared(request.cookie(cookies[0])!);
expect(logoutResponse.headers.location).to.be('/security/logged_out');
expect(logoutResponse.headers.location).to.be('/security/logged_out?msg=LOGGED_OUT');
});
it('should redirect to home page if session cookie is not provided', async () => {

View file

@ -7,7 +7,7 @@
import request, { Cookie } from 'request';
import { delay } from 'bluebird';
import expect from '@kbn/expect';
import type { AuthenticationProvider } from '../../../../plugins/security/common/types';
import type { AuthenticationProvider } from '../../../../plugins/security/common/model';
import { getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml/saml_tools';
import { FtrProviderContext } from '../../ftr_provider_context';

View file

@ -7,7 +7,7 @@
import request, { Cookie } from 'request';
import { delay } from 'bluebird';
import expect from '@kbn/expect';
import type { AuthenticationProvider } from '../../../../plugins/security/common/types';
import type { AuthenticationProvider } from '../../../../plugins/security/common/model';
import { getSAMLRequestId, getSAMLResponse } from '../../fixtures/saml/saml_tools';
import { FtrProviderContext } from '../../ftr_provider_context';