mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Return session error message to client (#139811)
This commit is contained in:
parent
9c6c725147
commit
2e495074fd
34 changed files with 949 additions and 219 deletions
|
@ -260,6 +260,7 @@ enabled:
|
|||
- x-pack/test/security_functional/saml.config.ts
|
||||
- x-pack/test/security_functional/insecure_cluster_warning.config.ts
|
||||
- x-pack/test/security_functional/user_profiles.config.ts
|
||||
- x-pack/test/security_functional/expired_session.config.ts
|
||||
- x-pack/test/security_solution_endpoint_api_int/config.ts
|
||||
- x-pack/test/security_solution_endpoint/config.ts
|
||||
- x-pack/test/session_view/basic/config.ts
|
||||
|
|
|
@ -45,6 +45,13 @@ export const LOGOUT_PROVIDER_QUERY_STRING_PARAMETER = 'provider';
|
|||
export const LOGOUT_REASON_QUERY_STRING_PARAMETER = 'msg';
|
||||
export const NEXT_URL_QUERY_STRING_PARAMETER = 'next';
|
||||
|
||||
/**
|
||||
* If there's a problem validating the session supplied in an AJAX request (i.e. a non-redirectable request),
|
||||
* a 401 error is returned. A header with the name defined in `SESSION_ERROR_REASON_HEADER` is added to the
|
||||
* HTTP response with more details of the problem.
|
||||
*/
|
||||
export const SESSION_ERROR_REASON_HEADER = 'kbn-session-error-reason';
|
||||
|
||||
/**
|
||||
* Matches valid usernames and role names.
|
||||
*
|
||||
|
|
|
@ -11,6 +11,7 @@ import fetchMock from 'fetch-mock/es5/client';
|
|||
import { setup } from '@kbn/core-test-helpers-http-setup-browser';
|
||||
import { applicationServiceMock } from '@kbn/core/public/mocks';
|
||||
|
||||
import { SESSION_ERROR_REASON_HEADER } from '../../common/constants';
|
||||
import { SessionExpired } from './session_expired';
|
||||
import { UnauthorizedResponseHttpInterceptor } from './unauthorized_response_http_interceptor';
|
||||
|
||||
|
@ -37,29 +38,37 @@ afterEach(() => {
|
|||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it(`logs out 401 responses`, async () => {
|
||||
const http = setupHttp('/foo');
|
||||
const sessionExpired = new SessionExpired(application, `${http.basePath}/logout`, tenant);
|
||||
const logoutPromise = new Promise<void>((resolve) => {
|
||||
jest.spyOn(sessionExpired, 'logout').mockImplementation(() => resolve());
|
||||
for (const reason of ['AUTHENTICATION_ERROR', 'SESSION_EXPIRED']) {
|
||||
const headers =
|
||||
reason === 'SESSION_EXPIRED' ? { [SESSION_ERROR_REASON_HEADER]: reason } : undefined;
|
||||
|
||||
it(`logs out 401 responses (reason: ${reason})`, async () => {
|
||||
const http = setupHttp('/foo');
|
||||
const sessionExpired = new SessionExpired(application, `${http.basePath}/logout`, tenant);
|
||||
const logoutPromise = new Promise<void>((resolve) => {
|
||||
jest.spyOn(sessionExpired, 'logout').mockImplementation(() => resolve());
|
||||
});
|
||||
const interceptor = new UnauthorizedResponseHttpInterceptor(
|
||||
sessionExpired,
|
||||
http.anonymousPaths
|
||||
);
|
||||
http.intercept(interceptor);
|
||||
fetchMock.mock('*', { status: 401, headers });
|
||||
|
||||
let fetchResolved = false;
|
||||
let fetchRejected = false;
|
||||
http.fetch('/foo-api').then(
|
||||
() => (fetchResolved = true),
|
||||
() => (fetchRejected = true)
|
||||
);
|
||||
|
||||
await logoutPromise;
|
||||
await drainPromiseQueue();
|
||||
expect(fetchResolved).toBe(false);
|
||||
expect(fetchRejected).toBe(false);
|
||||
expect(sessionExpired.logout).toHaveBeenCalledWith(reason);
|
||||
});
|
||||
const interceptor = new UnauthorizedResponseHttpInterceptor(sessionExpired, http.anonymousPaths);
|
||||
http.intercept(interceptor);
|
||||
fetchMock.mock('*', 401);
|
||||
|
||||
let fetchResolved = false;
|
||||
let fetchRejected = false;
|
||||
http.fetch('/foo-api').then(
|
||||
() => (fetchResolved = true),
|
||||
() => (fetchRejected = true)
|
||||
);
|
||||
|
||||
await logoutPromise;
|
||||
await drainPromiseQueue();
|
||||
expect(fetchResolved).toBe(false);
|
||||
expect(fetchRejected).toBe(false);
|
||||
expect(sessionExpired.logout).toHaveBeenCalledWith('AUTHENTICATION_ERROR');
|
||||
});
|
||||
}
|
||||
|
||||
it(`ignores anonymous paths`, async () => {
|
||||
mockCurrentUrl('/foo/bar');
|
||||
|
|
|
@ -12,6 +12,7 @@ import type {
|
|||
IHttpInterceptController,
|
||||
} from '@kbn/core/public';
|
||||
|
||||
import { SESSION_ERROR_REASON_HEADER } from '../../common/constants';
|
||||
import { LogoutReason } from '../../common/types';
|
||||
import type { SessionExpired } from './session_expired';
|
||||
|
||||
|
@ -40,7 +41,12 @@ export class UnauthorizedResponseHttpInterceptor implements HttpInterceptor {
|
|||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
this.sessionExpired.logout(LogoutReason.AUTHENTICATION_ERROR);
|
||||
const reason = response.headers.get(SESSION_ERROR_REASON_HEADER);
|
||||
this.sessionExpired.logout(
|
||||
reason === LogoutReason.SESSION_EXPIRED
|
||||
? LogoutReason.SESSION_EXPIRED
|
||||
: LogoutReason.AUTHENTICATION_ERROR
|
||||
);
|
||||
controller.halt();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import type { SecurityLicenseFeatures } from '../../common';
|
|||
import {
|
||||
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER,
|
||||
AUTH_URL_HASH_QUERY_STRING_PARAMETER,
|
||||
SESSION_ERROR_REASON_HEADER,
|
||||
} from '../../common/constants';
|
||||
import { licenseMock } from '../../common/licensing/index.mock';
|
||||
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
|
||||
|
@ -33,7 +34,13 @@ import { auditLoggerMock, auditServiceMock } from '../audit/mocks';
|
|||
import { ConfigSchema, createConfig } from '../config';
|
||||
import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock';
|
||||
import { securityMock } from '../mocks';
|
||||
import type { SessionValue } from '../session_management';
|
||||
import {
|
||||
type SessionError,
|
||||
SessionExpiredError,
|
||||
SessionMissingError,
|
||||
SessionUnexpectedError,
|
||||
type SessionValue,
|
||||
} from '../session_management';
|
||||
import { sessionMock } from '../session_management/index.mock';
|
||||
import type { UserProfileGrant } from '../user_profile';
|
||||
import { userProfileServiceMock } from '../user_profile/user_profile_service.mock';
|
||||
|
@ -41,7 +48,11 @@ import { AuthenticationResult } from './authentication_result';
|
|||
import type { AuthenticatorOptions } from './authenticator';
|
||||
import { Authenticator, enrichWithUserProfileId } from './authenticator';
|
||||
import { DeauthenticationResult } from './deauthentication_result';
|
||||
import type { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers';
|
||||
import type {
|
||||
BasicAuthenticationProvider,
|
||||
HTTPAuthenticationProvider,
|
||||
SAMLAuthenticationProvider,
|
||||
} from './providers';
|
||||
|
||||
let auditLogger: AuditLogger;
|
||||
function getMockOptions({
|
||||
|
@ -107,8 +118,16 @@ function expectAuditEvents(...events: ExpectedAuditEvent[]) {
|
|||
}
|
||||
|
||||
describe('Authenticator', () => {
|
||||
let mockHTTPAuthenticationProvider: jest.Mocked<PublicMethodsOf<HTTPAuthenticationProvider>>;
|
||||
let mockBasicAuthenticationProvider: jest.Mocked<PublicMethodsOf<BasicAuthenticationProvider>>;
|
||||
beforeEach(() => {
|
||||
mockHTTPAuthenticationProvider = {
|
||||
login: jest.fn(),
|
||||
authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()),
|
||||
logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()),
|
||||
getHTTPAuthenticationScheme: jest.fn(),
|
||||
};
|
||||
|
||||
mockBasicAuthenticationProvider = {
|
||||
login: jest.fn(),
|
||||
authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()),
|
||||
|
@ -118,8 +137,7 @@ describe('Authenticator', () => {
|
|||
|
||||
jest.requireMock('./providers/http').HTTPAuthenticationProvider.mockImplementation(() => ({
|
||||
type: 'http',
|
||||
authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()),
|
||||
logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()),
|
||||
...mockHTTPAuthenticationProvider,
|
||||
}));
|
||||
|
||||
jest.requireMock('./providers/basic').BasicAuthenticationProvider.mockImplementation(() => ({
|
||||
|
@ -305,7 +323,6 @@ describe('Authenticator', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } });
|
||||
|
||||
authenticator = new Authenticator(mockOptions);
|
||||
|
@ -592,7 +609,6 @@ describe('Authenticator', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
|
||||
authenticator = new Authenticator(mockOptions);
|
||||
});
|
||||
|
@ -684,8 +700,11 @@ describe('Authenticator', () => {
|
|||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
...mockSessVal,
|
||||
provider: { type: 'saml', name: 'saml2' },
|
||||
error: null,
|
||||
value: {
|
||||
...mockSessVal,
|
||||
provider: { type: 'saml', name: 'saml2' },
|
||||
},
|
||||
});
|
||||
|
||||
const loginAttemptValue = Symbol('attempt');
|
||||
|
@ -733,7 +752,7 @@ describe('Authenticator', () => {
|
|||
authenticator = new Authenticator(mockOptions);
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user));
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(
|
||||
authenticator.login(request, { provider: { name: 'basic1' }, value: credentials })
|
||||
|
@ -752,7 +771,7 @@ describe('Authenticator', () => {
|
|||
it('clears session if provider asked to do so in `succeeded` result.', async () => {
|
||||
const user = mockAuthenticatedUser();
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user, { state: null })
|
||||
|
@ -772,7 +791,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('clears session if provider asked to do so in `redirected` result.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo('some-url', { state: null })
|
||||
|
@ -815,7 +834,6 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if authenticated session is not created', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser)
|
||||
|
@ -829,7 +847,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if request cannot be handled', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.notHandled());
|
||||
|
||||
|
@ -841,7 +859,6 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if authentication fails', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
|
||||
const failureReason = new Error('something went wrong');
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
|
@ -856,7 +873,6 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if redirect is required to complete login', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo('/some-url', { state: 'some-state' })
|
||||
|
@ -871,8 +887,11 @@ describe('Authenticator', () => {
|
|||
it('does not redirect to Access Agreement if user has already acknowledged it', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
...mockSessVal,
|
||||
accessAgreementAcknowledged: true,
|
||||
error: null,
|
||||
value: {
|
||||
...mockSessVal,
|
||||
accessAgreementAcknowledged: true,
|
||||
},
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
|
@ -887,7 +906,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement its own requests', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ path: '/security/access_agreement' });
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, { state: 'some-state' })
|
||||
|
@ -901,7 +920,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if it is not configured', async () => {
|
||||
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
authenticator = new Authenticator(mockOptions);
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
|
@ -917,7 +936,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if license doesnt allow it.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
mockOptions.license.getFeatures.mockReturnValue({
|
||||
allowAccessAgreement: false,
|
||||
} as SecurityLicenseFeatures);
|
||||
|
@ -933,7 +952,7 @@ describe('Authenticator', () => {
|
|||
});
|
||||
|
||||
it('redirects to Access Agreement when needed.', async () => {
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -959,7 +978,7 @@ describe('Authenticator', () => {
|
|||
});
|
||||
|
||||
it('redirects to Access Agreement preserving redirect URL specified in login attempt.', async () => {
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -989,7 +1008,7 @@ describe('Authenticator', () => {
|
|||
});
|
||||
|
||||
it('redirects to Access Agreement preserving redirect URL specified in the authentication result.', async () => {
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo('/some-url', {
|
||||
|
@ -1016,7 +1035,7 @@ describe('Authenticator', () => {
|
|||
});
|
||||
|
||||
it('redirects AJAX requests to Access Agreement when needed.', async () => {
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -1051,7 +1070,6 @@ describe('Authenticator', () => {
|
|||
saml: { saml1: { order: 1, realm: 'saml1' } },
|
||||
},
|
||||
});
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
mockOptions.session.update.mockImplementation(async (request, value) => value);
|
||||
mockOptions.session.extend.mockImplementation(async (request, value) => value);
|
||||
mockOptions.session.create.mockImplementation(async (request, value) => ({
|
||||
|
@ -1066,7 +1084,10 @@ describe('Authenticator', () => {
|
|||
const request = httpServerMock.createKibanaRequest({
|
||||
path: '/security/overwritten_session',
|
||||
});
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, { state: 'some-state' })
|
||||
|
@ -1083,7 +1104,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Overwritten Session if username and provider did not change', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -1105,7 +1126,10 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Overwritten Session if session was unauthenticated before login', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: undefined },
|
||||
});
|
||||
|
||||
const newMockUser = mockAuthenticatedUser({ username: 'new-username' });
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
|
@ -1131,7 +1155,10 @@ describe('Authenticator', () => {
|
|||
|
||||
it('redirects to Overwritten Session when username changes', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -1161,8 +1188,11 @@ describe('Authenticator', () => {
|
|||
it('redirects to Overwritten Session when provider changes', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
...mockSessVal,
|
||||
provider: { type: 'saml', name: 'saml1' },
|
||||
error: null,
|
||||
value: {
|
||||
...mockSessVal,
|
||||
provider: { type: 'saml', name: 'saml1' },
|
||||
},
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
|
@ -1192,7 +1222,10 @@ describe('Authenticator', () => {
|
|||
|
||||
it('redirects to Overwritten Session preserving redirect URL specified in login attempt.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -1225,7 +1258,10 @@ describe('Authenticator', () => {
|
|||
|
||||
it('redirects to Overwritten Session preserving redirect URL specified in the authentication result.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo('/some-url', {
|
||||
|
@ -1255,7 +1291,10 @@ describe('Authenticator', () => {
|
|||
|
||||
it('redirects AJAX requests to Overwritten Session when needed.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.login.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -1291,7 +1330,6 @@ describe('Authenticator', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } });
|
||||
|
||||
authenticator = new Authenticator(mockOptions);
|
||||
|
@ -1318,6 +1356,136 @@ describe('Authenticator', () => {
|
|||
expectAuditEvents({ action: 'user_login', outcome: 'failure' });
|
||||
});
|
||||
|
||||
for (const FailureClass of [SessionMissingError, SessionExpiredError, SessionUnexpectedError]) {
|
||||
describe(`session.get results in ${FailureClass.name}`, () => {
|
||||
it('fails as expected for redirectable requests', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
const failureReason = new FailureClass();
|
||||
|
||||
mockOptions.session.get.mockResolvedValue({ error: failureReason, value: null });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
failureReason instanceof SessionMissingError
|
||||
? AuthenticationResult.notHandled()
|
||||
: AuthenticationResult.failed(failureReason)
|
||||
);
|
||||
// TODO: Add check for expected audit log
|
||||
// expect(auditLogger.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails as expected for non-redirectable requests', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
|
||||
const failureReason = new FailureClass();
|
||||
|
||||
mockOptions.session.get.mockResolvedValue({ error: failureReason, value: null });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
failureReason instanceof SessionMissingError
|
||||
? AuthenticationResult.notHandled()
|
||||
: AuthenticationResult.failed(failureReason, {
|
||||
authResponseHeaders: {
|
||||
[SESSION_ERROR_REASON_HEADER]: (failureReason as SessionError).code,
|
||||
},
|
||||
})
|
||||
);
|
||||
// TODO: Add check for expected audit log
|
||||
// expect(auditLogger.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get expected reponse headers for non-redirectable requests where the authentication succeeds', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
|
||||
const failureReason = new FailureClass();
|
||||
|
||||
mockOptions.session.get.mockResolvedValue({ error: failureReason, value: null });
|
||||
|
||||
const user = mockAuthenticatedUser();
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user)
|
||||
);
|
||||
|
||||
const authenticationResult = await authenticator.authenticate(request);
|
||||
const expectedResult =
|
||||
failureReason instanceof SessionExpiredError
|
||||
? AuthenticationResult.succeeded(user, {
|
||||
authResponseHeaders: {
|
||||
[SESSION_ERROR_REASON_HEADER]: (failureReason as SessionError).code,
|
||||
},
|
||||
})
|
||||
: AuthenticationResult.succeeded(user);
|
||||
expect(authenticationResult).toEqual(expectedResult);
|
||||
// TODO: Add check for expected audit log
|
||||
// expect(auditLogger.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get expected reponse headers for non-redirectable requests where the authentication fails', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
|
||||
const failureReason = new FailureClass();
|
||||
|
||||
mockOptions.session.get.mockResolvedValue({ error: failureReason, value: null });
|
||||
|
||||
const authError = new Error('foo');
|
||||
mockHTTPAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.failed(authError)
|
||||
);
|
||||
|
||||
const authenticationResult = await authenticator.authenticate(request);
|
||||
const expectedResult =
|
||||
failureReason instanceof SessionExpiredError
|
||||
? AuthenticationResult.failed(authError, {
|
||||
authResponseHeaders: {
|
||||
[SESSION_ERROR_REASON_HEADER]: (failureReason as SessionError).code,
|
||||
},
|
||||
})
|
||||
: AuthenticationResult.failed(authError);
|
||||
expect(authenticationResult).toEqual(expectedResult);
|
||||
// TODO: Add check for expected audit log
|
||||
// expectAuditEvents({ action: 'user_login', outcome: 'failure' });
|
||||
});
|
||||
|
||||
it('expected message is attached to the URL when authentication provider redirects to login page', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
const redirectUrl = '/mock-server-basepath/login?foo=bar';
|
||||
const failureReason = new FailureClass();
|
||||
|
||||
mockOptions.session.get.mockResolvedValue({ error: failureReason, value: null });
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo(redirectUrl)
|
||||
);
|
||||
|
||||
const authenticationResult = await authenticator.authenticate(request);
|
||||
expect(authenticationResult.redirected()).toBe(true);
|
||||
if (failureReason instanceof SessionExpiredError) {
|
||||
expect(authenticationResult.redirectURL).toBe(
|
||||
redirectUrl + '&msg=' + failureReason.code
|
||||
);
|
||||
} else {
|
||||
expect(authenticationResult.redirectURL).toBe(redirectUrl);
|
||||
}
|
||||
// TODO: Add check for expected audit log
|
||||
// expect(auditLogger.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not get a message attached to the redirect URL when authentication provider redirects to something that is not the login page', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
const redirectUrl = '/mock-server-basepath/some-other-page?foo=bar';
|
||||
const failureReason = new FailureClass();
|
||||
|
||||
mockOptions.session.get.mockResolvedValue({ error: failureReason, value: null });
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo(redirectUrl)
|
||||
);
|
||||
|
||||
const authenticationResult = await authenticator.authenticate(request);
|
||||
expect(authenticationResult.redirected()).toBe(true);
|
||||
expect(authenticationResult.redirectURL).toBe(redirectUrl);
|
||||
// TODO: Add check for expected audit log
|
||||
// expect(auditLogger.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('returns user that authentication provider returns.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
headers: { authorization: 'Basic ***' },
|
||||
|
@ -1425,7 +1593,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user)
|
||||
|
@ -1447,7 +1615,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user)
|
||||
|
@ -1470,7 +1638,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
|
@ -1492,7 +1660,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
|
@ -1515,7 +1683,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user, { state: newState })
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, { state: newState })
|
||||
|
@ -1543,7 +1711,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user, { state: newState })
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, { state: newState })
|
||||
|
@ -1580,7 +1748,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user, { userProfileGrant, state: newState })
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, { userProfileGrant, state: newState })
|
||||
|
@ -1611,7 +1779,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
|
@ -1636,7 +1804,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
|
@ -1656,7 +1824,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo('some-url', { state: null })
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo('some-url', { state: null })
|
||||
|
@ -1675,7 +1843,7 @@ describe('Authenticator', () => {
|
|||
headers: { 'kbn-system-request': 'true' },
|
||||
});
|
||||
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.notHandled()
|
||||
|
@ -1693,7 +1861,7 @@ describe('Authenticator', () => {
|
|||
headers: { 'kbn-system-request': 'false' },
|
||||
});
|
||||
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.notHandled()
|
||||
|
@ -1718,7 +1886,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Login Selector if there is an active session', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.notHandled()
|
||||
|
@ -1770,7 +1938,10 @@ describe('Authenticator', () => {
|
|||
);
|
||||
|
||||
// Unauthenticated session should be treated as non-existent one.
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: undefined },
|
||||
});
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo(
|
||||
'/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath'
|
||||
|
@ -1793,7 +1964,10 @@ describe('Authenticator', () => {
|
|||
);
|
||||
|
||||
// Includes hint if session is unauthenticated.
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: undefined });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: undefined },
|
||||
});
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo(
|
||||
'/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath%3Fauth_provider_hint%3Dcustom1&auth_provider_hint=custom1'
|
||||
|
@ -1826,7 +2000,6 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if there is no active session', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(mockUser)
|
||||
|
@ -1836,7 +2009,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect AJAX requests to Access Agreement', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(mockUser)
|
||||
|
@ -1846,7 +2019,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if request cannot be handled', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.notHandled()
|
||||
|
@ -1860,7 +2033,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if authentication fails', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
const failureReason = new Error('something went wrong');
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
|
@ -1875,7 +2048,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if redirect is required to complete authentication', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo('/some-url')
|
||||
|
@ -1890,8 +2063,11 @@ describe('Authenticator', () => {
|
|||
it('does not redirect to Access Agreement if user has already acknowledged it', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
...mockSessVal,
|
||||
accessAgreementAcknowledged: true,
|
||||
error: null,
|
||||
value: {
|
||||
...mockSessVal,
|
||||
accessAgreementAcknowledged: true,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
|
@ -1902,7 +2078,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement its own requests', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ path: '/security/access_agreement' });
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.authenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(mockUser)
|
||||
|
@ -1912,7 +2088,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if it is not configured', async () => {
|
||||
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
authenticator = new Authenticator(mockOptions);
|
||||
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
@ -1924,7 +2100,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Access Agreement if license doesnt allow it.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
mockOptions.license.getFeatures.mockReturnValue({
|
||||
allowAccessAgreement: false,
|
||||
} as SecurityLicenseFeatures);
|
||||
|
@ -1936,7 +2112,7 @@ describe('Authenticator', () => {
|
|||
});
|
||||
|
||||
it('redirects to Access Agreement when needed.', async () => {
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
mockOptions.session.extend.mockResolvedValue(mockSessVal);
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
|
@ -1969,7 +2145,7 @@ describe('Authenticator', () => {
|
|||
|
||||
authenticator = new Authenticator(mockOptions);
|
||||
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
mockOptions.session.extend.mockResolvedValue(mockSessVal);
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
|
@ -2012,7 +2188,10 @@ describe('Authenticator', () => {
|
|||
const request = httpServerMock.createKibanaRequest({
|
||||
path: '/security/overwritten_session',
|
||||
});
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser)
|
||||
|
@ -2029,7 +2208,10 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect AJAX requests to Overwritten Session', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -2052,7 +2234,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not redirect to Overwritten Session if username and provider did not change', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -2072,7 +2254,10 @@ describe('Authenticator', () => {
|
|||
|
||||
it('redirects to Overwritten Session when username changes', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(mockUser, {
|
||||
|
@ -2100,8 +2285,11 @@ describe('Authenticator', () => {
|
|||
it('redirects to Overwritten Session when provider changes', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
...mockSessVal,
|
||||
provider: { type: 'saml', name: 'saml1' },
|
||||
error: null,
|
||||
value: {
|
||||
...mockSessVal,
|
||||
provider: { type: 'saml', name: 'saml1' },
|
||||
},
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
|
@ -2129,7 +2317,10 @@ describe('Authenticator', () => {
|
|||
|
||||
it('redirects to Overwritten Session preserving redirect URL specified in the authentication result.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue({ ...mockSessVal, username: 'old-username' });
|
||||
mockOptions.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: { ...mockSessVal, username: 'old-username' },
|
||||
});
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.redirectTo('/some-url', {
|
||||
|
@ -2164,7 +2355,6 @@ describe('Authenticator', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
mockSessVal = sessionMock.createValue({ state: { authorization: 'Basic xxx' } });
|
||||
|
||||
authenticator = new Authenticator(mockOptions);
|
||||
|
@ -2215,7 +2405,7 @@ describe('Authenticator', () => {
|
|||
|
||||
it('does not clear session if provider cannot handle authentication', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.notHandled()
|
||||
|
@ -2244,7 +2434,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.reauthenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
|
@ -2264,7 +2454,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.reauthenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user)
|
||||
|
@ -2286,7 +2476,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.succeeded(user, { state: newState })
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.reauthenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.succeeded(user, { state: newState })
|
||||
|
@ -2312,7 +2502,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.reauthenticate(request)).resolves.toEqual(
|
||||
AuthenticationResult.failed(failureReason)
|
||||
|
@ -2348,7 +2538,6 @@ describe('Authenticator', () => {
|
|||
|
||||
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(
|
||||
|
@ -2364,7 +2553,7 @@ describe('Authenticator', () => {
|
|||
mockBasicAuthenticationProvider.logout.mockResolvedValue(
|
||||
DeauthenticationResult.redirectTo('some-url')
|
||||
);
|
||||
mockOptions.session.get.mockResolvedValue(mockSessVal);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessVal });
|
||||
|
||||
await expect(authenticator.logout(request)).resolves.toEqual(
|
||||
DeauthenticationResult.redirectTo('some-url')
|
||||
|
@ -2379,7 +2568,6 @@ describe('Authenticator', () => {
|
|||
const request = httpServerMock.createKibanaRequest({
|
||||
query: { provider: 'basic1' },
|
||||
});
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
|
||||
mockBasicAuthenticationProvider.logout.mockResolvedValue(
|
||||
DeauthenticationResult.redirectTo('some-url')
|
||||
|
@ -2397,7 +2585,6 @@ describe('Authenticator', () => {
|
|||
|
||||
it('if session does not exist and provider name is not available, returns whatever authentication provider returns.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
|
||||
mockBasicAuthenticationProvider.logout.mockResolvedValue(
|
||||
DeauthenticationResult.redirectTo('some-url')
|
||||
|
@ -2434,7 +2621,6 @@ describe('Authenticator', () => {
|
|||
|
||||
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.redirectTo('/mock-server-basepath/login?msg=LOGGED_OUT')
|
||||
|
@ -2454,7 +2640,7 @@ describe('Authenticator', () => {
|
|||
beforeEach(() => {
|
||||
mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } });
|
||||
mockSessionValue = sessionMock.createValue({ state: { authorization: 'Basic xxx' } });
|
||||
mockOptions.session.get.mockResolvedValue(mockSessionValue);
|
||||
mockOptions.session.get.mockResolvedValue({ error: null, value: mockSessionValue });
|
||||
mockOptions.getCurrentUser.mockReturnValue(mockAuthenticatedUser());
|
||||
mockOptions.license.getFeatures.mockReturnValue({
|
||||
allowAccessAgreement: true,
|
||||
|
@ -2478,7 +2664,7 @@ describe('Authenticator', () => {
|
|||
});
|
||||
|
||||
it('fails if cannot retrieve user session', async () => {
|
||||
mockOptions.session.get.mockResolvedValue(null);
|
||||
mockOptions.session.get.mockResolvedValue({ error: new SessionMissingError(), value: null });
|
||||
|
||||
await expect(
|
||||
authenticator.acknowledgeAccessAgreement(httpServerMock.createKibanaRequest())
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
LOGOUT_PROVIDER_QUERY_STRING_PARAMETER,
|
||||
LOGOUT_REASON_QUERY_STRING_PARAMETER,
|
||||
NEXT_URL_QUERY_STRING_PARAMETER,
|
||||
SESSION_ERROR_REASON_HEADER,
|
||||
} from '../../common/constants';
|
||||
import { shouldProviderUseLoginForm } from '../../common/model';
|
||||
import type { AuditServiceSetup } from '../audit';
|
||||
|
@ -24,7 +25,12 @@ import { accessAgreementAcknowledgedEvent, userLoginEvent, userLogoutEvent } fro
|
|||
import type { ConfigType } from '../config';
|
||||
import { getErrorStatusCode } from '../errors';
|
||||
import type { SecurityFeatureUsageServiceStart } from '../feature_usage';
|
||||
import type { Session, SessionValue } from '../session_management';
|
||||
import {
|
||||
type Session,
|
||||
SessionExpiredError,
|
||||
SessionUnexpectedError,
|
||||
type SessionValue,
|
||||
} from '../session_management';
|
||||
import type { UserProfileServiceStartInternal } from '../user_profile';
|
||||
import { AuthenticationResult } from './authentication_result';
|
||||
import { canRedirectRequest } from './can_redirect_request';
|
||||
|
@ -290,7 +296,7 @@ export class Authenticator {
|
|||
assertRequest(request);
|
||||
assertLoginAttempt(attempt);
|
||||
|
||||
const existingSessionValue = await this.getSessionValue(request);
|
||||
const { value: existingSessionValue } = await this.getSessionValue(request);
|
||||
|
||||
// Login attempt can target specific provider by its name (e.g. chosen at the Login Selector UI)
|
||||
// or a group of providers with the specified type (e.g. in case of 3rd-party initiated login
|
||||
|
@ -357,8 +363,9 @@ export class Authenticator {
|
|||
async authenticate(request: KibanaRequest): Promise<AuthenticationResult> {
|
||||
assertRequest(request);
|
||||
|
||||
const existingSessionValue = await this.getSessionValue(request);
|
||||
if (this.shouldRedirectToLoginSelector(request, existingSessionValue)) {
|
||||
const existingSession = await this.getSessionValue(request);
|
||||
|
||||
if (this.shouldRedirectToLoginSelector(request, existingSession.value)) {
|
||||
const providerNameSuggestedByHint = request.url.searchParams.get(
|
||||
AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER
|
||||
);
|
||||
|
@ -378,40 +385,114 @@ export class Authenticator {
|
|||
providerNameSuggestedByHint
|
||||
)}`
|
||||
: ''
|
||||
}${
|
||||
existingSession.error instanceof SessionExpiredError
|
||||
? `&${LOGOUT_REASON_QUERY_STRING_PARAMETER}=${encodeURIComponent(
|
||||
existingSession.error.code
|
||||
)}`
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
const requestIsRedirectable = canRedirectRequest(request);
|
||||
|
||||
const suggestedProviderName =
|
||||
existingSessionValue?.provider.name ??
|
||||
existingSession.value?.provider.name ??
|
||||
request.url.searchParams.get(AUTH_PROVIDER_HINT_QUERY_STRING_PARAMETER);
|
||||
for (const [providerName, provider] of this.providerIterator(suggestedProviderName)) {
|
||||
// Check if current session has been set by this provider.
|
||||
const ownsSession =
|
||||
existingSessionValue?.provider.name === providerName &&
|
||||
existingSessionValue?.provider.type === provider.type;
|
||||
existingSession.value?.provider.name === providerName &&
|
||||
existingSession.value?.provider.type === provider.type;
|
||||
|
||||
const authenticationResult = await provider.authenticate(
|
||||
let authenticationResult = await provider.authenticate(
|
||||
request,
|
||||
ownsSession ? existingSessionValue!.state : null
|
||||
ownsSession ? existingSession.value!.state : null
|
||||
);
|
||||
|
||||
if (!authenticationResult.notHandled()) {
|
||||
const sessionUpdateResult = await this.updateSessionValue(request, {
|
||||
provider: { type: provider.type, name: providerName },
|
||||
authenticationResult,
|
||||
existingSessionValue,
|
||||
existingSessionValue: existingSession.value,
|
||||
});
|
||||
return enrichWithUserProfileId(
|
||||
canRedirectRequest(request)
|
||||
? this.handlePreAccessRedirects(request, authenticationResult, sessionUpdateResult)
|
||||
: authenticationResult,
|
||||
sessionUpdateResult ? sessionUpdateResult.value : null
|
||||
);
|
||||
|
||||
if (requestIsRedirectable) {
|
||||
if (
|
||||
existingSession.error instanceof SessionExpiredError &&
|
||||
authenticationResult.redirectURL?.startsWith(
|
||||
`${this.options.basePath.get(request)}/login?`
|
||||
)
|
||||
) {
|
||||
// TODO: Make this less verbose!
|
||||
authenticationResult = AuthenticationResult.redirectTo(
|
||||
authenticationResult.redirectURL +
|
||||
`&${LOGOUT_REASON_QUERY_STRING_PARAMETER}=${encodeURIComponent(
|
||||
existingSession.error.code
|
||||
)}`,
|
||||
{
|
||||
user: authenticationResult.user,
|
||||
userProfileGrant: authenticationResult.userProfileGrant,
|
||||
authResponseHeaders: authenticationResult.authResponseHeaders,
|
||||
state: authenticationResult.state,
|
||||
}
|
||||
);
|
||||
}
|
||||
return enrichWithUserProfileId(
|
||||
this.handlePreAccessRedirects(request, authenticationResult, sessionUpdateResult),
|
||||
sessionUpdateResult ? sessionUpdateResult.value : null
|
||||
);
|
||||
} else {
|
||||
if (existingSession.error instanceof SessionExpiredError) {
|
||||
// TODO: Make this less verbose! Possible alternatives:
|
||||
// 1. Make authResponseHeaders editable
|
||||
// 2. Create utility function outside of the AuthenticationResult class to create clones of AuthenticationResult objects with certain properties augmented
|
||||
// 3. Create utility function inside of the AuthenticationResult class to create clones of AuthenticationResult objects with certain properties augmented
|
||||
// Whatever we choose, we probably want to consider doing the same for editing the `redirectURL` and the `user`, both of which we need to edit in this file
|
||||
if (authenticationResult.succeeded()) {
|
||||
authenticationResult = AuthenticationResult.succeeded(authenticationResult.user!, {
|
||||
userProfileGrant: authenticationResult.userProfileGrant,
|
||||
authHeaders: authenticationResult.authHeaders,
|
||||
state: authenticationResult.state,
|
||||
authResponseHeaders: {
|
||||
...authenticationResult.authResponseHeaders,
|
||||
[SESSION_ERROR_REASON_HEADER]: existingSession.error.code,
|
||||
},
|
||||
});
|
||||
} else if (authenticationResult.failed()) {
|
||||
authenticationResult = AuthenticationResult.failed(authenticationResult.error!, {
|
||||
authResponseHeaders: {
|
||||
...authenticationResult.authResponseHeaders,
|
||||
[SESSION_ERROR_REASON_HEADER]: existingSession.error.code,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Currently we can only get to here if the request is 1) not redirectable, and 2) handled. This leaves only the states `succeeded` and `failed` that we have to handle
|
||||
throw new Error(`Unexpected state: ${(authenticationResult as any).status}`);
|
||||
}
|
||||
}
|
||||
return enrichWithUserProfileId(
|
||||
authenticationResult,
|
||||
sessionUpdateResult ? sessionUpdateResult.value : null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthenticationResult.notHandled();
|
||||
if (
|
||||
existingSession.error instanceof SessionExpiredError ||
|
||||
existingSession.error instanceof SessionUnexpectedError
|
||||
) {
|
||||
const options = requestIsRedirectable
|
||||
? undefined
|
||||
: {
|
||||
authResponseHeaders: { [SESSION_ERROR_REASON_HEADER]: existingSession.error.code },
|
||||
};
|
||||
return AuthenticationResult.failed(existingSession.error, options);
|
||||
} else {
|
||||
return AuthenticationResult.notHandled();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -421,7 +502,7 @@ export class Authenticator {
|
|||
async reauthenticate(request: KibanaRequest) {
|
||||
assertRequest(request);
|
||||
|
||||
const existingSessionValue = await this.getSessionValue(request);
|
||||
const { value: existingSessionValue } = await this.getSessionValue(request);
|
||||
if (!existingSessionValue) {
|
||||
this.logger.warn('Session is no longer available and cannot be re-authenticated.');
|
||||
return AuthenticationResult.notHandled();
|
||||
|
@ -453,7 +534,7 @@ export class Authenticator {
|
|||
async logout(request: KibanaRequest) {
|
||||
assertRequest(request);
|
||||
|
||||
const sessionValue = await this.getSessionValue(request);
|
||||
const { value: sessionValue } = await this.getSessionValue(request);
|
||||
const suggestedProviderName =
|
||||
sessionValue?.provider.name ??
|
||||
request.url.searchParams.get(LOGOUT_PROVIDER_QUERY_STRING_PARAMETER);
|
||||
|
@ -493,8 +574,9 @@ export class Authenticator {
|
|||
async acknowledgeAccessAgreement(request: KibanaRequest) {
|
||||
assertRequest(request);
|
||||
|
||||
const existingSessionValue = await this.getSessionValue(request);
|
||||
const { value: existingSessionValue } = await this.getSessionValue(request);
|
||||
const currentUser = this.options.getCurrentUser(request);
|
||||
|
||||
if (!existingSessionValue || !currentUser) {
|
||||
throw new Error('Cannot acknowledge access agreement for unauthenticated user.');
|
||||
}
|
||||
|
@ -594,24 +676,24 @@ export class Authenticator {
|
|||
* @param request Request instance.
|
||||
*/
|
||||
private async getSessionValue(request: KibanaRequest) {
|
||||
const existingSessionValue = await this.session.get(request);
|
||||
const existingSession = await this.session.get(request);
|
||||
|
||||
// If we detect that for some reason we have a session stored for the provider that is not
|
||||
// available anymore (e.g. when user was logged in with one provider, but then configuration has
|
||||
// changed and that provider is no longer available), then we should clear session entirely.
|
||||
if (
|
||||
existingSessionValue &&
|
||||
this.providers.get(existingSessionValue.provider.name)?.type !==
|
||||
existingSessionValue.provider.type
|
||||
existingSession.value &&
|
||||
this.providers.get(existingSession.value.provider.name)?.type !==
|
||||
existingSession.value.provider.type
|
||||
) {
|
||||
this.logger.warn(
|
||||
`Attempted to retrieve session for the "${existingSessionValue.provider.type}/${existingSessionValue.provider.name}" provider, but it is not configured.`
|
||||
`Attempted to retrieve session for the "${existingSession.value.provider.type}/${existingSession.value.provider.name}" provider, but it is not configured.`
|
||||
);
|
||||
await this.invalidateSessionValue({ request, sessionValue: existingSessionValue });
|
||||
return null;
|
||||
await this.invalidateSessionValue({ request, sessionValue: existingSession.value });
|
||||
return { error: new SessionUnexpectedError(), value: null };
|
||||
}
|
||||
|
||||
return existingSessionValue;
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -916,6 +998,7 @@ export class Authenticator {
|
|||
state: authenticationResult.state,
|
||||
user: authenticationResult.user,
|
||||
authResponseHeaders: authenticationResult.authResponseHeaders,
|
||||
userProfileGrant: authenticationResult.userProfileGrant,
|
||||
})
|
||||
: authenticationResult;
|
||||
}
|
||||
|
|
|
@ -128,7 +128,7 @@ describe('Info session routes', () => {
|
|||
];
|
||||
|
||||
for (const [sessionInfo, expected] of assertions) {
|
||||
session.get.mockResolvedValue(sessionMock.createValue(sessionInfo));
|
||||
session.get.mockResolvedValue({ error: null, value: sessionMock.createValue(sessionInfo) });
|
||||
|
||||
const expectedBody = {
|
||||
canBeExtended: expected.canBeExtended,
|
||||
|
@ -151,8 +151,6 @@ describe('Info session routes', () => {
|
|||
});
|
||||
|
||||
it('returns empty response if session is not available.', async () => {
|
||||
session.get.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
routeHandler(
|
||||
{} as unknown as SecurityRequestHandlerContext,
|
||||
|
|
|
@ -16,7 +16,7 @@ export function defineSessionInfoRoutes({ router, getSession }: RouteDefinitionP
|
|||
router.get(
|
||||
{ path: '/internal/security/session', validate: false },
|
||||
async (_context, request, response) => {
|
||||
const sessionValue = await getSession().get(request);
|
||||
const { value: sessionValue } = await getSession().get(request);
|
||||
if (sessionValue) {
|
||||
const expirationTime =
|
||||
sessionValue.idleTimeoutExpiration && sessionValue.lifespanExpiration
|
||||
|
|
|
@ -89,8 +89,6 @@ describe('Update profile routes', () => {
|
|||
});
|
||||
|
||||
it('fails if session is not found.', async () => {
|
||||
session.get.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
routeHandler(
|
||||
getMockContext(),
|
||||
|
@ -103,7 +101,10 @@ describe('Update profile routes', () => {
|
|||
});
|
||||
|
||||
it('fails if session does not have profile ID.', async () => {
|
||||
session.get.mockResolvedValue(sessionMock.createValue({ userProfileId: undefined }));
|
||||
session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ userProfileId: undefined }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
routeHandler(
|
||||
|
@ -117,7 +118,7 @@ describe('Update profile routes', () => {
|
|||
});
|
||||
|
||||
it('fails for Elastic Cloud users.', async () => {
|
||||
session.get.mockResolvedValue(sessionMock.createValue());
|
||||
session.get.mockResolvedValue({ error: null, value: sessionMock.createValue() });
|
||||
authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser({ elastic_cloud_user: true }));
|
||||
|
||||
await expect(
|
||||
|
@ -132,7 +133,10 @@ describe('Update profile routes', () => {
|
|||
});
|
||||
|
||||
it('updates profile.', async () => {
|
||||
session.get.mockResolvedValue(sessionMock.createValue({ userProfileId: 'u_some_id' }));
|
||||
session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ userProfileId: 'u_some_id' }),
|
||||
});
|
||||
authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser());
|
||||
|
||||
await expect(
|
||||
|
|
|
@ -28,14 +28,16 @@ export function defineUpdateUserProfileDataRoute({
|
|||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
const session = await getSession().get(request);
|
||||
if (!session) {
|
||||
if (session.error) {
|
||||
logger.warn('User profile requested without valid session.');
|
||||
return response.notFound();
|
||||
}
|
||||
|
||||
if (!session.userProfileId) {
|
||||
if (!session.value.userProfileId) {
|
||||
logger.warn(
|
||||
`User profile missing from current session. (sid: ${getPrintableSessionId(session.sid)})`
|
||||
`User profile missing from current session. (sid: ${getPrintableSessionId(
|
||||
session.value.sid
|
||||
)})`
|
||||
);
|
||||
return response.notFound();
|
||||
}
|
||||
|
@ -44,7 +46,7 @@ export function defineUpdateUserProfileDataRoute({
|
|||
if (currentUser?.elastic_cloud_user) {
|
||||
logger.warn(
|
||||
`Elastic Cloud SSO users aren't allowed to update profiles in Kibana. (sid: ${getPrintableSessionId(
|
||||
session.sid
|
||||
session.value.sid
|
||||
)})`
|
||||
);
|
||||
return response.forbidden();
|
||||
|
@ -52,7 +54,7 @@ export function defineUpdateUserProfileDataRoute({
|
|||
|
||||
const userProfileService = getUserProfileService();
|
||||
try {
|
||||
await userProfileService.update(session.userProfileId, request.body);
|
||||
await userProfileService.update(session.value.userProfileId, request.body);
|
||||
return response.ok();
|
||||
} catch (error) {
|
||||
return response.customError(wrapIntoCustomErrorResponse(error));
|
||||
|
|
|
@ -19,7 +19,7 @@ import { AuthenticationResult } from '../../authentication';
|
|||
import type { InternalAuthenticationServiceStart } from '../../authentication';
|
||||
import { authenticationServiceMock } from '../../authentication/authentication_service.mock';
|
||||
import { securityMock } from '../../mocks';
|
||||
import type { Session } from '../../session_management';
|
||||
import { type Session, SessionMissingError } from '../../session_management';
|
||||
import { sessionMock } from '../../session_management/session.mock';
|
||||
import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types';
|
||||
import { routeDefinitionParamsMock } from '../index.mock';
|
||||
|
@ -58,7 +58,7 @@ describe('Change password', () => {
|
|||
|
||||
authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser(mockAuthenticatedUser()));
|
||||
authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser()));
|
||||
session.get.mockResolvedValue(sessionMock.createValue());
|
||||
session.get.mockResolvedValue({ error: null, value: sessionMock.createValue() });
|
||||
|
||||
mockCoreContext = coreMock.createRequestHandlerContext();
|
||||
mockContext = coreMock.createCustomRequestHandlerContext({
|
||||
|
@ -190,9 +190,10 @@ describe('Change password', () => {
|
|||
});
|
||||
authc.getCurrentUser.mockReturnValue(mockUser);
|
||||
authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockUser));
|
||||
session.get.mockResolvedValue(
|
||||
sessionMock.createValue({ provider: { type: 'token', name: 'token1' } })
|
||||
);
|
||||
session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ provider: { type: 'token', name: 'token1' } }),
|
||||
});
|
||||
|
||||
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
|
||||
|
||||
|
@ -211,7 +212,7 @@ describe('Change password', () => {
|
|||
});
|
||||
|
||||
it('successfully changes own password but does not re-login if current session does not exist.', async () => {
|
||||
session.get.mockResolvedValue(null);
|
||||
session.get.mockResolvedValue({ error: new SessionMissingError(), value: null });
|
||||
const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
|
|
@ -78,10 +78,10 @@ export function defineChangeUserPasswordRoutes({
|
|||
// user with the new password and update session. We check this since it's possible to update
|
||||
// password even if user is authenticated via HTTP headers and hence doesn't have an active
|
||||
// session and in such cases we shouldn't create a new one.
|
||||
if (isUserChangingOwnPassword && currentSession) {
|
||||
if (isUserChangingOwnPassword && currentSession?.value) {
|
||||
try {
|
||||
const authenticationResult = await getAuthenticationService().login(request, {
|
||||
provider: { name: currentSession.provider.name },
|
||||
provider: { name: currentSession.value.provider.name },
|
||||
value: { username, password: newPassword },
|
||||
});
|
||||
|
||||
|
|
|
@ -130,8 +130,6 @@ describe('Access agreement view routes', () => {
|
|||
it('returns empty `accessAgreement` if session info is not available.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
session.get.mockResolvedValue(null);
|
||||
|
||||
await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
|
||||
options: { body: { accessAgreement: '' } },
|
||||
payload: { accessAgreement: '' },
|
||||
|
@ -164,7 +162,10 @@ describe('Access agreement view routes', () => {
|
|||
];
|
||||
|
||||
for (const [sessionProvider, expectedAccessAgreement] of cases) {
|
||||
session.get.mockResolvedValue(sessionMock.createValue({ provider: sessionProvider }));
|
||||
session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ provider: sessionProvider }),
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
|
||||
options: { body: { accessAgreement: expectedAccessAgreement } },
|
||||
|
@ -201,7 +202,10 @@ describe('Access agreement view routes', () => {
|
|||
];
|
||||
|
||||
for (const [sessionProvider, expectedAccessAgreement] of cases) {
|
||||
session.get.mockResolvedValue(sessionMock.createValue({ provider: sessionProvider }));
|
||||
session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ provider: sessionProvider }),
|
||||
});
|
||||
|
||||
await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({
|
||||
options: { body: { accessAgreement: expectedAccessAgreement } },
|
||||
|
|
|
@ -46,7 +46,7 @@ export function defineAccessAgreementRoutes({
|
|||
// It's not guaranteed that we'll have session for the authenticated user (e.g. when user is
|
||||
// authenticated with the help of HTTP authentication), that means we should safely check if
|
||||
// we have it and can get a corresponding configuration.
|
||||
const sessionValue = await getSession().get(request);
|
||||
const { value: sessionValue } = await getSession().get(request);
|
||||
|
||||
let accessAgreement = '';
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ describe('LoggedOut view routes', () => {
|
|||
});
|
||||
|
||||
it('redirects user to the root page if they have a session already.', async () => {
|
||||
session.get.mockResolvedValue(sessionMock.createValue());
|
||||
session.get.mockResolvedValue({ error: null, value: sessionMock.createValue() });
|
||||
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
|
@ -55,8 +55,6 @@ describe('LoggedOut view routes', () => {
|
|||
});
|
||||
|
||||
it('renders view if user does not have an active session.', async () => {
|
||||
session.get.mockResolvedValue(null);
|
||||
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
const responseFactory = httpResourcesMock.createResponseFactory();
|
||||
await routeHandler({} as any, request, responseFactory);
|
||||
|
|
|
@ -25,7 +25,7 @@ export function defineLoggedOutRoutes({
|
|||
async (context, request, response) => {
|
||||
// Authentication flow isn't triggered automatically for this route, so we should explicitly
|
||||
// check whether user has an active session already.
|
||||
const isUserAlreadyLoggedIn = (await getSession().get(request)) !== null;
|
||||
const isUserAlreadyLoggedIn = (await getSession().get(request)).value !== null;
|
||||
if (isUserAlreadyLoggedIn) {
|
||||
logger.debug('User is already authenticated, redirecting...');
|
||||
return response.redirected({
|
||||
|
|
|
@ -7,5 +7,11 @@
|
|||
|
||||
export type { SessionValue } from './session';
|
||||
export { Session, getPrintableSessionId } from './session';
|
||||
export {
|
||||
SessionError,
|
||||
SessionMissingError,
|
||||
SessionExpiredError,
|
||||
SessionUnexpectedError,
|
||||
} from './session_errors';
|
||||
export type { SessionManagementServiceStart } from './session_management_service';
|
||||
export { SessionManagementService } from './session_management_service';
|
||||
|
|
|
@ -9,12 +9,13 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
|
|||
|
||||
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
|
||||
import type { Session, SessionValue } from './session';
|
||||
import { SessionMissingError } from './session_errors';
|
||||
import { sessionIndexMock } from './session_index.mock';
|
||||
|
||||
export const sessionMock = {
|
||||
create: (): jest.Mocked<PublicMethodsOf<Session>> => ({
|
||||
getSID: jest.fn(),
|
||||
get: jest.fn(),
|
||||
get: jest.fn().mockResolvedValue({ error: new SessionMissingError(), value: null }),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
extend: jest.fn(),
|
||||
|
|
|
@ -14,9 +14,9 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
|
|||
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
|
||||
import { ConfigSchema, createConfig } from '../config';
|
||||
import { sessionCookieMock, sessionIndexMock, sessionMock } from './index.mock';
|
||||
import type { SessionValueContentToEncrypt } from './session';
|
||||
import { getPrintableSessionId, Session } from './session';
|
||||
import { getPrintableSessionId, Session, type SessionValueContentToEncrypt } from './session';
|
||||
import type { SessionCookie } from './session_cookie';
|
||||
import { SessionExpiredError, SessionMissingError, SessionUnexpectedError } from './session_errors';
|
||||
import type { SessionIndex } from './session_index';
|
||||
|
||||
describe('Session', () => {
|
||||
|
@ -87,7 +87,10 @@ describe('Session', () => {
|
|||
})
|
||||
);
|
||||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({
|
||||
error: expect.any(SessionMissingError),
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears session value if session is expired because of idle timeout', async () => {
|
||||
|
@ -107,7 +110,10 @@ describe('Session', () => {
|
|||
})
|
||||
);
|
||||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({
|
||||
error: expect.any(SessionExpiredError),
|
||||
value: null,
|
||||
});
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
@ -129,7 +135,10 @@ describe('Session', () => {
|
|||
})
|
||||
);
|
||||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({
|
||||
error: expect.any(SessionExpiredError),
|
||||
value: null,
|
||||
});
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
@ -144,7 +153,12 @@ describe('Session', () => {
|
|||
);
|
||||
mockSessionIndex.get.mockResolvedValue(null);
|
||||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.any(SessionUnexpectedError),
|
||||
value: null,
|
||||
})
|
||||
);
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
@ -158,7 +172,10 @@ describe('Session', () => {
|
|||
);
|
||||
mockSessionIndex.get.mockResolvedValue(sessionIndexMock.createValue({ content: 'Uh! Oh!' }));
|
||||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({
|
||||
error: expect.any(SessionUnexpectedError),
|
||||
value: null,
|
||||
});
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
@ -180,7 +197,10 @@ describe('Session', () => {
|
|||
})
|
||||
);
|
||||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toBeNull();
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({
|
||||
error: expect.any(SessionUnexpectedError),
|
||||
value: null,
|
||||
});
|
||||
expect(mockSessionCookie.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockSessionIndex.invalidate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
@ -205,14 +225,17 @@ describe('Session', () => {
|
|||
mockSessionIndex.get.mockResolvedValue(mockSessionIndexValue);
|
||||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({
|
||||
idleTimeoutExpiration: now + 1,
|
||||
lifespanExpiration: now + 1,
|
||||
metadata: { index: mockSessionIndexValue },
|
||||
provider: { name: 'basic1', type: 'basic' },
|
||||
sid: 'some-long-sid',
|
||||
state: 'some-state',
|
||||
username: 'some-user',
|
||||
userProfileId: 'uid',
|
||||
error: null,
|
||||
value: {
|
||||
idleTimeoutExpiration: now + 1,
|
||||
lifespanExpiration: now + 1,
|
||||
metadata: { index: mockSessionIndexValue },
|
||||
provider: { name: 'basic1', type: 'basic' },
|
||||
sid: 'some-long-sid',
|
||||
state: 'some-state',
|
||||
username: 'some-user',
|
||||
userProfileId: 'uid',
|
||||
},
|
||||
});
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
|
@ -235,12 +258,15 @@ describe('Session', () => {
|
|||
mockSessionIndex.get.mockResolvedValue(mockSessionIndexValue);
|
||||
|
||||
await expect(session.get(httpServerMock.createKibanaRequest())).resolves.toEqual({
|
||||
idleTimeoutExpiration: now + 1,
|
||||
lifespanExpiration: now + 1,
|
||||
metadata: { index: mockSessionIndexValue },
|
||||
provider: { name: 'basic1', type: 'basic' },
|
||||
sid: 'some-long-sid',
|
||||
state: 'some-state',
|
||||
error: null,
|
||||
value: {
|
||||
idleTimeoutExpiration: now + 1,
|
||||
lifespanExpiration: now + 1,
|
||||
metadata: { index: mockSessionIndexValue },
|
||||
provider: { name: 'basic1', type: 'basic' },
|
||||
sid: 'some-long-sid',
|
||||
state: 'some-state',
|
||||
},
|
||||
});
|
||||
expect(mockSessionCookie.clear).not.toHaveBeenCalled();
|
||||
expect(mockSessionIndex.invalidate).not.toHaveBeenCalled();
|
||||
|
|
|
@ -16,6 +16,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
|
|||
import type { AuthenticationProvider } from '../../common';
|
||||
import type { ConfigType } from '../config';
|
||||
import type { SessionCookie } from './session_cookie';
|
||||
import { SessionExpiredError, SessionMissingError, SessionUnexpectedError } from './session_errors';
|
||||
import type { SessionIndex, SessionIndexValue } from './session_index';
|
||||
|
||||
/**
|
||||
|
@ -150,7 +151,7 @@ export class Session {
|
|||
async get(request: KibanaRequest) {
|
||||
const sessionCookieValue = await this.options.sessionCookie.get(request);
|
||||
if (!sessionCookieValue) {
|
||||
return null;
|
||||
return { error: new SessionMissingError(), value: null };
|
||||
}
|
||||
|
||||
const sessionLogger = this.getLoggerForSID(sessionCookieValue.sid);
|
||||
|
@ -162,7 +163,7 @@ export class Session {
|
|||
) {
|
||||
sessionLogger.debug('Session has expired and will be invalidated.');
|
||||
await this.invalidate(request, { match: 'current' });
|
||||
return null;
|
||||
return { error: new SessionExpiredError(), value: null };
|
||||
}
|
||||
|
||||
const sessionIndexValue = await this.options.sessionIndex.get(sessionCookieValue.sid);
|
||||
|
@ -171,7 +172,7 @@ export class Session {
|
|||
'Session value is not available in the index, session cookie will be invalidated.'
|
||||
);
|
||||
await this.options.sessionCookie.clear(request);
|
||||
return null;
|
||||
return { error: new SessionUnexpectedError(), value: null };
|
||||
}
|
||||
|
||||
let decryptedContent: SessionValueContentToEncrypt;
|
||||
|
@ -184,13 +185,16 @@ export class Session {
|
|||
`Unable to decrypt session content, session will be invalidated: ${err.message}`
|
||||
);
|
||||
await this.invalidate(request, { match: 'current' });
|
||||
return null;
|
||||
return { error: new SessionUnexpectedError(), value: null };
|
||||
}
|
||||
|
||||
return {
|
||||
...Session.sessionIndexValueToSessionValue(sessionIndexValue, decryptedContent),
|
||||
// Unlike session index, session cookie contains the most up-to-date idle timeout expiration.
|
||||
idleTimeoutExpiration: sessionCookieValue.idleTimeoutExpiration,
|
||||
error: null,
|
||||
value: {
|
||||
...Session.sessionIndexValueToSessionValue(sessionIndexValue, decryptedContent),
|
||||
// Unlike session index, session cookie contains the most up-to-date idle timeout expiration.
|
||||
idleTimeoutExpiration: sessionCookieValue.idleTimeoutExpiration,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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 { SessionError } from './session_error';
|
||||
export { SessionMissingError } from './session_missing_error';
|
||||
export { SessionExpiredError } from './session_expired_error';
|
||||
export { SessionUnexpectedError } from './session_unexpected_error';
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 enum SessionErrorReason {
|
||||
'SESSION_MISSING' = 'SESSION_MISSING',
|
||||
'SESSION_EXPIRED' = 'SESSION_EXPIRED',
|
||||
'UNEXPECTED_SESSION_ERROR' = 'UNEXPECTED_SESSION_ERROR',
|
||||
}
|
||||
|
||||
export class SessionError extends Error {
|
||||
constructor(message: string, public readonly code: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SessionError, SessionErrorReason } from './session_error';
|
||||
|
||||
export class SessionExpiredError extends SessionError {
|
||||
constructor() {
|
||||
super(SessionErrorReason.SESSION_EXPIRED, SessionErrorReason.SESSION_EXPIRED);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SessionError, SessionErrorReason } from './session_error';
|
||||
|
||||
export class SessionMissingError extends SessionError {
|
||||
constructor() {
|
||||
super(SessionErrorReason.SESSION_MISSING, SessionErrorReason.SESSION_MISSING);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SessionError, SessionErrorReason } from './session_error';
|
||||
|
||||
export class SessionUnexpectedError extends SessionError {
|
||||
constructor() {
|
||||
super(SessionErrorReason.UNEXPECTED_SESSION_ERROR, SessionErrorReason.UNEXPECTED_SESSION_ERROR);
|
||||
}
|
||||
}
|
|
@ -90,8 +90,6 @@ describe('UserProfileService', () => {
|
|||
});
|
||||
|
||||
it('returns `null` if session is not available', async () => {
|
||||
mockStartParams.session.get.mockResolvedValue(null);
|
||||
|
||||
const startContract = userProfileService.start(mockStartParams);
|
||||
await expect(startContract.getCurrent({ request: mockRequest })).resolves.toBeNull();
|
||||
|
||||
|
@ -104,9 +102,10 @@ describe('UserProfileService', () => {
|
|||
});
|
||||
|
||||
it('returns `null` if session available, but not user profile id', async () => {
|
||||
mockStartParams.session.get.mockResolvedValue(
|
||||
sessionMock.createValue({ userProfileId: undefined })
|
||||
);
|
||||
mockStartParams.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ userProfileId: undefined }),
|
||||
});
|
||||
|
||||
const startContract = userProfileService.start(mockStartParams);
|
||||
await expect(startContract.getCurrent({ request: mockRequest })).resolves.toBeNull();
|
||||
|
@ -137,9 +136,10 @@ describe('UserProfileService', () => {
|
|||
});
|
||||
|
||||
it('fails if profile retrieval fails', async () => {
|
||||
mockStartParams.session.get.mockResolvedValue(
|
||||
sessionMock.createValue({ userProfileId: mockUserProfile.uid })
|
||||
);
|
||||
mockStartParams.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }),
|
||||
});
|
||||
|
||||
const failureReason = new errors.ResponseError(
|
||||
securityMock.createApiResponse({ statusCode: 500, body: 'some message' })
|
||||
|
@ -165,9 +165,10 @@ describe('UserProfileService', () => {
|
|||
});
|
||||
|
||||
it('fails if cannot find user profile', async () => {
|
||||
mockStartParams.session.get.mockResolvedValue(
|
||||
sessionMock.createValue({ userProfileId: mockUserProfile.uid })
|
||||
);
|
||||
mockStartParams.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }),
|
||||
});
|
||||
|
||||
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({
|
||||
profiles: [],
|
||||
|
@ -192,9 +193,10 @@ describe('UserProfileService', () => {
|
|||
});
|
||||
|
||||
it('properly parses returned profile', async () => {
|
||||
mockStartParams.session.get.mockResolvedValue(
|
||||
sessionMock.createValue({ userProfileId: mockUserProfile.uid })
|
||||
);
|
||||
mockStartParams.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }),
|
||||
});
|
||||
|
||||
const startContract = userProfileService.start(mockStartParams);
|
||||
await expect(startContract.getCurrent({ request: mockRequest })).resolves
|
||||
|
@ -231,9 +233,10 @@ describe('UserProfileService', () => {
|
|||
});
|
||||
|
||||
it('should get user profile and application data scoped to Kibana', async () => {
|
||||
mockStartParams.session.get.mockResolvedValue(
|
||||
sessionMock.createValue({ userProfileId: mockUserProfile.uid })
|
||||
);
|
||||
mockStartParams.session.get.mockResolvedValue({
|
||||
error: null,
|
||||
value: sessionMock.createValue({ userProfileId: mockUserProfile.uid }),
|
||||
});
|
||||
|
||||
mockStartParams.clusterClient.asInternalUser.security.getUserProfile.mockResolvedValue({
|
||||
profiles: [
|
||||
|
|
|
@ -24,8 +24,7 @@ import type {
|
|||
import type { AuthorizationServiceSetupInternal } from '../authorization';
|
||||
import type { CheckUserProfilesPrivilegesResponse } from '../authorization/types';
|
||||
import { getDetailedErrorMessage, getErrorStatusCode } from '../errors';
|
||||
import type { Session } from '../session_management';
|
||||
import { getPrintableSessionId } from '../session_management';
|
||||
import { getPrintableSessionId, type Session } from '../session_management';
|
||||
import type { UserProfileGrant } from './user_profile_grant';
|
||||
|
||||
const KIBANA_DATA_ROOT = 'kibana';
|
||||
|
@ -316,14 +315,14 @@ export class UserProfileService {
|
|||
throw error;
|
||||
}
|
||||
|
||||
if (!userSession) {
|
||||
if (userSession.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!userSession.userProfileId) {
|
||||
if (!userSession.value.userProfileId) {
|
||||
this.logger.debug(
|
||||
`User profile missing from the current session [sid=${getPrintableSessionId(
|
||||
userSession.sid
|
||||
userSession.value.sid
|
||||
)}].`
|
||||
);
|
||||
return null;
|
||||
|
@ -333,13 +332,13 @@ export class UserProfileService {
|
|||
try {
|
||||
// @ts-expect-error Invalid response format.
|
||||
body = (await clusterClient.asInternalUser.security.getUserProfile({
|
||||
uid: userSession.userProfileId,
|
||||
uid: userSession.value.userProfileId,
|
||||
data: dataPath ? prefixCommaSeparatedValues(dataPath, KIBANA_DATA_ROOT) : undefined,
|
||||
})) as { profiles: SecurityUserProfileWithMetadata[] };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to retrieve user profile for the current user [sid=${getPrintableSessionId(
|
||||
userSession.sid
|
||||
userSession.value.sid
|
||||
)}]: ${getDetailedErrorMessage(error)}`
|
||||
);
|
||||
throw error;
|
||||
|
@ -348,7 +347,7 @@ export class UserProfileService {
|
|||
if (body.profiles.length === 0) {
|
||||
this.logger.error(
|
||||
`The user profile for the current user [sid=${getPrintableSessionId(
|
||||
userSession.sid
|
||||
userSession.value.sid
|
||||
)}] is not found.`
|
||||
);
|
||||
throw new Error(`User profile is not found.`);
|
||||
|
|
|
@ -66,6 +66,21 @@ export class SecurityPageObject extends FtrService {
|
|||
);
|
||||
},
|
||||
|
||||
getInfoMessage: async () => {
|
||||
return await this.retry.try(async () => {
|
||||
const infoMessageContainer = await this.retry.try(() =>
|
||||
this.testSubjects.find('loginInfoMessage')
|
||||
);
|
||||
const infoMessageText = await infoMessageContainer.getVisibleText();
|
||||
|
||||
if (!infoMessageText) {
|
||||
throw new Error('Login Info Message not present yet');
|
||||
}
|
||||
|
||||
return infoMessageText;
|
||||
});
|
||||
},
|
||||
|
||||
getErrorMessage: async () => {
|
||||
return await this.retry.try(async () => {
|
||||
const errorMessageContainer = await this.retry.try(() =>
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* 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 { parse as parseCookie } from 'tough-cookie';
|
||||
import { setTimeout as setTimeoutAsync } from 'timers/promises';
|
||||
import expect from '@kbn/expect';
|
||||
import { adminTestUser } from '@kbn/test';
|
||||
import { SESSION_ERROR_REASON_HEADER } from '@kbn/security-plugin/common/constants';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const es = getService('es');
|
||||
const esDeleteAllIndices = getService('esDeleteAllIndices');
|
||||
const log = getService('log');
|
||||
const { username: basicUsername, password: basicPassword } = adminTestUser;
|
||||
|
||||
async function getNumberOfSessionDocuments() {
|
||||
return (
|
||||
// @ts-expect-error doesn't handle total as number
|
||||
(await es.search({ index: '.kibana_security_session*' })).hits.total.value as number
|
||||
);
|
||||
}
|
||||
|
||||
describe('Session expired', () => {
|
||||
beforeEach(async () => {
|
||||
await es.cluster.health({ index: '.kibana_security_session*', wait_for_status: 'green' });
|
||||
await esDeleteAllIndices('.kibana_security_session*');
|
||||
});
|
||||
|
||||
it(`return ${SESSION_ERROR_REASON_HEADER} header if session is expired`, async function () {
|
||||
this.timeout(100000);
|
||||
|
||||
log.debug(`Log in as ${basicUsername} using ${basicPassword} password.`);
|
||||
const response = await supertest
|
||||
.post('/internal/security/login')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({
|
||||
providerType: 'basic',
|
||||
providerName: 'basic1',
|
||||
currentURL: '/',
|
||||
params: { username: basicUsername, password: basicPassword },
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const sessionCookie = parseCookie(response.headers['set-cookie'][0])!;
|
||||
expect(await getNumberOfSessionDocuments()).to.be(1);
|
||||
|
||||
log.debug(`Authenticating as ${basicUsername} with valid session cookie.`);
|
||||
await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(200);
|
||||
|
||||
// sessions expire based on the `xpack.security.session.idleTimeout` config and is 10s in this test
|
||||
log.debug('Waiting for session to expire...');
|
||||
await setTimeoutAsync(11000);
|
||||
|
||||
// Session info should not be removed yet (if cleanup is run before we expect, then it will be)
|
||||
expect(await getNumberOfSessionDocuments()).to.be(1);
|
||||
|
||||
log.debug(`Authenticating as ${basicUsername} with expired session cookie.`);
|
||||
const resp = await supertest
|
||||
.get('/internal/security/me')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('Cookie', sessionCookie.cookieString())
|
||||
.expect(401);
|
||||
|
||||
expect(resp.headers[SESSION_ERROR_REASON_HEADER]).to.be('SESSION_EXPIRED');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
|
|||
describe('security APIs - Session Idle', function () {
|
||||
loadTestFile(require.resolve('./cleanup'));
|
||||
loadTestFile(require.resolve('./extension'));
|
||||
loadTestFile(require.resolve('./expired'));
|
||||
});
|
||||
}
|
||||
|
|
52
x-pack/test/security_functional/expired_session.config.ts
Normal file
52
x-pack/test/security_functional/expired_session.config.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 { resolve } from 'path';
|
||||
import { FtrConfigProviderContext } from '@kbn/test';
|
||||
import { services } from '../functional/services';
|
||||
import { pageObjects } from '../functional/page_objects';
|
||||
|
||||
// the default export of config files must be a config provider
|
||||
// that returns an object with the projects config values
|
||||
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
||||
const xpackFunctionalConfig = await readConfigFile(
|
||||
require.resolve('../functional/config.base.js')
|
||||
);
|
||||
|
||||
const testEndpointsPlugin = resolve(__dirname, './fixtures/common/test_endpoints');
|
||||
|
||||
return {
|
||||
testFiles: [resolve(__dirname, './tests/expired_session')],
|
||||
services,
|
||||
pageObjects,
|
||||
servers: xpackFunctionalConfig.get('servers'),
|
||||
esTestCluster: xpackFunctionalConfig.get('esTestCluster'),
|
||||
kbnTestServer: {
|
||||
...xpackFunctionalConfig.get('kbnTestServer'),
|
||||
serverArgs: [
|
||||
...xpackFunctionalConfig.get('kbnTestServer.serverArgs'),
|
||||
`--plugin-path=${testEndpointsPlugin}`,
|
||||
'--xpack.security.session.idleTimeout=10s',
|
||||
`--xpack.security.authc.providers=${JSON.stringify({
|
||||
basic: { basic1: { order: 0 } },
|
||||
anonymous: {
|
||||
anonymous1: {
|
||||
order: 1,
|
||||
credentials: { username: 'anonymous_user', password: 'changeme' },
|
||||
},
|
||||
},
|
||||
})}`,
|
||||
],
|
||||
},
|
||||
uiSettings: xpackFunctionalConfig.get('uiSettings'),
|
||||
apps: xpackFunctionalConfig.get('apps'),
|
||||
screenshots: { directory: resolve(__dirname, 'screenshots') },
|
||||
junit: {
|
||||
reportName: 'Chrome X-Pack Security Functional Tests (Expired Session)',
|
||||
},
|
||||
};
|
||||
}
|
|
@ -18,6 +18,17 @@ export interface PluginStartDependencies {
|
|||
|
||||
export class TestEndpointsPlugin implements Plugin<void, void, object, PluginStartDependencies> {
|
||||
public setup(core: CoreSetup<PluginStartDependencies>) {
|
||||
// Prevent auto-logout on server `401` errors.
|
||||
core.http.anonymousPaths.register('/app/expired_session_test');
|
||||
core.application.register({
|
||||
id: 'expired_session_test',
|
||||
title: 'Expired Session Test',
|
||||
async mount({ element }) {
|
||||
(window as any).kibanaFetch = core.http.fetch;
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
},
|
||||
});
|
||||
|
||||
// Prevent auto-logout on server `401` errors.
|
||||
core.http.anonymousPaths.register('/authentication/app');
|
||||
|
||||
|
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { SESSION_ERROR_REASON_HEADER } from '@kbn/security-plugin/common/constants';
|
||||
import { setTimeout as setTimeoutAsync } from 'timers/promises';
|
||||
import { parse } from 'url';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const browser = getService('browser');
|
||||
const deployment = getService('deployment');
|
||||
const PageObjects = getPageObjects(['security', 'common']);
|
||||
|
||||
describe('Basic functionality', function () {
|
||||
this.tags('includeFirefox');
|
||||
|
||||
before(async () => {
|
||||
await PageObjects.security.forceLogout();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// NOTE: Logout needs to happen before anything else to avoid flaky behavior
|
||||
await PageObjects.security.forceLogout();
|
||||
});
|
||||
|
||||
it('should attach msg=SESSION_EXPIRED to the redirect URL when redirecting to /login if the session has expired when trying to access a page', async () => {
|
||||
await login();
|
||||
|
||||
// Kibana will log the user out automatically 5 seconds before the `xpack.security.session.idleTimeout` timeout.
|
||||
// To simulate what will happen if this doesn't happen, navigate to a non-Kibana URL to ensure Kibana isn't running in the browser.
|
||||
await browser.get('data:,');
|
||||
|
||||
// Sessions expire based on the `xpack.security.session.idleTimeout` config and is 10s in this test
|
||||
await setTimeoutAsync(11000);
|
||||
|
||||
await PageObjects.common.navigateToUrl('home', '', {
|
||||
ensureCurrentUrl: false,
|
||||
shouldLoginIfPrompted: false,
|
||||
});
|
||||
|
||||
const currentURL = parse(await browser.getCurrentUrl());
|
||||
expect(currentURL.pathname).to.eql('/login');
|
||||
|
||||
expect(await PageObjects.security.loginPage.getInfoMessage()).to.be(
|
||||
'Your session has timed out. Please log in again.'
|
||||
);
|
||||
});
|
||||
|
||||
it(`should handle returned ${SESSION_ERROR_REASON_HEADER} header when the server response is 401`, async () => {
|
||||
// TODO: Even though the anonymous path is registered as anonymous, we're still redirected to the `/login` page. So for now I can't test the base case and have to trust that this works as intented
|
||||
// await goToAnonymousPath();
|
||||
|
||||
// // First verify that without being logged in, we get the expected result if requesting a protected API
|
||||
// const notAuthenticatedFailure = await fetchProtectedAPI();
|
||||
// expect(notAuthenticatedFailure.statusCode).to.be(??);
|
||||
// expect(notAuthenticatedFailure.reason).to.be(??);
|
||||
|
||||
await login();
|
||||
|
||||
await goToAnonymousPath();
|
||||
|
||||
// Test that, even though we're now on a page that doesn't require
|
||||
// authentication, that we can request an API that does and that our
|
||||
// session allows us to.
|
||||
const success = await fetchProtectedAPI();
|
||||
expect(success.statusCode).to.be(200);
|
||||
expect(success.reason).to.be(null);
|
||||
|
||||
// Sessions expire based on the `xpack.security.session.idleTimeout`
|
||||
// config and is 10s in this test
|
||||
await setTimeoutAsync(11000);
|
||||
|
||||
// After the session should have expired, the API should now return
|
||||
// accordingly
|
||||
const failure = await fetchProtectedAPI();
|
||||
expect(failure.statusCode).to.be(401);
|
||||
expect(failure.reason).to.be('SESSION_EXPIRED');
|
||||
|
||||
// Navigate to a page that doesn't require authentication, so that the
|
||||
// timer which normally would log the user out 5 seconds before their
|
||||
// session expires, isn't scheduled.
|
||||
async function goToAnonymousPath() {
|
||||
await browser.get(`${deployment.getHostPort()}/app/expired_session_test`);
|
||||
const currentURL = parse(await browser.getCurrentUrl());
|
||||
expect(currentURL.pathname).to.eql('/app/expired_session_test');
|
||||
}
|
||||
|
||||
async function fetchProtectedAPI() {
|
||||
// Ensure we're on the expected page and haven't, for instance, been
|
||||
// logged out and redirected to `/login`. If we're not on this page,
|
||||
// getting `window.kibanaFetch` later on will not work.
|
||||
const currentURL = parse(await browser.getCurrentUrl());
|
||||
expect(currentURL.pathname).to.eql('/app/expired_session_test');
|
||||
|
||||
// Exectue an AJAX request from within the browser to the Kibana server
|
||||
// and return the result
|
||||
return await browser.execute(async () => {
|
||||
let response;
|
||||
|
||||
try {
|
||||
const kibanaFetch = await getKibanaFetch();
|
||||
const result = await kibanaFetch('/internal/security/me', {
|
||||
asResponse: true, // if `false` we would just get the body of the request
|
||||
});
|
||||
response = result.response;
|
||||
} catch (err) {
|
||||
// We expect kibanaFetch to throw when it gets a 401 response, so
|
||||
// in that case, this `catch` block is actually the "happy" path.
|
||||
// However, if the thrown error looks different from what we
|
||||
// expect, then re-throw it.
|
||||
if (err.res === undefined) {
|
||||
throw err;
|
||||
}
|
||||
response = err.res;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
reason: response.headers.get('kbn-session-error-reason'),
|
||||
};
|
||||
|
||||
async function getKibanaFetch() {
|
||||
// In our test-Kibana-plugin, we store the custom Kibana fetch
|
||||
// function on the `window` object as a hack so we can access it
|
||||
// from here.
|
||||
const kibanaFetch = (window as any).kibanaFetch;
|
||||
if (kibanaFetch === undefined) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(getKibanaFetch());
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
return kibanaFetch;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function login() {
|
||||
await browser.get(`${deployment.getHostPort()}/login`);
|
||||
await PageObjects.security.loginSelector.verifyLoginSelectorIsVisible();
|
||||
await PageObjects.security.loginSelector.login('basic', 'basic1');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('security app - expired session', function () {
|
||||
loadTestFile(require.resolve('./basic_functionality'));
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue