Return session error message to client (#139811)

This commit is contained in:
Thomas Watson 2022-11-07 13:35:47 +01:00 committed by GitHub
parent 9c6c725147
commit 2e495074fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 949 additions and 219 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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);
}
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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);
}
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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);
}
}

View file

@ -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: [

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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'));
});
}