[7.x] Migrate authentication subsystem to the new platform. (#41593)

This commit is contained in:
Aleh Zasypkin 2019-07-22 10:47:54 +02:00 committed by GitHub
parent 945dde0e85
commit e19a03bb7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 6224 additions and 5582 deletions

View file

@ -75,7 +75,7 @@ export function createRootWithSettings(
repl: false,
basePath: false,
optimize: false,
oss: false,
oss: true,
...cliArgs,
},
isDevClusterMaster: false,

View file

@ -1,58 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`config schema authc oidc realm realm is not allowed when authc.providers is "['basic']" 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is not allowed]]]`;
exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is required]]]`;
exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc']" and realm is unspecified 1`] = `[ValidationError: child "authc" fails because [child "oidc" fails because ["oidc" is required]]]`;
exports[`config schema authc oidc realm returns a validation error when authc.providers is "['oidc']" and realm is unspecified 2`] = `[ValidationError: child "authc" fails because [child "oidc" fails because [child "realm" fails because ["realm" is required]]]]`;
exports[`config schema authc saml \`realm\` is not allowed if saml provider is not enabled 1`] = `[ValidationError: child "authc" fails because [child "saml" fails because ["saml" is not allowed]]]`;
exports[`config schema with context {"dist":false} produces correct config 1`] = `
Object {
"audit": Object {
"enabled": false,
},
"authc": Object {
"providers": Array [
"basic",
],
},
"authorization": Object {
"legacyFallback": Object {
"enabled": true,
},
},
"cookieName": "sid",
"enabled": true,
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"public": Object {},
"secureCookies": false,
"sessionTimeout": null,
}
`;
exports[`config schema with context {"dist":true} produces correct config 1`] = `
Object {
"audit": Object {
"enabled": false,
},
"authc": Object {
"providers": Array [
"basic",
],
},
"authorization": Object {
"legacyFallback": Object {
"enabled": true,
},
},
"cookieName": "sid",
"enabled": true,
"public": Object {},
"secureCookies": false,
"sessionTimeout": null,
}
`;

View file

@ -8,6 +8,9 @@ export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role';
export { FeaturesPrivileges } from './features_privileges';
export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges';
export { KibanaPrivileges } from './kibana_privileges';
export { User, EditUser, getUserDisplayName } from './user';
export { AuthenticatedUser, canUserChangePassword } from './authenticated_user';
export { User, EditUser, getUserDisplayName } from '../../../../../plugins/security/common/model';
export {
AuthenticatedUser,
canUserChangePassword,
} from '../../../../../plugins/security/common/model';
export { BuiltinESPrivileges } from './builtin_es_privileges';

View file

@ -6,7 +6,6 @@
import { Legacy } from 'kibana';
import { AuthenticatedUser } from './common/model';
import { AuthenticationResult, DeauthenticationResult } from './server/lib/authentication';
import { AuthorizationService } from './server/lib/authorization/service';
/**
@ -14,8 +13,5 @@ import { AuthorizationService } from './server/lib/authorization/service';
*/
export interface SecurityPlugin {
authorization: Readonly<AuthorizationService>;
authenticate: (request: Legacy.Request) => Promise<AuthenticationResult>;
deauthenticate: (request: Legacy.Request) => Promise<DeauthenticationResult>;
getUser: (request: Legacy.Request) => Promise<AuthenticatedUser>;
isAuthenticated: (request: Legacy.Request) => Promise<boolean>;
}

View file

@ -6,7 +6,6 @@
import { resolve } from 'path';
import { get, has } from 'lodash';
import { getUserProvider } from './server/lib/get_user';
import { initAuthenticateApi } from './server/routes/api/v1/authenticate';
import { initUsersApi } from './server/routes/api/v1/users';
import { initExternalRolesApi } from './server/routes/api/external/roles';
@ -17,10 +16,7 @@ import { initOverwrittenSessionView } from './server/routes/views/overwritten_se
import { initLoginView } from './server/routes/views/login';
import { initLogoutView } from './server/routes/views/logout';
import { initLoggedOutView } from './server/routes/views/logged_out';
import { validateConfig } from './server/lib/validate_config';
import { authenticateFactory } from './server/lib/auth_redirect';
import { checkLicense } from './server/lib/check_license';
import { initAuthenticator } from './server/lib/authentication/authenticator';
import { SecurityAuditLogger } from './server/lib/audit_logger';
import { AuditLogger } from '../../server/lib/audit_logger';
import {
@ -35,6 +31,7 @@ import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status
import { SecureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/secure_saved_objects_client_wrapper';
import { deepFreeze } from './server/lib/deep_freeze';
import { createOptionalPlugin } from '../../server/lib/optional_plugin';
import { KibanaRequest } from '../../../../src/core/server';
export const security = (kibana) => new kibana.Plugin({
id: 'security',
@ -43,28 +40,13 @@ export const security = (kibana) => new kibana.Plugin({
require: ['kibana', 'elasticsearch', 'xpack_main'],
config(Joi) {
const providerOptionsSchema = (providerName, schema) => Joi.any()
.when('providers', {
is: Joi.array().items(Joi.string().valid(providerName).required(), Joi.string()),
then: schema,
otherwise: Joi.any().forbidden(),
});
return Joi.object({
enabled: Joi.boolean().default(true),
cookieName: Joi.string().default('sid'),
encryptionKey: Joi.when(Joi.ref('$dist'), {
is: true,
then: Joi.string(),
otherwise: Joi.string().default('a'.repeat(32)),
}),
sessionTimeout: Joi.number().allow(null).default(null),
secureCookies: Joi.boolean().default(false),
public: Joi.object({
protocol: Joi.string().valid(['http', 'https']),
hostname: Joi.string().hostname(),
port: Joi.number().integer().min(0).max(65535)
}).default(),
cookieName: Joi.any().description('This key is handled in the new platform security plugin ONLY'),
encryptionKey: Joi.any().description('This key is handled in the new platform security plugin ONLY'),
sessionTimeout: Joi.any().description('This key is handled in the new platform security plugin ONLY'),
secureCookies: Joi.any().description('This key is handled in the new platform security plugin ONLY'),
public: Joi.any().description('This key is handled in the new platform security plugin ONLY'),
authorization: Joi.object({
legacyFallback: Joi.object({
enabled: Joi.boolean().default(true) // deprecated
@ -73,11 +55,7 @@ export const security = (kibana) => new kibana.Plugin({
audit: Joi.object({
enabled: Joi.boolean().default(false)
}).default(),
authc: Joi.object({
providers: Joi.array().items(Joi.string()).default(['basic']),
oidc: providerOptionsSchema('oidc', Joi.object({ realm: Joi.string().required() }).required()),
saml: providerOptionsSchema('saml', Joi.object({ realm: Joi.string() })),
}).default()
authc: Joi.any().description('This key is handled in the new platform security plugin ONLY')
}).default();
},
@ -130,15 +108,18 @@ export const security = (kibana) => new kibana.Plugin({
'plugins/security/hacks/on_unauthorized_response'
],
home: ['plugins/security/register_feature'],
injectDefaultVars: function (server) {
const config = server.config();
injectDefaultVars: (server) => {
const securityPlugin = server.newPlatform.setup.plugins.security;
if (!securityPlugin) {
throw new Error('New Platform XPack Security plugin is not available.');
}
return {
secureCookies: config.get('xpack.security.secureCookies'),
sessionTimeout: config.get('xpack.security.sessionTimeout'),
enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'),
secureCookies: securityPlugin.config.secureCookies,
sessionTimeout: securityPlugin.config.sessionTimeout,
enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'),
};
}
},
},
async postInit(server) {
@ -156,28 +137,34 @@ export const security = (kibana) => new kibana.Plugin({
},
async init(server) {
const plugin = this;
const securityPlugin = server.newPlatform.setup.plugins.security;
if (!securityPlugin) {
throw new Error('New Platform XPack Security plugin is not available.');
}
const config = server.config();
const xpackMainPlugin = server.plugins.xpack_main;
const xpackInfo = xpackMainPlugin.info;
securityPlugin.registerLegacyAPI({
xpackInfo,
serverConfig: {
protocol: server.info.protocol,
hostname: config.get('server.host'),
port: config.get('server.port'),
},
isSystemAPIRequest: server.plugins.kibana.systemApi.isSystemApiRequest.bind(
server.plugins.kibana.systemApi
),
});
const plugin = this;
const xpackInfoFeature = xpackInfo.feature(plugin.id);
// Register a function that is called whenever the xpack info changes,
// to re-compute the license check results for this plugin
xpackInfoFeature.registerLicenseCheckResultsGenerator(checkLicense);
validateConfig(config, message => server.log(['security', 'warning'], message));
// Create a Hapi auth scheme that should be applied to each request.
server.auth.scheme('login', () => ({ authenticate: authenticateFactory(server) }));
server.auth.strategy('session', 'login');
// The default means that the `session` strategy that is based on `login` schema defined above will be
// automatically assigned to all routes that don't contain an auth config.
server.auth.default('session');
server.expose({ getUser: request => securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)) });
const { savedObjects } = server;
@ -221,20 +208,17 @@ export const security = (kibana) => new kibana.Plugin({
return client;
});
getUserProvider(server);
await initAuthenticator(server);
initAuthenticateApi(server);
initAuthenticateApi(securityPlugin, server);
initAPIAuthorization(server, authorization);
initAppAuthorization(server, xpackMainPlugin, authorization);
initUsersApi(server);
initUsersApi(securityPlugin, server);
initExternalRolesApi(server);
initIndicesApi(server);
initPrivilegesApi(server);
initGetBuiltinPrivilegesApi(server);
initLoginView(server, xpackMainPlugin);
initLoginView(securityPlugin, server, xpackMainPlugin);
initLogoutView(server);
initLoggedOutView(server);
initLoggedOutView(securityPlugin, server);
initOverwrittenSessionView(server);
server.injectUiAppVars('login', () => {

View file

@ -1,117 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { security } from './index';
import { getConfigSchema } from '../../../test_utils';
const describeWithContext = describe.each([[{ dist: false }], [{ dist: true }]]);
describeWithContext('config schema with context %j', context => {
it('produces correct config', async () => {
const schema = await getConfigSchema(security);
await expect(schema.validate({}, { context })).resolves.toMatchSnapshot();
});
});
describe('config schema', () => {
describe('authc', () => {
describe('oidc', () => {
describe('realm', () => {
it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => {
const schema = await getConfigSchema(security);
expect(schema.validate({ authc: { providers: ['oidc'] } }).error).toMatchSnapshot();
expect(schema.validate({ authc: { providers: ['oidc'], oidc: {} } }).error).toMatchSnapshot();
});
it(`is valid when authc.providers is "['oidc']" and realm is specified`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authc: {
providers: ['oidc'],
oidc: {
realm: 'realm-1',
},
},
});
expect(validationResult.error).toBeNull();
expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1');
});
it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authc: { providers: ['oidc', 'basic'] },
});
expect(validationResult.error).toMatchSnapshot();
});
it(`is valid when authc.providers is "['oidc', 'basic']" and realm is specified`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authc: {
providers: ['oidc', 'basic'],
oidc: {
realm: 'realm-1',
},
},
});
expect(validationResult.error).toBeNull();
expect(validationResult.value).toHaveProperty('authc.oidc.realm', 'realm-1');
});
it(`realm is not allowed when authc.providers is "['basic']"`, async () => {
const schema = await getConfigSchema(security);
const validationResult = schema.validate({
authc: {
providers: ['basic'],
oidc: {
realm: 'realm-1',
},
},
});
expect(validationResult.error).toMatchSnapshot();
});
});
});
describe('saml', () => {
it('`realm` is optional', async () => {
const schema = await getConfigSchema(security);
let validationResult = schema.validate({
authc: { providers: ['saml'] },
});
expect(validationResult.error).toBeNull();
expect(validationResult.value.authc.saml).toBeUndefined();
validationResult = schema.validate({
authc: { providers: ['saml'], saml: {} },
});
expect(validationResult.error).toBeNull();
expect(validationResult.value.authc.saml.realm).toBeUndefined();
validationResult = schema.validate({
authc: { providers: ['saml'], saml: { realm: 'realm-1' } },
});
expect(validationResult.error).toBeNull();
expect(validationResult.value.authc.saml.realm).toBe('realm-1');
});
it('`realm` is not allowed if saml provider is not enabled', async () => {
const schema = await getConfigSchema(security);
expect(schema.validate({
authc: {
providers: ['basic'],
saml: { realm: 'realm-1' },
},
}).error).toMatchSnapshot();
});
});
});
});

View file

@ -7,7 +7,7 @@ import { EuiFieldText } from '@elastic/eui';
import { ReactWrapper } from 'enzyme';
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { User } from '../../../../common/model/user';
import { User } from '../../../../common/model';
import { UserAPIClient } from '../../../lib/api';
import { ChangePasswordForm } from './change_password_form';

View file

@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { ChangeEvent, Component } from 'react';
import { toastNotifications } from 'ui/notify';
import { User } from '../../../../common/model/user';
import { User } from '../../../../common/model';
import { UserAPIClient } from '../../../lib/api';
interface Props {

View file

@ -5,9 +5,7 @@
*/
import { Request } from 'hapi';
import { stub } from 'sinon';
import url from 'url';
import { LoginAttempt } from '../../authentication/login_attempt';
interface RequestFixtureOptions {
headers?: Record<string, string>;
@ -24,26 +22,18 @@ export function requestFixture({
auth,
params,
path = '/wat',
basePath = '',
search = '',
payload,
}: RequestFixtureOptions = {}) {
const cookieAuth = { clear: stub(), set: stub() };
return ({
raw: { req: { headers } },
auth,
headers,
params,
url: { path, search },
cookieAuth,
getBasePath: () => basePath,
loginAttempt: stub().returns(new LoginAttempt()),
query: search ? url.parse(search, true /* parseQueryString */).query : {},
payload,
state: { user: 'these are the contents of the user client cookie' },
} as any) as Request & {
cookieAuth: typeof cookieAuth;
loginAttempt: () => LoginAttempt;
getBasePath: () => string;
};
route: { settings: {} },
} as any) as Request;
}

View file

@ -1,154 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import expect from '@kbn/expect';
import sinon from 'sinon';
import { hFixture } from './__fixtures__/h';
import { requestFixture } from './__fixtures__/request';
import { serverFixture } from './__fixtures__/server';
import { AuthenticationResult } from '../authentication/authentication_result';
import { authenticateFactory } from '../auth_redirect';
describe('lib/auth_redirect', function () {
let authenticate;
let request;
let h;
let err;
let credentials;
let server;
beforeEach(() => {
request = requestFixture();
h = hFixture();
err = new Error();
credentials = {};
server = serverFixture();
server.plugins.xpack_main.info
.isAvailable.returns(true);
server.plugins.xpack_main.info
.feature.returns({ isEnabled: sinon.stub().returns(true) });
authenticate = authenticateFactory(server);
});
it('invokes `authenticate` with request', async () => {
server.plugins.security.authenticate.withArgs(request).returns(
Promise.resolve(AuthenticationResult.succeeded(credentials))
);
await authenticate(request, h);
sinon.assert.calledWithExactly(server.plugins.security.authenticate, request);
});
it('continues request with credentials on success', async () => {
server.plugins.security.authenticate.withArgs(request).returns(
Promise.resolve(AuthenticationResult.succeeded(credentials))
);
await authenticate(request, h);
sinon.assert.calledWith(h.authenticated, { credentials });
sinon.assert.notCalled(h.redirect);
});
it('redirects user if redirection is requested by the authenticator', async () => {
server.plugins.security.authenticate.withArgs(request).returns(
Promise.resolve(AuthenticationResult.redirectTo('/some/url'))
);
await authenticate(request, h);
sinon.assert.calledWithExactly(h.redirect, '/some/url');
sinon.assert.called(h.takeover);
sinon.assert.notCalled(h.authenticated);
});
it('returns `Internal Server Error` when `authenticate` throws unhandled exception', async () => {
server.plugins.security.authenticate
.withArgs(request)
.returns(Promise.reject(err));
const response = await authenticate(request, h);
sinon.assert.calledWithExactly(server.log, ['error', 'authentication'], sinon.match.same(err));
expect(response.isBoom).to.be(true);
expect(response.output.statusCode).to.be(500);
sinon.assert.notCalled(h.redirect);
sinon.assert.notCalled(h.authenticated);
});
it('returns wrapped original error when `authenticate` fails to authenticate user', async () => {
const esError = Boom.badRequest('some message');
server.plugins.security.authenticate.withArgs(request).returns(
Promise.resolve(AuthenticationResult.failed(esError))
);
const response = await authenticate(request, h);
sinon.assert.calledWithExactly(
server.log,
['info', 'authentication'],
'Authentication attempt failed: some message'
);
expect(response).to.eql(esError);
sinon.assert.notCalled(h.redirect);
sinon.assert.notCalled(h.authenticated);
});
it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => {
const originalEsError = Boom.unauthorized('some message');
originalEsError.output.headers['WWW-Authenticate'] = [
'Basic realm="Access to prod", charset="UTF-8"',
'Basic',
'Negotiate'
];
server.plugins.security.authenticate.withArgs(request).resolves(
AuthenticationResult.failed(originalEsError, ['Negotiate'])
);
const response = await authenticate(request, h);
sinon.assert.calledWithExactly(
server.log,
['info', 'authentication'],
'Authentication attempt failed: some message'
);
expect(response.message).to.eql(originalEsError.message);
expect(response.output.headers).to.eql({ 'WWW-Authenticate': ['Negotiate'] });
sinon.assert.notCalled(h.redirect);
sinon.assert.notCalled(h.authenticated);
});
it('returns `unauthorized` when authentication can not be handled', async () => {
server.plugins.security.authenticate.withArgs(request).returns(
Promise.resolve(AuthenticationResult.notHandled())
);
const response = await authenticate(request, h);
expect(response.isBoom).to.be(true);
expect(response.message).to.be('Unauthorized');
expect(response.output.statusCode).to.be(401);
sinon.assert.notCalled(h.redirect);
sinon.assert.notCalled(h.authenticated);
});
it('replies with no credentials when security is disabled in elasticsearch', async () => {
server.plugins.xpack_main.info.feature.returns({ isEnabled: sinon.stub().returns(false) });
await authenticate(request, h);
sinon.assert.calledWith(h.authenticated, { credentials: {} });
sinon.assert.notCalled(h.redirect);
});
});

View file

@ -1,78 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import sinon from 'sinon';
import { validateConfig } from '../validate_config';
describe('Validate config', function () {
let config;
const log = sinon.stub();
const validKey = 'd624dce49dafa1401be7f3e1182b756a';
beforeEach(() => {
config = {
get: sinon.stub(),
getDefault: sinon.stub(),
set: sinon.stub(),
};
log.resetHistory();
});
it('should log a warning and set xpack.security.encryptionKey if not set', function () {
config.get.withArgs('server.ssl.key').returns('foo');
config.get.withArgs('server.ssl.certificate').returns('bar');
config.get.withArgs('xpack.security.secureCookies').returns(false);
expect(() => validateConfig(config, log)).not.to.throwError();
sinon.assert.calledWith(config.set, 'xpack.security.encryptionKey');
sinon.assert.calledWith(config.set, 'xpack.security.secureCookies', true);
sinon.assert.calledWithMatch(log, /Generating a random key/);
sinon.assert.calledWithMatch(log, /please set xpack.security.encryptionKey/);
});
it('should throw error if xpack.security.encryptionKey is less than 32 characters', function () {
config.get.withArgs('xpack.security.encryptionKey').returns('foo');
const validateConfigFn = () => validateConfig(config);
expect(validateConfigFn).to.throwException(/xpack.security.encryptionKey must be at least 32 characters/);
});
it('should log a warning if SSL is not configured', function () {
config.get.withArgs('xpack.security.encryptionKey').returns(validKey);
config.get.withArgs('xpack.security.secureCookies').returns(false);
expect(() => validateConfig(config, log)).not.to.throwError();
sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey');
sinon.assert.neverCalledWith(config.set, 'xpack.security.secureCookies');
sinon.assert.calledWithMatch(log, /Session cookies will be transmitted over insecure connections/);
});
it('should log a warning if SSL is not configured yet secure cookies are being used', function () {
config.get.withArgs('xpack.security.encryptionKey').returns(validKey);
config.get.withArgs('xpack.security.secureCookies').returns(true);
expect(() => validateConfig(config, log)).not.to.throwError();
sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey');
sinon.assert.neverCalledWith(config.set, 'xpack.security.secureCookies');
sinon.assert.calledWithMatch(log, /SSL must be configured outside of Kibana/);
});
it('should set xpack.security.secureCookies if SSL is configured', function () {
config.get.withArgs('server.ssl.key').returns('foo');
config.get.withArgs('server.ssl.certificate').returns('bar');
config.get.withArgs('xpack.security.encryptionKey').returns(validKey);
expect(() => validateConfig(config, log)).not.to.throwError();
sinon.assert.neverCalledWith(config.set, 'xpack.security.encryptionKey');
sinon.assert.calledWith(config.set, 'xpack.security.secureCookies', true);
sinon.assert.notCalled(log);
});
});

View file

@ -1,63 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { wrapError } from './errors';
/**
* Creates a hapi authenticate function that conditionally redirects
* on auth failure.
* @param {Hapi.Server} server HapiJS Server instance.
* @returns {Function} Authentication function that will be called by Hapi for every
* request that needs to be authenticated.
*/
export function authenticateFactory(server) {
return async function authenticate(request, h) {
// If security is disabled continue with no user credentials
// and delete the client cookie as well.
const xpackInfo = server.plugins.xpack_main.info;
if (xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) {
return h.authenticated({ credentials: {} });
}
let authenticationResult;
try {
authenticationResult = await server.plugins.security.authenticate(request);
} catch (err) {
server.log(['error', 'authentication'], err);
return wrapError(err);
}
if (authenticationResult.succeeded()) {
return h.authenticated({ credentials: authenticationResult.user });
}
if (authenticationResult.redirected()) {
// Some authentication mechanisms may require user to be redirected to another location to
// initiate or complete authentication flow. It can be Kibana own login page for basic
// authentication (username and password) or arbitrary external page managed by 3rd party
// Identity Provider for SSO authentication mechanisms. Authentication provider is the one who
// decides what location user should be redirected to.
return h.redirect(authenticationResult.redirectURL).takeover();
}
if (authenticationResult.failed()) {
server.log(
['info', 'authentication'],
`Authentication attempt failed: ${authenticationResult.error.message}`
);
const error = wrapError(authenticationResult.error);
if (authenticationResult.challenges) {
error.output.headers['WWW-Authenticate'] = authenticationResult.challenges;
}
return error;
}
return Boom.unauthorized();
};
}

View file

@ -1,573 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import sinon from 'sinon';
import Boom from 'boom';
import { Legacy } from 'kibana';
import { serverFixture } from '../__tests__/__fixtures__/server';
import { requestFixture } from '../__tests__/__fixtures__/request';
import { AuthenticationResult } from './authentication_result';
import { DeauthenticationResult } from './deauthentication_result';
import { Session } from './session';
import { LoginAttempt } from './login_attempt';
import { initAuthenticator } from './authenticator';
import * as ClientShield from '../../../../../server/lib/get_client_shield';
describe('Authenticator', () => {
const sandbox = sinon.createSandbox();
let config: sinon.SinonStubbedInstance<Legacy.KibanaConfig>;
let server: ReturnType<typeof serverFixture>;
let session: sinon.SinonStubbedInstance<Session>;
let cluster: sinon.SinonStubbedInstance<{
callWithRequest: (request: ReturnType<typeof requestFixture>, ...args: any[]) => any;
callWithInternalUser: (...args: any[]) => any;
}>;
beforeEach(() => {
server = serverFixture();
session = sinon.createStubInstance(Session);
config = { get: sinon.stub(), has: sinon.stub() };
// Cluster is returned by `getClient` function that is wrapped into `once` making cluster
// a static singleton, so we should use sandbox to set/reset its behavior between tests.
cluster = sinon.stub({ callWithRequest() {}, callWithInternalUser() {} });
sandbox.stub(ClientShield, 'getClient').returns(cluster);
server.config.returns(config);
server.register.yields();
sandbox
.stub(Session, 'create')
.withArgs(server as any)
.resolves(session as any);
sandbox.useFakeTimers();
});
afterEach(() => sandbox.restore());
describe('initialization', () => {
it('fails if authentication providers are not configured.', async () => {
config.get.withArgs('xpack.security.authc.providers').returns([]);
await expect(initAuthenticator(server as any)).rejects.toThrowError(
'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.'
);
});
it('fails if configured authentication provider is not known.', async () => {
config.get.withArgs('xpack.security.authc.providers').returns(['super-basic']);
await expect(initAuthenticator(server as any)).rejects.toThrowError(
'Unsupported authentication provider name: super-basic.'
);
});
});
describe('`authenticate` method', () => {
let authenticate: (request: ReturnType<typeof requestFixture>) => Promise<AuthenticationResult>;
beforeEach(async () => {
config.get.withArgs('xpack.security.authc.providers').returns(['basic']);
server.plugins.kibana.systemApi.isSystemApiRequest.returns(true);
session.clear.throws(new Error('`Session.clear` is not supposed to be called!'));
await initAuthenticator(server as any);
// Second argument will be a method we'd like to test.
authenticate = server.expose.withArgs('authenticate').firstCall.args[1];
});
it('fails if request is not provided.', async () => {
await expect(authenticate(undefined as any)).rejects.toThrowError(
'Request should be a valid object, was [undefined].'
);
});
it('fails if any authentication providers fail.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic ***' } });
session.get.withArgs(request).resolves(null);
const failureReason = new Error('Not Authorized');
cluster.callWithRequest.withArgs(request).rejects(failureReason);
const authenticationResult = await authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('returns user that authentication provider returns.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic ***' } });
const user = { username: 'user' };
cluster.callWithRequest.withArgs(request).resolves(user);
const authenticationResult = await authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
});
it('creates session whenever authentication provider returns state for system API requests', async () => {
const user = { username: 'user' };
const request = requestFixture();
const loginAttempt = new LoginAttempt();
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
loginAttempt.setCredentials('foo', 'bar');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(true);
cluster.callWithRequest.withArgs(request).resolves(user);
const systemAPIAuthenticationResult = await authenticate(request);
expect(systemAPIAuthenticationResult.succeeded()).toBe(true);
expect(systemAPIAuthenticationResult.user).toEqual(user);
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, request, {
state: { authorization },
provider: 'basic',
});
});
it('creates session whenever authentication provider returns state for non-system API requests', async () => {
const user = { username: 'user' };
const request = requestFixture();
const loginAttempt = new LoginAttempt();
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
loginAttempt.setCredentials('foo', 'bar');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(false);
cluster.callWithRequest.withArgs(request).resolves(user);
const notSystemAPIAuthenticationResult = await authenticate(request);
expect(notSystemAPIAuthenticationResult.succeeded()).toBe(true);
expect(notSystemAPIAuthenticationResult.user).toEqual(user);
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, request, {
state: { authorization },
provider: 'basic',
});
});
it('extends session only for non-system API calls.', async () => {
const user = { username: 'user' };
const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } });
const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } });
session.get.withArgs(systemAPIRequest).resolves({
state: { authorization: 'Basic xxx' },
provider: 'basic',
});
session.get.withArgs(notSystemAPIRequest).resolves({
state: { authorization: 'Basic yyy' },
provider: 'basic',
});
server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest)
.returns(true)
.withArgs(notSystemAPIRequest)
.returns(false);
cluster.callWithRequest
.withArgs(systemAPIRequest)
.resolves(user)
.withArgs(notSystemAPIRequest)
.resolves(user);
const systemAPIAuthenticationResult = await authenticate(systemAPIRequest);
expect(systemAPIAuthenticationResult.succeeded()).toBe(true);
expect(systemAPIAuthenticationResult.user).toEqual(user);
sinon.assert.notCalled(session.set);
const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest);
expect(notSystemAPIAuthenticationResult.succeeded()).toBe(true);
expect(notSystemAPIAuthenticationResult.user).toEqual(user);
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, notSystemAPIRequest, {
state: { authorization: 'Basic yyy' },
provider: 'basic',
});
});
it('does not extend session if authentication fails.', async () => {
const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } });
const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } });
session.get.withArgs(systemAPIRequest).resolves({
state: { authorization: 'Basic xxx' },
provider: 'basic',
});
session.get.withArgs(notSystemAPIRequest).resolves({
state: { authorization: 'Basic yyy' },
provider: 'basic',
});
server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest)
.returns(true)
.withArgs(notSystemAPIRequest)
.returns(false);
cluster.callWithRequest
.withArgs(systemAPIRequest)
.rejects(new Error('some error'))
.withArgs(notSystemAPIRequest)
.rejects(new Error('some error'));
const systemAPIAuthenticationResult = await authenticate(systemAPIRequest);
expect(systemAPIAuthenticationResult.failed()).toBe(true);
const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest);
expect(notSystemAPIAuthenticationResult.failed()).toBe(true);
sinon.assert.notCalled(session.clear);
sinon.assert.notCalled(session.set);
});
it('replaces existing session with the one returned by authentication provider for system API requests', async () => {
const user = { username: 'user' };
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('foo', 'bar');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
session.get.withArgs(request).resolves({
state: { authorization: 'Basic some-old-token' },
provider: 'basic',
});
server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(true);
cluster.callWithRequest.withArgs(request).resolves(user);
const authenticationResult = await authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, request, {
state: { authorization },
provider: 'basic',
});
});
it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => {
const user = { username: 'user' };
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('foo', 'bar');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
session.get.withArgs(request).resolves({
state: { authorization: 'Basic some-old-token' },
provider: 'basic',
});
server.plugins.kibana.systemApi.isSystemApiRequest.withArgs(request).returns(false);
cluster.callWithRequest.withArgs(request).resolves(user);
const authenticationResult = await authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
sinon.assert.calledOnce(session.set);
sinon.assert.calledWithExactly(session.set, request, {
state: { authorization },
provider: 'basic',
});
});
it('clears session if provider failed to authenticate request with 401 with active session.', async () => {
const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } });
const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } });
session.get.withArgs(systemAPIRequest).resolves({
state: { authorization: 'Basic xxx' },
provider: 'basic',
});
session.get.withArgs(notSystemAPIRequest).resolves({
state: { authorization: 'Basic yyy' },
provider: 'basic',
});
session.clear.resolves();
server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest)
.returns(true)
.withArgs(notSystemAPIRequest)
.returns(false);
cluster.callWithRequest
.withArgs(systemAPIRequest)
.rejects(Boom.unauthorized('token expired'))
.withArgs(notSystemAPIRequest)
.rejects(Boom.unauthorized('invalid token'));
const systemAPIAuthenticationResult = await authenticate(systemAPIRequest);
expect(systemAPIAuthenticationResult.failed()).toBe(true);
sinon.assert.calledOnce(session.clear);
sinon.assert.calledWithExactly(session.clear, systemAPIRequest);
const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest);
expect(notSystemAPIAuthenticationResult.failed()).toBe(true);
sinon.assert.calledTwice(session.clear);
sinon.assert.calledWithExactly(session.clear, notSystemAPIRequest);
});
it('clears session if provider requested it via setting state to `null`.', async () => {
// Use `token` provider for this test as it's the only one that does what we want.
config.get.withArgs('xpack.security.authc.providers').returns(['token']);
await initAuthenticator(server as any);
authenticate = server.expose.withArgs('authenticate').lastCall.args[1];
const request = requestFixture({ headers: { xCustomHeader: 'xxx' } });
session.get.withArgs(request).resolves({
state: { accessToken: 'access-xxx', refreshToken: 'refresh-xxx' },
provider: 'token',
});
session.clear.resolves();
cluster.callWithRequest.withArgs(request).rejects({ statusCode: 401 });
cluster.callWithInternalUser
.withArgs('shield.getAccessToken')
.rejects(Boom.badRequest('refresh token expired'));
const authenticationResult = await authenticate(request);
expect(authenticationResult.redirected()).toBe(true);
sinon.assert.calledOnce(session.clear);
sinon.assert.calledWithExactly(session.clear, request);
});
it('does not clear session if provider failed to authenticate request with non-401 reason with active session.', async () => {
const systemAPIRequest = requestFixture({ headers: { xCustomHeader: 'xxx' } });
const notSystemAPIRequest = requestFixture({ headers: { xCustomHeader: 'yyy' } });
session.get.withArgs(systemAPIRequest).resolves({
state: { authorization: 'Basic xxx' },
provider: 'basic',
});
session.get.withArgs(notSystemAPIRequest).resolves({
state: { authorization: 'Basic yyy' },
provider: 'basic',
});
session.clear.resolves();
server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest)
.returns(true)
.withArgs(notSystemAPIRequest)
.returns(false);
cluster.callWithRequest
.withArgs(systemAPIRequest)
.rejects(Boom.badRequest('something went wrong'))
.withArgs(notSystemAPIRequest)
.rejects(new Error('Non boom error'));
const systemAPIAuthenticationResult = await authenticate(systemAPIRequest);
expect(systemAPIAuthenticationResult.failed()).toBe(true);
const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest);
expect(notSystemAPIAuthenticationResult.failed()).toBe(true);
sinon.assert.notCalled(session.clear);
});
it('does not clear session if provider can not handle request authentication with active session.', async () => {
// Add `kbn-xsrf` header to the raw part of the request to make `can_redirect_request`
// think that it's AJAX request and redirect logic shouldn't be triggered.
const systemAPIRequest = requestFixture({
headers: { xCustomHeader: 'xxx', 'kbn-xsrf': 'xsrf' },
});
const notSystemAPIRequest = requestFixture({
headers: { xCustomHeader: 'yyy', 'kbn-xsrf': 'xsrf' },
});
session.get.withArgs(systemAPIRequest).resolves({
state: { authorization: 'Some weird authentication schema...' },
provider: 'basic',
});
session.get.withArgs(notSystemAPIRequest).resolves({
state: { authorization: 'Some weird authentication schema...' },
provider: 'basic',
});
session.clear.resolves();
server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest)
.returns(true)
.withArgs(notSystemAPIRequest)
.returns(false);
const systemAPIAuthenticationResult = await authenticate(systemAPIRequest);
expect(systemAPIAuthenticationResult.failed()).toBe(true);
const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest);
expect(notSystemAPIAuthenticationResult.failed()).toBe(true);
sinon.assert.notCalled(session.clear);
});
it('clears session if it belongs to not configured provider.', async () => {
// Add `kbn-xsrf` header to the raw part of the request to make `can_redirect_request`
// think that it's AJAX request and redirect logic shouldn't be triggered.
const systemAPIRequest = requestFixture({
headers: { xCustomHeader: 'xxx', 'kbn-xsrf': 'xsrf' },
});
const notSystemAPIRequest = requestFixture({
headers: { xCustomHeader: 'yyy', 'kbn-xsrf': 'xsrf' },
});
session.get.withArgs(systemAPIRequest).resolves({
state: { accessToken: 'some old token' },
provider: 'token',
});
session.get.withArgs(notSystemAPIRequest).resolves({
state: { accessToken: 'some old token' },
provider: 'token',
});
session.clear.resolves();
server.plugins.kibana.systemApi.isSystemApiRequest
.withArgs(systemAPIRequest)
.returns(true)
.withArgs(notSystemAPIRequest)
.returns(false);
const systemAPIAuthenticationResult = await authenticate(systemAPIRequest);
expect(systemAPIAuthenticationResult.notHandled()).toBe(true);
sinon.assert.calledOnce(session.clear);
const notSystemAPIAuthenticationResult = await authenticate(notSystemAPIRequest);
expect(notSystemAPIAuthenticationResult.notHandled()).toBe(true);
sinon.assert.calledTwice(session.clear);
});
});
describe('`deauthenticate` method', () => {
let deauthenticate: (
request: ReturnType<typeof requestFixture>
) => Promise<DeauthenticationResult>;
beforeEach(async () => {
config.get.withArgs('xpack.security.authc.providers').returns(['basic']);
config.get.withArgs('server.basePath').returns('/base-path');
await initAuthenticator(server as any);
// Second argument will be a method we'd like to test.
deauthenticate = server.expose.withArgs('deauthenticate').firstCall.args[1];
});
it('fails if request is not provided.', async () => {
await expect(deauthenticate(undefined as any)).rejects.toThrowError(
'Request should be a valid object, was [undefined].'
);
});
it('returns `notHandled` if session does not exist.', async () => {
const request = requestFixture();
session.get.withArgs(request).resolves(null);
const deauthenticationResult = await deauthenticate(request);
expect(deauthenticationResult.notHandled()).toBe(true);
sinon.assert.notCalled(session.clear);
});
it('clears session and returns whatever authentication provider returns.', async () => {
const request = requestFixture({ search: '?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' });
session.get.withArgs(request).resolves({
state: {},
provider: 'basic',
});
const deauthenticationResult = await deauthenticate(request);
sinon.assert.calledOnce(session.clear);
sinon.assert.calledWithExactly(session.clear, request);
expect(deauthenticationResult.redirected()).toBe(true);
expect(deauthenticationResult.redirectURL).toBe(
'/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED'
);
});
it('only clears session if it belongs to not configured provider.', async () => {
const request = requestFixture({ search: '?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' });
session.get.withArgs(request).resolves({
state: {},
provider: 'token',
});
const deauthenticationResult = await deauthenticate(request);
sinon.assert.calledOnce(session.clear);
sinon.assert.calledWithExactly(session.clear, request);
expect(deauthenticationResult.notHandled()).toBe(true);
});
});
describe('`isAuthenticated` method', () => {
let isAuthenticated: (request: ReturnType<typeof requestFixture>) => Promise<boolean>;
beforeEach(async () => {
config.get.withArgs('xpack.security.authc.providers').returns(['basic']);
await initAuthenticator(server as any);
// Second argument will be a method we'd like to test.
isAuthenticated = server.expose.withArgs('isAuthenticated').firstCall.args[1];
});
it('returns `true` if `getUser` succeeds.', async () => {
const request = requestFixture();
server.plugins.security.getUser.withArgs(request).resolves({});
await expect(isAuthenticated(request)).resolves.toBe(true);
});
it('returns `false` when `getUser` throws a 401 boom error.', async () => {
const request = requestFixture();
server.plugins.security.getUser.withArgs(request).rejects(Boom.unauthorized());
await expect(isAuthenticated(request)).resolves.toBe(false);
});
it('throw non-boom errors.', async () => {
const request = requestFixture();
const nonBoomError = new TypeError();
server.plugins.security.getUser.withArgs(request).rejects(nonBoomError);
await expect(isAuthenticated(request)).rejects.toThrowError(nonBoomError);
});
it('throw non-401 boom errors.', async () => {
const request = requestFixture();
const non401Error = Boom.boomify(new TypeError());
server.plugins.security.getUser.withArgs(request).rejects(non401Error);
await expect(isAuthenticated(request)).rejects.toThrowError(non401Error);
});
});
});

View file

@ -1,322 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Legacy } from 'kibana';
import { getClient } from '../../../../../server/lib/get_client_shield';
import { getErrorStatusCode } from '../errors';
import {
AuthenticationProviderOptions,
BaseAuthenticationProvider,
BasicAuthenticationProvider,
KerberosAuthenticationProvider,
RequestWithLoginAttempt,
SAMLAuthenticationProvider,
TokenAuthenticationProvider,
OIDCAuthenticationProvider,
} from './providers';
import { AuthenticationResult } from './authentication_result';
import { DeauthenticationResult } from './deauthentication_result';
import { Session } from './session';
import { LoginAttempt } from './login_attempt';
import { AuthenticationProviderSpecificOptions } from './providers/base';
import { Tokens } from './tokens';
interface ProviderSession {
provider: string;
state: unknown;
}
// Mapping between provider key defined in the config and authentication
// provider class that can handle specific authentication mechanism.
const providerMap = new Map<
string,
new (
options: AuthenticationProviderOptions,
providerSpecificOptions?: AuthenticationProviderSpecificOptions
) => BaseAuthenticationProvider
>([
['basic', BasicAuthenticationProvider],
['kerberos', KerberosAuthenticationProvider],
['saml', SAMLAuthenticationProvider],
['token', TokenAuthenticationProvider],
['oidc', OIDCAuthenticationProvider],
]);
function assertRequest(request: Legacy.Request) {
if (!request || typeof request !== 'object') {
throw new Error(`Request should be a valid object, was [${typeof request}].`);
}
}
/**
* Prepares options object that is shared among all authentication providers.
* @param server Server instance.
*/
function getProviderOptions(server: Legacy.Server) {
const config = server.config();
const client = getClient(server);
const log = server.log.bind(server);
return {
client,
log,
protocol: server.info.protocol,
hostname: config.get<string>('server.host'),
port: config.get<number>('server.port'),
basePath: config.get<string>('server.basePath'),
tokens: new Tokens({ client, log }),
...config.get<any>('xpack.security.public'),
};
}
/**
* Prepares options object that is specific only to an authentication provider.
* @param server Server instance.
* @param providerType the type of the provider to get the options for.
*/
function getProviderSpecificOptions(
server: Legacy.Server,
providerType: string
): AuthenticationProviderSpecificOptions | undefined {
const config = server.config();
const providerOptionsConfigKey = `xpack.security.authc.${providerType}`;
if (config.has(providerOptionsConfigKey)) {
return config.get<AuthenticationProviderSpecificOptions>(providerOptionsConfigKey);
}
}
/**
* Instantiates authentication provider based on the provider key from config.
* @param providerType Provider type key.
* @param options Options to pass to provider's constructor.
* @param providerSpecificOptions Options that are specific to {@param providerType}.
*/
function instantiateProvider(
providerType: string,
options: AuthenticationProviderOptions,
providerSpecificOptions?: AuthenticationProviderSpecificOptions
) {
const ProviderClassName = providerMap.get(providerType);
if (!ProviderClassName) {
throw new Error(`Unsupported authentication provider name: ${providerType}.`);
}
return new ProviderClassName(options, providerSpecificOptions);
}
/**
* Authenticator is responsible for authentication of the request using chain of
* authentication providers. The chain is essentially a prioritized list of configured
* providers (typically of various types). The order of the list determines the order in
* which the providers will be consulted. During the authentication process, Authenticator
* will try to authenticate the request via one provider at a time. Once one of the
* providers successfully authenticates the request, the authentication is considered
* to be successful and the authenticated user will be associated with the request.
* If provider cannot authenticate the request, the next in line provider in the chain
* will be used. If all providers in the chain could not authenticate the request,
* the authentication is then considered to be unsuccessful and an authentication error
* will be returned.
*/
class Authenticator {
/**
* List of configured and instantiated authentication providers.
*/
private readonly providers: Map<string, BaseAuthenticationProvider>;
/**
* Instantiates Authenticator and bootstrap configured providers.
* @param server Server instance.
* @param session Session instance.
*/
constructor(private readonly server: Legacy.Server, private readonly session: Session) {
const config = this.server.config();
const authProviders = config.get<string[]>('xpack.security.authc.providers');
if (authProviders.length === 0) {
throw new Error(
'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.'
);
}
const providerOptions = Object.freeze(getProviderOptions(server));
this.providers = new Map(
authProviders.map(providerType => {
const providerSpecificOptions = getProviderSpecificOptions(server, providerType);
return [
providerType,
instantiateProvider(providerType, providerOptions, providerSpecificOptions),
] as [string, BaseAuthenticationProvider];
})
);
}
/**
* Performs request authentication using configured chain of authentication providers.
* @param request Request instance.
*/
async authenticate(request: RequestWithLoginAttempt) {
assertRequest(request);
const isSystemApiRequest = this.server.plugins.kibana.systemApi.isSystemApiRequest(request);
const existingSession = await this.getSessionValue(request);
let authenticationResult;
for (const [providerType, provider] of this.providerIterator(existingSession)) {
// Check if current session has been set by this provider.
const ownsSession = existingSession && existingSession.provider === providerType;
authenticationResult = await provider.authenticate(
request,
ownsSession ? existingSession!.state : null
);
if (ownsSession || authenticationResult.shouldUpdateState()) {
// If authentication succeeds or requires redirect we should automatically extend existing user session,
// unless authentication has been triggered by a system API request. In case provider explicitly returns new
// state we should store it in the session regardless of whether it's a system API request or not.
const sessionCanBeUpdated =
(authenticationResult.succeeded() || authenticationResult.redirected()) &&
(authenticationResult.shouldUpdateState() || !isSystemApiRequest);
// If provider owned the session, but failed to authenticate anyway, that likely means that
// session is not valid and we should clear it. Also provider can specifically ask to clear
// session by setting it to `null` even if authentication attempt didn't fail.
if (
authenticationResult.shouldClearState() ||
(authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401)
) {
await this.session.clear(request);
} else if (sessionCanBeUpdated) {
await this.session.set(
request,
authenticationResult.shouldUpdateState()
? { state: authenticationResult.state, provider: providerType }
: existingSession
);
}
}
if (authenticationResult.failed()) {
return authenticationResult;
}
if (authenticationResult.succeeded()) {
return AuthenticationResult.succeeded(authenticationResult.user!);
} else if (authenticationResult.redirected()) {
return authenticationResult;
}
}
return authenticationResult;
}
/**
* Deauthenticates current request.
* @param request Request instance.
*/
async deauthenticate(request: RequestWithLoginAttempt) {
assertRequest(request);
const sessionValue = await this.getSessionValue(request);
if (sessionValue) {
await this.session.clear(request);
return this.providers.get(sessionValue.provider)!.deauthenticate(request, sessionValue.state);
}
// Normally when there is no active session in Kibana, `deauthenticate` method shouldn't do anything
// and user will eventually be redirected to the home page to log in. But if SAML is supported there
// is a special case when logout is initiated by the IdP or another SP, then IdP will request _every_
// SP associated with the current user session to do the logout. So if Kibana (without active session)
// receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP
// with correct logout response and only Elasticsearch knows how to do that.
if ((request.query as Record<string, string>).SAMLRequest && this.providers.has('saml')) {
return this.providers.get('saml')!.deauthenticate(request);
}
return DeauthenticationResult.notHandled();
}
/**
* Returns provider iterator where providers are sorted in the order of priority (based on the session ownership).
* @param sessionValue Current session value.
*/
*providerIterator(
sessionValue: ProviderSession | null
): IterableIterator<[string, BaseAuthenticationProvider]> {
// If there is no session to predict which provider to use first, let's use the order
// providers are configured in. Otherwise return provider that owns session first, and only then the rest
// of providers.
if (!sessionValue) {
yield* this.providers;
} else {
yield [sessionValue.provider, this.providers.get(sessionValue.provider)!];
for (const [providerType, provider] of this.providers) {
if (providerType !== sessionValue.provider) {
yield [providerType, provider];
}
}
}
}
/**
* Extracts session value for the specified request. Under the hood it can
* clear session if it belongs to the provider that is not available.
* @param request Request to extract session value for.
*/
private async getSessionValue(request: Legacy.Request) {
let sessionValue = await this.session.get<ProviderSession>(request);
// If for some reason we have a session stored for the provider that is not available
// (e.g. when user was logged in with one provider, but then configuration has changed
// and that provider is no longer available), then we should clear session entirely.
if (sessionValue && !this.providers.has(sessionValue.provider)) {
await this.session.clear(request);
sessionValue = null;
}
return sessionValue;
}
}
export async function initAuthenticator(server: Legacy.Server) {
const session = await Session.create(server);
const authenticator = new Authenticator(server, session);
const loginAttempts = new WeakMap();
server.decorate('request', 'loginAttempt', function(this: Legacy.Request) {
const request = this;
if (!loginAttempts.has(request)) {
loginAttempts.set(request, new LoginAttempt());
}
return loginAttempts.get(request);
});
server.expose('authenticate', (request: RequestWithLoginAttempt) =>
authenticator.authenticate(request)
);
server.expose('deauthenticate', (request: RequestWithLoginAttempt) =>
authenticator.deauthenticate(request)
);
server.expose('isAuthenticated', async (request: Legacy.Request) => {
try {
await server.plugins.security!.getUser(request);
return true;
} catch (err) {
// Don't swallow server errors.
if (!err.isBoom || err.output.statusCode !== 401) {
throw err;
}
}
return false;
});
}

View file

@ -1,38 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { LoginAttempt } from './login_attempt';
describe('LoginAttempt', () => {
describe('getCredentials()', () => {
it('returns null by default', () => {
const attempt = new LoginAttempt();
expect(attempt.getCredentials()).toBe(null);
});
it('returns a credentials object after credentials are set', () => {
const attempt = new LoginAttempt();
attempt.setCredentials('foo', 'bar');
expect(attempt.getCredentials()).toEqual({ username: 'foo', password: 'bar' });
});
});
describe('setCredentials()', () => {
it('sets the credentials for this login attempt', () => {
const attempt = new LoginAttempt();
attempt.setCredentials('foo', 'bar');
expect(attempt.getCredentials()).toEqual({ username: 'foo', password: 'bar' });
});
it('throws if credentials have already been set', () => {
const attempt = new LoginAttempt();
attempt.setCredentials('foo', 'bar');
expect(() => attempt.setCredentials('some', 'some')).toThrowError(
'Credentials for login attempt have already been set'
);
});
});
});

View file

@ -1,42 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* Represents login credentials.
*/
interface LoginCredentials {
username: string;
password: string;
}
/**
* A LoginAttempt represents a single attempt to provide login credentials.
* Once credentials are set, they cannot be changed.
*/
export class LoginAttempt {
/**
* Username and password for login.
*/
private credentials: LoginCredentials | null = null;
/**
* Gets the username and password for this login.
*/
public getCredentials() {
return this.credentials;
}
/**
* Sets the username and password for this login.
*/
public setCredentials(username: string, password: string) {
if (this.credentials) {
throw new Error('Credentials for login attempt have already been set');
}
this.credentials = Object.freeze({ username, password });
}
}

View file

@ -1,27 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { stub, createStubInstance } from 'sinon';
import { Tokens } from '../tokens';
import { AuthenticationProviderOptions } from './base';
export function mockAuthenticationProviderOptions(
providerOptions: Partial<Pick<AuthenticationProviderOptions, 'basePath'>> = {}
) {
const client = { callWithRequest: stub(), callWithInternalUser: stub() };
const log = stub();
return {
hostname: 'test-hostname',
port: 1234,
protocol: 'test-protocol',
client,
log,
basePath: '/base-path',
tokens: createStubInstance(Tokens),
...providerOptions,
};
}

View file

@ -1,67 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Legacy } from 'kibana';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { LoginAttempt } from '../login_attempt';
import { Tokens } from '../tokens';
/**
* Describes a request complemented with `loginAttempt` method.
*/
export interface RequestWithLoginAttempt extends Legacy.Request {
loginAttempt: () => LoginAttempt;
}
/**
* Represents available provider options.
*/
export interface AuthenticationProviderOptions {
protocol: string;
hostname: string;
port: number;
basePath: string;
client: Legacy.Plugins.elasticsearch.Cluster;
log: (tags: string[], message: string) => void;
tokens: PublicMethodsOf<Tokens>;
}
/**
* Represents available provider specific options.
*/
export type AuthenticationProviderSpecificOptions = Record<string, unknown>;
/**
* Base class that all authentication providers should extend.
*/
export abstract class BaseAuthenticationProvider {
/**
* Instantiates AuthenticationProvider.
* @param options Provider options object.
*/
constructor(protected readonly options: Readonly<AuthenticationProviderOptions>) {}
/**
* Performs request authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
abstract authenticate(
request: RequestWithLoginAttempt,
state?: unknown
): Promise<AuthenticationResult>;
/**
* Invalidates user session associated with the request.
* @param request Request instance.
* @param [state] Optional state object associated with the provider that needs to be invalidated.
*/
abstract deauthenticate(
request: Legacy.Request,
state?: unknown
): Promise<DeauthenticationResult>;
}

View file

@ -1,226 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable max-classes-per-file */
import { Legacy } from 'kibana';
import { canRedirectRequest } from '../../can_redirect_request';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base';
/**
* Utility class that knows how to decorate request with proper Basic authentication headers.
*/
export class BasicCredentials {
/**
* Takes provided `username` and `password`, transforms them into proper `Basic ***` authorization
* header and decorates passed request with it.
* @param request Request instance.
* @param username User name.
* @param password User password.
*/
public static decorateRequest<T extends RequestWithLoginAttempt>(
request: T,
username: string,
password: string
) {
const typeOfRequest = typeof request;
if (!request || typeOfRequest !== 'object') {
throw new Error('Request should be a valid object.');
}
if (!username || typeof username !== 'string') {
throw new Error('Username should be a valid non-empty string.');
}
if (!password || typeof password !== 'string') {
throw new Error('Password should be a valid non-empty string.');
}
const basicCredentials = Buffer.from(`${username}:${password}`).toString('base64');
request.headers.authorization = `Basic ${basicCredentials}`;
return request;
}
}
/**
* The state supported by the provider.
*/
interface ProviderState {
/**
* Content of the HTTP authorization header (`Basic base-64-of-username:password`) that is based
* on user credentials used at login time and that should be provided with every request to the
* Elasticsearch on behalf of the authenticated user.
*/
authorization?: string;
}
/**
* Provider that supports request authentication via Basic HTTP Authentication.
*/
export class BasicAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Performs request authentication using Basic HTTP Authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
// first try from login payload
let authenticationResult = await this.authenticateViaLoginAttempt(request);
// if there isn't a payload, try header-based auth
if (authenticationResult.notHandled()) {
const {
authenticationResult: headerAuthResult,
headerNotRecognized,
} = await this.authenticateViaHeader(request);
if (headerNotRecognized) {
return headerAuthResult;
}
authenticationResult = headerAuthResult;
}
if (authenticationResult.notHandled() && state) {
authenticationResult = await this.authenticateViaState(request, state);
} else if (authenticationResult.notHandled() && canRedirectRequest(request)) {
// If we couldn't handle authentication let's redirect user to the login page.
const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`);
authenticationResult = AuthenticationResult.redirectTo(
`${this.options.basePath}/login?next=${nextURL}`
);
}
return authenticationResult;
}
/**
* Redirects user to the login page preserving query string parameters.
* @param request Request instance.
*/
public async deauthenticate(request: Legacy.Request) {
// Query string may contain the path where logout has been called or
// logout reason that login page may need to know.
const queryString = request.url.search || `?msg=LOGGED_OUT`;
return DeauthenticationResult.redirectTo(`${this.options.basePath}/login${queryString}`);
}
/**
* Validates whether request contains a login payload and authenticates the
* user if necessary.
* @param request Request instance.
*/
private async authenticateViaLoginAttempt(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via login attempt.');
const credentials = request.loginAttempt().getCredentials();
if (!credentials) {
this.debug('Username and password not found in payload.');
return AuthenticationResult.notHandled();
}
try {
const { username, password } = credentials;
BasicCredentials.decorateRequest(request, username, password);
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via login attempt.');
return AuthenticationResult.succeeded(user, { authorization: request.headers.authorization });
} catch (err) {
this.debug(`Failed to authenticate request via login attempt: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* Validates whether request contains `Basic ***` Authorization header and just passes it
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization) {
this.debug('Authorization header is not presented.');
return { authenticationResult: AuthenticationResult.notHandled() };
}
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'basic') {
this.debug(`Unsupported authentication schema: ${authenticationSchema}`);
return {
authenticationResult: AuthenticationResult.notHandled(),
headerNotRecognized: true,
};
}
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via header.');
return { authenticationResult: AuthenticationResult.succeeded(user) };
} catch (err) {
this.debug(`Failed to authenticate request via header: ${err.message}`);
return { authenticationResult: AuthenticationResult.failed(err) };
}
}
/**
* Tries to extract authorization header from the state and adds it to the request before
* it's forwarded to Elasticsearch backend.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ authorization }: ProviderState
) {
this.debug('Trying to authenticate via state.');
if (!authorization) {
this.debug('Access token is not found in state.');
return AuthenticationResult.notHandled();
}
request.headers.authorization = authorization;
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user);
} catch (err) {
this.debug(`Failed to authenticate request via state: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to crash if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* Logs message with `debug` level and saml/security related tags.
* @param message Message to log.
*/
private debug(message: string) {
this.options.log(['debug', 'security', 'basic'], message);
}
}

View file

@ -1,443 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import sinon from 'sinon';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../login_attempt';
import { mockAuthenticationProviderOptions } from './base.mock';
import { KerberosAuthenticationProvider } from './kerberos';
describe('KerberosAuthenticationProvider', () => {
let provider: KerberosAuthenticationProvider;
let callWithRequest: sinon.SinonStub;
let callWithInternalUser: sinon.SinonStub;
let tokens: ReturnType<typeof mockAuthenticationProviderOptions>['tokens'];
beforeEach(() => {
const providerOptions = mockAuthenticationProviderOptions();
callWithRequest = providerOptions.client.callWithRequest;
callWithInternalUser = providerOptions.client.callWithInternalUser;
tokens = providerOptions.tokens;
provider = new KerberosAuthenticationProvider(providerOptions);
});
describe('`authenticate` method', () => {
it('does not handle AJAX request that can not be authenticated.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } });
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests with non-empty `loginAttempt`.', async () => {
const request = requestFixture();
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.notCalled(callWithRequest);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests that can be authenticated without `Negotiate` header.', async () => {
const request = requestFixture();
callWithRequest
.withArgs(
sinon.match({
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
}),
'shield.authenticate'
)
.resolves({});
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests if backend does not support Kerberos.', async () => {
const request = requestFixture();
callWithRequest
.withArgs(
sinon.match({
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
}),
'shield.authenticate'
)
.rejects(Boom.unauthorized());
let authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
callWithRequest
.withArgs(request, 'shield.authenticate')
.rejects(Boom.unauthorized(null, 'Basic'));
authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('fails if state is present, but backend does not support Kerberos.', async () => {
const request = requestFixture();
const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' };
callWithRequest.withArgs(sinon.match.any, 'shield.authenticate').rejects(Boom.unauthorized());
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
let authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toBeUndefined();
callWithRequest
.withArgs(sinon.match.any, 'shield.authenticate')
.rejects(Boom.unauthorized(null, 'Basic'));
authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toBeUndefined();
});
it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => {
const request = requestFixture();
callWithRequest
.withArgs(
sinon.match({
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
}),
'shield.authenticate'
)
.rejects(Boom.unauthorized(null, 'Negotiate'));
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
it('fails if request authentication is failed with non-401 error.', async () => {
const request = requestFixture();
callWithRequest
.withArgs(sinon.match.any, 'shield.authenticate')
.rejects(Boom.serverUnavailable());
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 503);
expect(authenticationResult.challenges).toBeUndefined();
});
it('gets a token pair in exchange to SPNEGO one and stores it in the state.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'negotiate spnego' } });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer some-token' } }),
'shield.authenticate'
)
.resolves(user);
callWithInternalUser
.withArgs('shield.getAccessToken')
.resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' });
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.getAccessToken', {
body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' },
});
expect(request.headers.authorization).toBe('Bearer some-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toEqual({
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
});
});
it('fails if could not retrieve an access token in exchange to SPNEGO one.', async () => {
const request = requestFixture({ headers: { authorization: 'negotiate spnego' } });
const failureReason = Boom.unauthorized();
callWithInternalUser.withArgs('shield.getAccessToken').rejects(failureReason);
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.getAccessToken', {
body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' },
});
expect(request.headers.authorization).toBe('negotiate spnego');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.challenges).toBeUndefined();
});
it('fails if could not retrieve user using the new access token.', async () => {
const request = requestFixture({ headers: { authorization: 'negotiate spnego' } });
const failureReason = Boom.unauthorized();
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer some-token' } }),
'shield.authenticate'
)
.rejects(failureReason);
callWithInternalUser
.withArgs('shield.getAccessToken')
.resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' });
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.getAccessToken', {
body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' },
});
expect(request.headers.authorization).toBe('negotiate spnego');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.challenges).toBeUndefined();
});
it('succeeds if state contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture();
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
});
it('succeeds with valid session even if requiring a token refresh', async () => {
const user = { username: 'user' };
const request = requestFixture();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
.rejects(Boom.unauthorized());
tokens.refresh
.withArgs(tokenPair.refreshToken)
.resolves({ accessToken: 'newfoo', refreshToken: 'newbar' });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer newfoo' } }),
'shield.authenticate'
)
.returns(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledTwice(callWithRequest);
sinon.assert.calledOnce(tokens.refresh);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' });
expect(request.headers.authorization).toEqual('Bearer newfoo');
});
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = requestFixture();
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const failureReason = Boom.internal('Token is not valid!');
callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken');
});
it('fails with `Negotiate` challenge if both access and refresh tokens from the state are expired and backend supports Kerberos.', async () => {
const request = requestFixture();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' };
callWithRequest.rejects(Boom.unauthorized(null, 'Negotiate'));
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
it('fails with `Negotiate` challenge if both access and refresh token documents are missing and backend supports Kerberos.', async () => {
const request = requestFixture({ headers: {} });
const tokenPair = { accessToken: 'missing-token', refreshToken: 'missing-refresh-token' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
.rejects({
statusCode: 500,
body: { error: { reason: 'token document is missing and must be present' } },
})
.withArgs(
sinon.match({
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
}),
'shield.authenticate'
)
.rejects(Boom.unauthorized(null, 'Negotiate'));
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
it('succeeds if `authorization` contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } });
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
});
it('fails if token from `authorization` header is rejected.', async () => {
const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } });
const failureReason = { statusCode: 401 };
callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } });
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const failureReason = { statusCode: 401 };
callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason);
callWithRequest
.withArgs(sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }))
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
});
describe('`deauthenticate` method', () => {
it('returns `notHandled` if state is not presented.', async () => {
const request = requestFixture();
let deauthenticateResult = await provider.deauthenticate(request);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, null);
expect(deauthenticateResult.notHandled()).toBe(true);
sinon.assert.notCalled(tokens.invalidate);
});
it('fails if `tokens.invalidate` fails', async () => {
const request = requestFixture();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const failureReason = new Error('failed to delete token');
tokens.invalidate.withArgs(tokenPair).rejects(failureReason);
const authenticationResult = await provider.deauthenticate(request, tokenPair);
sinon.assert.calledOnce(tokens.invalidate);
sinon.assert.calledWithExactly(tokens.invalidate, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to `/logged_out` page if tokens are invalidated successfully.', async () => {
const request = requestFixture();
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
tokens.invalidate.withArgs(tokenPair).resolves();
const authenticationResult = await provider.deauthenticate(request, tokenPair);
sinon.assert.calledOnce(tokens.invalidate);
sinon.assert.calledWithExactly(tokens.invalidate, tokenPair);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
});
});

View file

@ -1,331 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { get } from 'lodash';
import { Legacy } from 'kibana';
import { getErrorStatusCode } from '../../errors';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { Tokens, TokenPair } from '../tokens';
import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base';
/**
* The state supported by the provider.
*/
type ProviderState = TokenPair;
/**
* Parses request's `Authorization` HTTP header if present and extracts authentication scheme.
* @param request Request instance to extract authentication scheme for.
*/
function getRequestAuthenticationScheme(request: RequestWithLoginAttempt) {
const authorization = request.headers.authorization;
if (!authorization) {
return '';
}
return authorization.split(/\s+/)[0].toLowerCase();
}
/**
* Provider that supports Kerberos request authentication.
*/
export class KerberosAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Performs Kerberos request authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
const authenticationScheme = getRequestAuthenticationScheme(request);
if (
authenticationScheme &&
(authenticationScheme !== 'negotiate' && authenticationScheme !== 'bearer')
) {
this.debug(`Unsupported authentication scheme: ${authenticationScheme}`);
return AuthenticationResult.notHandled();
}
if (request.loginAttempt().getCredentials() != null) {
this.debug('Login attempt is detected, but it is not supported by the provider');
return AuthenticationResult.notHandled();
}
let authenticationResult = AuthenticationResult.notHandled();
if (authenticationScheme) {
// We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore.
authenticationResult =
authenticationScheme === 'bearer'
? await this.authenticateWithBearerScheme(request)
: await this.authenticateWithNegotiateScheme(request);
}
if (state && authenticationResult.notHandled()) {
authenticationResult = await this.authenticateViaState(request, state);
if (
authenticationResult.failed() &&
Tokens.isAccessTokenExpiredError(authenticationResult.error)
) {
authenticationResult = await this.authenticateViaRefreshToken(request, state);
}
}
// If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can
// start authentication mechanism negotiation, otherwise just return authentication result we have.
return authenticationResult.notHandled()
? await this.authenticateViaSPNEGO(request, state)
: authenticationResult;
}
/**
* Invalidates access token retrieved in exchange for SPNEGO token if it exists.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
public async deauthenticate(request: Legacy.Request, state?: ProviderState | null) {
this.debug(`Trying to deauthenticate user via ${request.url.path}.`);
if (!state) {
this.debug('There is no access token invalidate.');
return DeauthenticationResult.notHandled();
}
try {
await this.options.tokens.invalidate(state);
} catch (err) {
this.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`);
return DeauthenticationResult.failed(err);
}
return DeauthenticationResult.redirectTo('/logged_out');
}
/**
* Tries to authenticate request with `Negotiate ***` Authorization header by passing it to the Elasticsearch backend to
* get an access token in exchange.
* @param request Request instance.
*/
private async authenticateWithNegotiateScheme(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate request using "Negotiate" authentication scheme.');
const [, kerberosTicket] = request.headers.authorization.split(/\s+/);
// First attempt to exchange SPNEGO token for an access token.
let tokens: { access_token: string; refresh_token: string };
try {
tokens = await this.options.client.callWithInternalUser('shield.getAccessToken', {
body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket },
});
} catch (err) {
this.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`);
return AuthenticationResult.failed(err);
}
this.debug('Get token API request to Elasticsearch successful');
// Then attempt to query for the user details using the new token
const originalAuthorizationHeader = request.headers.authorization;
request.headers.authorization = `Bearer ${tokens.access_token}`;
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('User has been authenticated with new access token');
return AuthenticationResult.succeeded(user, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
});
} catch (err) {
this.debug(`Failed to authenticate request via access token: ${err.message}`);
// Restore `Authorization` header we've just set. We can end up here only if newly generated
// access token was rejected by Elasticsearch for some reason and it doesn't make any sense to
// keep it in the request object since it can confuse other consumers of the request down the
// line (e.g. in the next authentication provider).
request.headers.authorization = originalAuthorizationHeader;
return AuthenticationResult.failed(err);
}
}
/**
* Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateWithBearerScheme(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate request using "Bearer" authentication scheme.');
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated using "Bearer" authentication scheme.');
return AuthenticationResult.succeeded(user);
} catch (err) {
this.debug(
`Failed to authenticate request using "Bearer" authentication scheme: ${err.message}`
);
return AuthenticationResult.failed(err);
}
}
/**
* Tries to extract access token from state and adds it to the request before it's
* forwarded to Elasticsearch backend.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ accessToken }: ProviderState
) {
this.debug('Trying to authenticate via state.');
if (!accessToken) {
this.debug('Access token is not found in state.');
return AuthenticationResult.notHandled();
}
request.headers.authorization = `Bearer ${accessToken}`;
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user);
} catch (err) {
this.debug(`Failed to authenticate request via state: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* This method is only called when authentication via access token stored in the state failed because of expired
* token. So we should use refresh token, that is also stored in the state, to extend expired access token and
* authenticate user with it.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: RequestWithLoginAttempt,
state: ProviderState
) {
this.debug('Trying to refresh access token.');
let refreshedTokenPair: TokenPair | null;
try {
refreshedTokenPair = await this.options.tokens.refresh(state.refreshToken);
} catch (err) {
return AuthenticationResult.failed(err);
}
// If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO.
if (refreshedTokenPair === null) {
this.debug('Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.');
return this.authenticateViaSPNEGO(request, state);
}
try {
request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`;
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, refreshedTokenPair);
} catch (err) {
this.debug(`Failed to authenticate user using newly refreshed access token: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* Tries to query Elasticsearch and see if we can rely on SPNEGO to authenticate user.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
private async authenticateViaSPNEGO(
request: RequestWithLoginAttempt,
state?: ProviderState | null
) {
this.debug('Trying to authenticate request via SPNEGO.');
// Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO.
let authenticationError: Error;
try {
await this.options.client.callWithRequest(
{
headers: {
...request.headers,
// We should send a fake SPNEGO token to Elasticsearch to make sure Kerberos realm is included
// into authentication chain and adds a `WWW-Authenticate: Negotiate` header to the error
// response. Otherwise it may not be even consulted if request can be authenticated by other
// means (e.g. when anonymous access is enabled in Elasticsearch).
authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}`,
},
},
'shield.authenticate'
);
this.debug('Request was not supposed to be authenticated, ignoring result.');
return AuthenticationResult.notHandled();
} catch (err) {
// Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch
// session cookie in this case.
if (getErrorStatusCode(err) !== 401) {
return AuthenticationResult.failed(err);
}
authenticationError = err;
}
const challenges = ([] as string[]).concat(
get<string | string[]>(authenticationError, 'output.headers[WWW-Authenticate]') || ''
);
if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) {
this.debug(`SPNEGO is supported by the backend, challenges are: [${challenges}].`);
return AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']);
}
this.debug(`SPNEGO is not supported by the backend, challenges are: [${challenges}].`);
// If we failed to do SPNEGO and have a session with expired token that belongs to Kerberos
// authentication provider then it means Elasticsearch isn't configured to use Kerberos anymore.
// In this case we should reply with the `401` error and allow Authenticator to clear the cookie.
// Otherwise give a chance to the next authentication provider to authenticate request.
return state
? AuthenticationResult.failed(Boom.unauthorized())
: AuthenticationResult.notHandled();
}
/**
* Logs message with `debug` level and kerberos/security related tags.
* @param message Message to log.
*/
private debug(message: string) {
this.options.log(['debug', 'security', 'kerberos'], message);
}
}

View file

@ -1,913 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import sinon from 'sinon';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../login_attempt';
import { mockAuthenticationProviderOptions } from './base.mock';
import { SAMLAuthenticationProvider } from './saml';
describe('SAMLAuthenticationProvider', () => {
let provider: SAMLAuthenticationProvider;
let callWithRequest: sinon.SinonStub;
let callWithInternalUser: sinon.SinonStub;
let tokens: ReturnType<typeof mockAuthenticationProviderOptions>['tokens'];
beforeEach(() => {
const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' });
callWithRequest = providerOptions.client.callWithRequest;
callWithInternalUser = providerOptions.client.callWithInternalUser;
tokens = providerOptions.tokens;
provider = new SAMLAuthenticationProvider(providerOptions);
});
describe('`authenticate` method', () => {
it('does not handle AJAX request that can not be authenticated.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests with non-empty `loginAttempt`.', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
expect(authenticationResult.notHandled()).toBe(true);
});
it('redirects non-AJAX request that can not be authenticated to the IdP.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
callWithInternalUser.withArgs('shield.samlPrepare').resolves({
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
});
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', {
body: { acs: `test-protocol://test-hostname:1234/test-base-path/api/security/v1/saml` },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://idp-host/path/login?SAMLRequest=some%20request%20'
);
expect(authenticationResult.state).toEqual({
requestId: 'some-request-id',
nextURL: `/s/foo/some-path`,
});
});
it('uses `realm` name instead of `acs` if it is specified for SAML prepare request.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
// Create new provider instance with additional `realm` option.
const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' });
callWithRequest = providerOptions.client.callWithRequest as sinon.SinonStub;
callWithInternalUser = providerOptions.client.callWithInternalUser as sinon.SinonStub;
provider = new SAMLAuthenticationProvider(providerOptions, { realm: 'test-realm' });
callWithInternalUser.withArgs('shield.samlPrepare').resolves({
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
});
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', {
body: { realm: 'test-realm' },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://idp-host/path/login?SAMLRequest=some%20request%20'
);
expect(authenticationResult.state).toEqual({
requestId: 'some-request-id',
nextURL: `/s/foo/some-path`,
});
});
it('fails if SAML request preparation fails.', async () => {
const request = requestFixture({ path: '/some-path' });
const failureReason = new Error('Realm is misconfigured!');
callWithInternalUser.withArgs('shield.samlPrepare').rejects(failureReason);
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', {
body: { acs: `test-protocol://test-hostname:1234/test-base-path/api/security/v1/saml` },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('gets token and redirects user to requested URL if SAML Response is valid.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
callWithInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' });
const authenticationResult = await provider.authenticate(request, {
requestId: 'some-request-id',
nextURL: '/test-base-path/some-path',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: ['some-request-id'], content: 'saml-response-xml' },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path');
expect(authenticationResult.state).toEqual({
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
});
});
it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
const authenticationResult = await provider.authenticate(request, {
nextURL: '/test-base-path/some-path',
} as any);
sinon.assert.notCalled(callWithInternalUser);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toEqual(
Boom.badRequest(
'SAML response state does not have corresponding request id or redirect URL.'
)
);
});
it('fails if SAML Response payload is presented but state does not contain redirect URL.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
const authenticationResult = await provider.authenticate(request, {
requestId: 'some-request-id',
} as any);
sinon.assert.notCalled(callWithInternalUser);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toEqual(
Boom.badRequest(
'SAML response state does not have corresponding request id or redirect URL.'
)
);
});
it('redirects to the default location if state is not presented.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
callWithInternalUser.withArgs('shield.samlAuthenticate').resolves({
access_token: 'idp-initiated-login-token',
refresh_token: 'idp-initiated-login-refresh-token',
});
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: [], content: 'saml-response-xml' },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/');
expect(authenticationResult.state).toEqual({
accessToken: 'idp-initiated-login-token',
refreshToken: 'idp-initiated-login-refresh-token',
});
});
it('fails if SAML Response is rejected.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
const failureReason = new Error('SAML response is stale!');
callWithInternalUser.withArgs('shield.samlAuthenticate').rejects(failureReason);
const authenticationResult = await provider.authenticate(request, {
requestId: 'some-request-id',
nextURL: '/test-base-path/some-path',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: ['some-request-id'], content: 'saml-response-xml' },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('succeeds if state contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture();
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
});
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = requestFixture();
const failureReason = { statusCode: 500, message: 'Token is not valid!' };
callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-invalid-token',
refreshToken: 'some-invalid-refresh-token',
});
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken');
});
it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => {
const user = { username: 'user' };
const request = requestFixture();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer new-access-token' } }),
'shield.authenticate'
)
.resolves(user);
tokens.refresh
.withArgs(tokenPair.refreshToken)
.resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' });
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers.authorization).toBe('Bearer new-access-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toEqual({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
});
it('fails if token from the state is expired and refresh attempt failed with unknown reason too.', async () => {
const request = requestFixture();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
const refreshFailureReason = {
statusCode: 500,
message: 'Something is wrong with refresh token.',
};
tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(refreshFailureReason);
});
it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toEqual(
Boom.badRequest('Both access and refresh tokens are expired.')
);
});
it('initiates SAML handshake for non-AJAX requests if access token document is missing.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
callWithInternalUser.withArgs('shield.samlPrepare').resolves({
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
});
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({
statusCode: 500,
body: { error: { reason: 'token document is missing and must be present' } },
});
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', {
body: { acs: `test-protocol://test-hostname:1234/test-base-path/api/security/v1/saml` },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://idp-host/path/login?SAMLRequest=some%20request%20'
);
expect(authenticationResult.state).toEqual({
requestId: 'some-request-id',
nextURL: `/s/foo/some-path`,
});
});
it('initiates SAML handshake for non-AJAX requests if refresh token is expired.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
callWithInternalUser.withArgs('shield.samlPrepare').resolves({
id: 'some-request-id',
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
});
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer expired-token' } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlPrepare', {
body: { acs: `test-protocol://test-hostname:1234/test-base-path/api/security/v1/saml` },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://idp-host/path/login?SAMLRequest=some%20request%20'
);
expect(authenticationResult.state).toEqual({
requestId: 'some-request-id',
nextURL: `/s/foo/some-path`,
});
});
it('succeeds if `authorization` contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } });
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
});
it('fails if token from `authorization` header is rejected.', async () => {
const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } });
const failureReason = { statusCode: 401 };
callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } });
const failureReason = { statusCode: 401 };
callWithRequest.withArgs(request, 'shield.authenticate').rejects(failureReason);
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }))
.resolves(user);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
describe('IdP initiated login with existing session', () => {
it('fails if new SAML Response is rejected.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
const user = { username: 'user' };
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const failureReason = new Error('SAML response is invalid!');
callWithInternalUser.withArgs('shield.samlAuthenticate').rejects(failureReason);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: [], content: 'saml-response-xml' },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('fails if token received in exchange to new SAML Response is rejected.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
// Call to `authenticate` using existing valid session.
const user = { username: 'user' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer existing-valid-token' } }),
'shield.authenticate'
)
.resolves(user);
// Call to `authenticate` with token received in exchange to new SAML payload.
const failureReason = new Error('Access token is invalid!');
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer new-invalid-token' } }),
'shield.authenticate'
)
.rejects(failureReason);
callWithInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-invalid-token', refresh_token: 'new-invalid-token' });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: [], content: 'saml-response-xml' },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('fails if fails to invalidate existing access/refresh tokens.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
const tokenPair = {
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
const user = { username: 'user' };
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
callWithInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' });
const failureReason = new Error('Failed to invalidate token!');
tokens.invalidate.withArgs(tokenPair).rejects(failureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: [], content: 'saml-response-xml' },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to the home page if new SAML Response is for the same user.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
const tokenPair = {
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
const user = { username: 'user', authentication_realm: { name: 'saml1' } };
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
callWithInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' });
tokens.invalidate.withArgs(tokenPair).resolves();
const authenticationResult = await provider.authenticate(request, {
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: [], content: 'saml-response-xml' },
});
sinon.assert.calledOnce(tokens.invalidate);
sinon.assert.calledWithExactly(tokens.invalidate, tokenPair);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/');
});
it('redirects to `overwritten_session` if new SAML Response is for the another user.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
const tokenPair = {
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
.resolves(existingUser);
const newUser = { username: 'new-user', authentication_realm: { name: 'saml1' } };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer new-valid-token' } }),
'shield.authenticate'
)
.resolves(newUser);
callWithInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' });
tokens.invalidate.withArgs(tokenPair).resolves();
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: [], content: 'saml-response-xml' },
});
sinon.assert.calledOnce(tokens.invalidate);
sinon.assert.calledWithExactly(tokens.invalidate, tokenPair);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/overwritten_session');
});
it('redirects to `overwritten_session` if new SAML Response is for another realm.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });
const tokenPair = {
accessToken: 'existing-valid-token',
refreshToken: 'existing-valid-refresh-token',
};
const existingUser = { username: 'user', authentication_realm: { name: 'saml1' } };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer existing-valid-token' } }),
'shield.authenticate'
)
.resolves(existingUser);
const newUser = { username: 'user', authentication_realm: { name: 'saml2' } };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer new-valid-token' } }),
'shield.authenticate'
)
.resolves(newUser);
callWithInternalUser
.withArgs('shield.samlAuthenticate')
.resolves({ access_token: 'new-valid-token', refresh_token: 'new-valid-refresh-token' });
tokens.invalidate.withArgs(tokenPair).resolves();
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlAuthenticate', {
body: { ids: [], content: 'saml-response-xml' },
});
sinon.assert.calledOnce(tokens.invalidate);
sinon.assert.calledWithExactly(tokens.invalidate, tokenPair);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/overwritten_session');
});
});
});
describe('`deauthenticate` method', () => {
it('returns `notHandled` if state is not presented or does not include access token.', async () => {
const request = requestFixture();
let deauthenticateResult = await provider.deauthenticate(request);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, {} as any);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, { somethingElse: 'x' } as any);
expect(deauthenticateResult.notHandled()).toBe(true);
sinon.assert.notCalled(callWithInternalUser);
});
it('fails if SAML logout call fails.', async () => {
const request = requestFixture();
const accessToken = 'x-saml-token';
const refreshToken = 'x-saml-refresh-token';
const failureReason = new Error('Realm is misconfigured!');
callWithInternalUser.withArgs('shield.samlLogout').rejects(failureReason);
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('fails if SAML invalidate call fails.', async () => {
const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' });
const failureReason = new Error('Realm is misconfigured!');
callWithInternalUser.withArgs('shield.samlInvalidate').rejects(failureReason);
const authenticationResult = await provider.deauthenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', {
body: {
queryString: 'SAMLRequest=xxx%20yyy',
acs: 'test-protocol://test-hostname:1234/test-base-path/api/security/v1/saml',
},
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to /logged_out if `redirect` field in SAML logout response is null.', async () => {
const request = requestFixture();
const accessToken = 'x-saml-token';
const refreshToken = 'x-saml-refresh-token';
callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: null });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
it('redirects to /logged_out if `redirect` field in SAML logout response is not defined.', async () => {
const request = requestFixture();
const accessToken = 'x-saml-token';
const refreshToken = 'x-saml-refresh-token';
callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: undefined });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
it('relies on SAML logout if query string is not empty, but does not include SAMLRequest.', async () => {
const request = requestFixture({ search: '?Whatever=something%20unrelated' });
const accessToken = 'x-saml-token';
const refreshToken = 'x-saml-refresh-token';
callWithInternalUser.withArgs('shield.samlLogout').resolves({ redirect: null });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
it('relies on SAML invalidate call even if access token is presented.', async () => {
const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' });
callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: null });
const authenticationResult = await provider.deauthenticate(request, {
accessToken: 'x-saml-token',
refreshToken: 'x-saml-refresh-token',
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', {
body: {
queryString: 'SAMLRequest=xxx%20yyy',
acs: 'test-protocol://test-hostname:1234/test-base-path/api/security/v1/saml',
},
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
it('redirects to /logged_out if `redirect` field in SAML invalidate response is null.', async () => {
const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' });
callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: null });
const authenticationResult = await provider.deauthenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', {
body: {
queryString: 'SAMLRequest=xxx%20yyy',
acs: 'test-protocol://test-hostname:1234/test-base-path/api/security/v1/saml',
},
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
it('uses `realm` name instead of `acs` if it is specified for SAML invalidate request.', async () => {
const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' });
// Create new provider instance with additional `realm` option.
const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' });
callWithRequest = providerOptions.client.callWithRequest as sinon.SinonStub;
callWithInternalUser = providerOptions.client.callWithInternalUser as sinon.SinonStub;
provider = new SAMLAuthenticationProvider(providerOptions, { realm: 'test-realm' });
callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: undefined });
const authenticationResult = await provider.deauthenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', {
body: { queryString: 'SAMLRequest=xxx%20yyy', realm: 'test-realm' },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
it('redirects to /logged_out if `redirect` field in SAML invalidate response is not defined.', async () => {
const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' });
callWithInternalUser.withArgs('shield.samlInvalidate').resolves({ redirect: undefined });
const authenticationResult = await provider.deauthenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.samlInvalidate', {
body: {
queryString: 'SAMLRequest=xxx%20yyy',
acs: 'test-protocol://test-hostname:1234/test-base-path/api/security/v1/saml',
},
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
it('redirects user to the IdP if SLO is supported by IdP in case of SP initiated logout.', async () => {
const request = requestFixture();
const accessToken = 'x-saml-token';
const refreshToken = 'x-saml-refresh-token';
callWithInternalUser
.withArgs('shield.samlLogout')
.resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' });
const authenticationResult = await provider.deauthenticate(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H');
});
it('redirects user to the IdP if SLO is supported by IdP in case of IdP initiated logout.', async () => {
const request = requestFixture({ search: '?SAMLRequest=xxx%20yyy' });
callWithInternalUser
.withArgs('shield.samlInvalidate')
.resolves({ redirect: 'http://fake-idp/SLO?SAMLRequest=7zlH37H' });
const authenticationResult = await provider.deauthenticate(request, {
accessToken: 'x-saml-token',
refreshToken: 'x-saml-refresh-token',
});
sinon.assert.calledOnce(callWithInternalUser);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('http://fake-idp/SLO?SAMLRequest=7zlH37H');
});
});
});

View file

@ -1,278 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { Legacy } from 'kibana';
import { canRedirectRequest } from '../../can_redirect_request';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { Tokens, TokenPair } from '../tokens';
import { BaseAuthenticationProvider, RequestWithLoginAttempt } from './base';
/**
* The state supported by the provider.
*/
type ProviderState = TokenPair;
/**
* Provider that supports token-based request authentication.
*/
export class TokenAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Performs token-based request authentication
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
// first try from login payload
let authenticationResult = await this.authenticateViaLoginAttempt(request);
// if there isn't a payload, try header-based token auth
if (authenticationResult.notHandled()) {
const {
authenticationResult: headerAuthResult,
headerNotRecognized,
} = await this.authenticateViaHeader(request);
if (headerNotRecognized) {
return headerAuthResult;
}
authenticationResult = headerAuthResult;
}
// if we still can't attempt auth, try authenticating via state (session token)
if (authenticationResult.notHandled() && state) {
authenticationResult = await this.authenticateViaState(request, state);
if (
authenticationResult.failed() &&
Tokens.isAccessTokenExpiredError(authenticationResult.error)
) {
authenticationResult = await this.authenticateViaRefreshToken(request, state);
}
}
// finally, if authentication still can not be handled for this
// request/state combination, redirect to the login page if appropriate
if (authenticationResult.notHandled() && canRedirectRequest(request)) {
authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request));
}
return authenticationResult;
}
/**
* Redirects user to the login page preserving query string parameters.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
public async deauthenticate(request: Legacy.Request, state?: ProviderState | null) {
this.debug(`Trying to deauthenticate user via ${request.url.path}.`);
if (!state) {
this.debug('There are no access and refresh tokens to invalidate.');
return DeauthenticationResult.notHandled();
}
this.debug('Token-based logout has been initiated by the user.');
try {
await this.options.tokens.invalidate(state);
} catch (err) {
this.debug(`Failed invalidating user's access token: ${err.message}`);
return DeauthenticationResult.failed(err);
}
const queryString = request.url.search || `?msg=LOGGED_OUT`;
return DeauthenticationResult.redirectTo(`${this.options.basePath}/login${queryString}`);
}
/**
* Validates whether request contains `Bearer ***` Authorization header and just passes it
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization) {
this.debug('Authorization header is not presented.');
return { authenticationResult: AuthenticationResult.notHandled() };
}
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'bearer') {
this.debug(`Unsupported authentication schema: ${authenticationSchema}`);
return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true };
}
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via header.');
// We intentionally do not store anything in session state because token
// header auth can only be used on a request by request basis.
return { authenticationResult: AuthenticationResult.succeeded(user) };
} catch (err) {
this.debug(`Failed to authenticate request via header: ${err.message}`);
return { authenticationResult: AuthenticationResult.failed(err) };
}
}
/**
* Validates whether request contains a login payload and authenticates the
* user if necessary.
* @param request Request instance.
*/
private async authenticateViaLoginAttempt(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via login attempt.');
const credentials = request.loginAttempt().getCredentials();
if (!credentials) {
this.debug('Username and password not found in payload.');
return AuthenticationResult.notHandled();
}
try {
// First attempt to exchange login credentials for an access token
const { username, password } = credentials;
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callWithInternalUser('shield.getAccessToken', {
body: { grant_type: 'password', username, password },
});
this.debug('Get token API request to Elasticsearch successful');
// Then attempt to query for the user details using the new token
request.headers.authorization = `Bearer ${accessToken}`;
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('User has been authenticated with new access token');
return AuthenticationResult.succeeded(user, { accessToken, refreshToken });
} catch (err) {
this.debug(`Failed to authenticate request via login attempt: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* Tries to extract authorization header from the state and adds it to the request before
* it's forwarded to Elasticsearch backend.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ accessToken }: ProviderState
) {
this.debug('Trying to authenticate via state.');
try {
request.headers.authorization = `Bearer ${accessToken}`;
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user);
} catch (err) {
this.debug(`Failed to authenticate request via state: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to crash if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* This method is only called when authentication via access token stored in the state failed because of expired
* token. So we should use refresh token, that is also stored in the state, to extend expired access token and
* authenticate user with it.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: RequestWithLoginAttempt,
{ refreshToken }: ProviderState
) {
this.debug('Trying to refresh access token.');
let refreshedTokenPair: TokenPair | null;
try {
refreshedTokenPair = await this.options.tokens.refresh(refreshToken);
} catch (err) {
return AuthenticationResult.failed(err);
}
// If refresh token is no longer valid, then we should clear session and redirect user to the
// login page to re-authenticate, or fail if redirect isn't possible.
if (refreshedTokenPair === null) {
if (canRedirectRequest(request)) {
this.debug('Clearing session since both access and refresh tokens are expired.');
// Set state to `null` to let `Authenticator` know that we want to clear current session.
return AuthenticationResult.redirectTo(this.getLoginPageURL(request), null);
}
return AuthenticationResult.failed(
Boom.badRequest('Both access and refresh tokens are expired.')
);
}
try {
request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`;
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, refreshedTokenPair);
} catch (err) {
this.debug(`Failed to authenticate user using newly refreshed access token: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
return AuthenticationResult.failed(err);
}
}
/**
* Constructs login page URL using current url path as `next` query string parameter.
* @param request Request instance.
*/
private getLoginPageURL(request: RequestWithLoginAttempt) {
const nextURL = encodeURIComponent(`${request.getBasePath()}${request.url.path}`);
return `${this.options.basePath}/login?next=${nextURL}`;
}
/**
* Logs message with `debug` level and token/security related tags.
* @param message Message to log.
*/
private debug(message: string) {
this.options.log(['debug', 'security', 'token'], message);
}
}

View file

@ -1,191 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import sinon from 'sinon';
import { requestFixture } from '../__tests__/__fixtures__/request';
import { serverFixture } from '../__tests__/__fixtures__/server';
import { Session } from './session';
describe('Session', () => {
const sandbox = sinon.createSandbox();
let server: ReturnType<typeof serverFixture>;
let config: { get: sinon.SinonStub };
beforeEach(() => {
server = serverFixture();
config = { get: sinon.stub() };
server.config.returns(config);
sandbox.useFakeTimers();
});
afterEach(() => {
sandbox.restore();
});
describe('constructor', () => {
it('correctly setups Hapi plugin.', async () => {
config.get.withArgs('xpack.security.cookieName').returns('cookie-name');
config.get.withArgs('xpack.security.encryptionKey').returns('encryption-key');
config.get.withArgs('xpack.security.secureCookies').returns('secure-cookies');
config.get.withArgs('server.basePath').returns('base/path');
await Session.create(server as any);
sinon.assert.calledOnce(server.auth.strategy);
sinon.assert.calledWithExactly(server.auth.strategy, 'security-cookie', 'cookie', {
cookie: 'cookie-name',
password: 'encryption-key',
clearInvalid: true,
validateFunc: sinon.match.func,
isHttpOnly: true,
isSecure: 'secure-cookies',
isSameSite: false,
path: 'base/path/',
});
});
});
describe('`get` method', () => {
let session: Session;
beforeEach(async () => {
session = await Session.create(server as any);
});
it('fails if request is not provided.', async () => {
await expect(session.get(undefined as any)).rejects.toThrowError(
'Request should be a valid object, was [undefined].'
);
});
it('logs the reason of validation function failure.', async () => {
const request = requestFixture();
const failureReason = new Error('Invalid cookie.');
server.auth.test.withArgs('security-cookie', request).rejects(failureReason);
await expect(session.get(request)).resolves.toBeNull();
sinon.assert.calledOnce(server.log);
sinon.assert.calledWithExactly(
server.log,
['debug', 'security', 'auth', 'session'],
failureReason
);
});
it('returns session if single session cookie is in an array.', async () => {
const request = requestFixture();
const sessionValue = { token: 'token' };
const sessions = [{ value: sessionValue }];
server.auth.test.withArgs('security-cookie', request).resolves(sessions);
await expect(session.get(request)).resolves.toBe(sessionValue);
});
it('returns null if multiple session cookies are detected.', async () => {
const request = requestFixture();
const sessions = [{ value: { token: 'token' } }, { value: { token: 'token' } }];
server.auth.test.withArgs('security-cookie', request).resolves(sessions);
await expect(session.get(request)).resolves.toBeNull();
});
it('returns what validation function returns', async () => {
const request = requestFixture();
const rawSessionValue = { value: { token: 'token' } };
server.auth.test.withArgs('security-cookie', request).resolves(rawSessionValue);
await expect(session.get(request)).resolves.toEqual(rawSessionValue.value);
});
it('correctly process session expiration date', async () => {
const { validateFunc } = server.auth.strategy.firstCall.args[2];
const currentTime = 100;
sandbox.clock.tick(currentTime);
const sessionWithoutExpires = { token: 'token' };
let result = validateFunc({}, sessionWithoutExpires);
expect(result.valid).toBe(true);
const notExpiredSession = { token: 'token', expires: currentTime + 1 };
result = validateFunc({}, notExpiredSession);
expect(result.valid).toBe(true);
const expiredSession = { token: 'token', expires: currentTime - 1 };
result = validateFunc({}, expiredSession);
expect(result.valid).toBe(false);
});
});
describe('`set` method', () => {
let session: Session;
beforeEach(async () => {
session = await Session.create(server as any);
});
it('fails if request is not provided.', async () => {
await expect(session.set(undefined as any, undefined as any)).rejects.toThrowError(
'Request should be a valid object, was [undefined].'
);
});
it('does not set expires if corresponding config value is not specified.', async () => {
const sessionValue = { token: 'token' };
const request = requestFixture();
await session.set(request, sessionValue);
sinon.assert.calledOnce(request.cookieAuth.set);
sinon.assert.calledWithExactly(request.cookieAuth.set, {
value: sessionValue,
expires: undefined,
});
});
it('sets expires based on corresponding config value.', async () => {
const sessionValue = { token: 'token' };
const request = requestFixture();
config.get.withArgs('xpack.security.sessionTimeout').returns(100);
sandbox.clock.tick(1000);
const sessionWithTimeout = await Session.create(server as any);
await sessionWithTimeout.set(request, sessionValue);
sinon.assert.calledOnce(request.cookieAuth.set);
sinon.assert.calledWithExactly(request.cookieAuth.set, {
value: sessionValue,
expires: 1100,
});
});
});
describe('`clear` method', () => {
let session: Session;
beforeEach(async () => {
session = await Session.create(server as any);
});
it('fails if request is not provided.', async () => {
await expect(session.clear(undefined as any)).rejects.toThrowError(
'Request should be a valid object, was [undefined].'
);
});
it('correctly clears cookie', async () => {
const request = requestFixture();
await session.clear(request);
sinon.assert.calledOnce(request.cookieAuth.clear);
});
});
});

View file

@ -1,163 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import hapiAuthCookie from 'hapi-auth-cookie';
import { Legacy } from 'kibana';
const HAPI_STRATEGY_NAME = 'security-cookie';
// Forbid applying of Hapi authentication strategies to routes automatically.
const HAPI_STRATEGY_MODE = false;
/**
* The shape of the session that is actually stored in the cookie.
*/
interface InternalSession {
/**
* Session value that is fed to the authentication provider. The shape is unknown upfront and
* entirely determined by the authentication provider that owns the current session.
*/
value: unknown;
/**
* The Unix time in ms when the session should be considered expired. If `null`, session will stay
* active until the browser is closed.
*/
expires: number | null;
}
function assertRequest(request: Legacy.Request) {
if (!request || typeof request !== 'object') {
throw new Error(`Request should be a valid object, was [${typeof request}].`);
}
}
/**
* Manages Kibana user session.
*/
export class Session {
/**
* Session duration in ms. If `null` session will stay active until the browser is closed.
*/
private readonly ttl: number | null = null;
/**
* Instantiates Session. Constructor is not supposed to be used directly. To make sure that all
* `Session` dependencies/plugins are properly initialized one should use static `Session.create` instead.
* @param server Server instance.
*/
constructor(private readonly server: Legacy.Server) {
this.ttl = this.server.config().get<number | null>('xpack.security.sessionTimeout');
}
/**
* Retrieves session value from the session storage (e.g. cookie).
* @param request Request instance.
*/
async get<T>(request: Legacy.Request) {
assertRequest(request);
try {
const session = await this.server.auth.test(HAPI_STRATEGY_NAME, request);
// If it's not an array, just return the session value
if (!Array.isArray(session)) {
return session.value as T;
}
// If we have an array with one value, we're good also
if (session.length === 1) {
return session[0].value as T;
}
// Otherwise, we have more than one and won't be authing the user because we don't
// know which session identifies the actual user. There's potential to change this behavior
// to ensure all valid sessions identify the same user, or choose one valid one, but this
// is the safest option.
const warning = `Found ${session.length} auth sessions when we were only expecting 1.`;
this.server.log(['warning', 'security', 'auth', 'session'], warning);
return null;
} catch (err) {
this.server.log(['debug', 'security', 'auth', 'session'], err);
return null;
}
}
/**
* Puts current session value into the session storage.
* @param request Request instance.
* @param value Any object that will be associated with the request.
*/
async set(request: Legacy.Request, value: unknown) {
assertRequest(request);
request.cookieAuth.set({
value,
expires: this.ttl && Date.now() + this.ttl,
} as InternalSession);
}
/**
* Clears current session.
* @param request Request instance.
*/
async clear(request: Legacy.Request) {
assertRequest(request);
request.cookieAuth.clear();
}
/**
* Prepares and creates a session instance.
* @param server Server instance.
*/
static async create(server: Legacy.Server) {
// Register HAPI plugin that manages session cookie and delegate parsing of the session cookie to it.
await server.register({
plugin: hapiAuthCookie,
});
const config = server.config();
const httpOnly = true;
const name = config.get<string>('xpack.security.cookieName');
const password = config.get<string>('xpack.security.encryptionKey');
const path = `${config.get<string | undefined>('server.basePath')}/`;
const secure = config.get<boolean>('xpack.security.secureCookies');
server.auth.strategy(HAPI_STRATEGY_NAME, 'cookie', {
cookie: name,
password,
clearInvalid: true,
validateFunc: Session.validateCookie,
isHttpOnly: httpOnly,
isSecure: secure,
isSameSite: false,
path,
});
if (HAPI_STRATEGY_MODE) {
server.auth.default({
strategy: HAPI_STRATEGY_NAME,
mode: 'required',
});
}
return new Session(server);
}
/**
* Validation function that is passed to hapi-auth-cookie plugin and is responsible
* only for cookie expiration time validation.
* @param request Request instance.
* @param session Session value object retrieved from cookie.
*/
private static validateCookie(request: Legacy.Request, session: InternalSession) {
if (session.expires && session.expires < Date.now()) {
return { valid: false };
}
return { valid: true };
}
}

View file

@ -1,211 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { errors } from 'elasticsearch';
import sinon from 'sinon';
import { Tokens } from './tokens';
describe('Tokens', () => {
let tokens: Tokens;
let callWithInternalUser: sinon.SinonStub;
beforeEach(() => {
const client = { callWithRequest: sinon.stub(), callWithInternalUser: sinon.stub() };
const tokensOptions = { client, log: sinon.stub() };
callWithInternalUser = tokensOptions.client.callWithInternalUser as sinon.SinonStub;
tokens = new Tokens(tokensOptions);
});
it('isAccessTokenExpiredError() returns `true` only if token expired or its document is missing', () => {
for (const error of [
{},
new Error(),
Boom.serverUnavailable(),
Boom.forbidden(),
new errors.InternalServerError(),
new errors.Forbidden(),
{
statusCode: 500,
body: { error: { reason: 'some unknown reason' } },
},
]) {
expect(Tokens.isAccessTokenExpiredError(error)).toBe(false);
}
for (const error of [
{ statusCode: 401 },
Boom.unauthorized(),
new errors.AuthenticationException(),
{
statusCode: 500,
body: { error: { reason: 'token document is missing and must be present' } },
},
]) {
expect(Tokens.isAccessTokenExpiredError(error)).toBe(true);
}
});
describe('refresh()', () => {
const refreshToken = 'some-refresh-token';
it('throws if API call fails with unknown reason', async () => {
const refreshFailureReason = Boom.serverUnavailable('Server is not available');
callWithInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: refreshToken },
})
.rejects(refreshFailureReason);
await expect(tokens.refresh(refreshToken)).rejects.toBe(refreshFailureReason);
});
it('returns `null` if refresh token is not valid', async () => {
const refreshFailureReason = Boom.badRequest();
callWithInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: refreshToken },
})
.rejects(refreshFailureReason);
await expect(tokens.refresh(refreshToken)).resolves.toBe(null);
});
it('returns token pair if refresh API call succeeds', async () => {
const tokenPair = { accessToken: 'access-token', refreshToken: 'refresh-token' };
callWithInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: refreshToken },
})
.resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken });
await expect(tokens.refresh(refreshToken)).resolves.toEqual(tokenPair);
});
});
describe('invalidate()', () => {
it('throws if call to delete access token responds with an error', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const failureReason = new Error('failed to delete token');
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } })
.rejects(failureReason);
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } })
.resolves({ invalidated_tokens: 1 });
await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason);
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: tokenPair.accessToken },
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: tokenPair.refreshToken },
});
});
it('throws if call to delete refresh token responds with an error', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const failureReason = new Error('failed to delete token');
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { refresh_token: tokenPair.refreshToken } })
.rejects(failureReason);
callWithInternalUser
.withArgs('shield.deleteAccessToken', { body: { token: tokenPair.accessToken } })
.resolves({ invalidated_tokens: 1 });
await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason);
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: tokenPair.accessToken },
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: tokenPair.refreshToken },
});
});
it('invalidates all provided tokens', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: tokenPair.accessToken },
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: tokenPair.refreshToken },
});
});
it('invalidates only access token if only access token is provided', async () => {
const tokenPair = { accessToken: 'foo' };
callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: tokenPair.accessToken },
});
});
it('invalidates only refresh token if only refresh token is provided', async () => {
const tokenPair = { refreshToken: 'foo' };
callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 1 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: tokenPair.refreshToken },
});
});
it('does not fail if none of the tokens were invalidated', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 0 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: tokenPair.accessToken },
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: tokenPair.refreshToken },
});
});
it('does not fail if more than one token per access or refresh token were invalidated', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithInternalUser.withArgs('shield.deleteAccessToken').resolves({ invalidated_tokens: 5 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
sinon.assert.calledTwice(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { token: tokenPair.accessToken },
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.deleteAccessToken', {
body: { refresh_token: tokenPair.refreshToken },
});
});
});
});

View file

@ -1,20 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Legacy } from 'kibana';
import { getClient } from '../../../../server/lib/get_client_shield';
export function getUserProvider(server: any) {
const callWithRequest = getClient(server).callWithRequest;
server.expose('getUser', async (request: Legacy.Request) => {
const xpackInfo = server.plugins.xpack_main.info;
if (xpackInfo && xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled()) {
return Promise.resolve(null);
}
return await callWithRequest(request, 'shield.authenticate');
});
}

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
const crypto = require('crypto');
export function validateConfig(config, log) {
if (config.get('xpack.security.encryptionKey') == null) {
log('Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' +
'restart, please set xpack.security.encryptionKey in kibana.yml');
config.set('xpack.security.encryptionKey', crypto.randomBytes(16).toString('hex'));
} else if (config.get('xpack.security.encryptionKey').length < 32) {
throw new Error('xpack.security.encryptionKey must be at least 32 characters. Please update the key in kibana.yml.');
}
const isSslConfigured = config.get('server.ssl.key') != null && config.get('server.ssl.certificate') != null;
if (!isSslConfigured) {
if (config.get('xpack.security.secureCookies')) {
log('Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' +
'function properly.');
} else {
log('Session cookies will be transmitted over insecure connections. This is not recommended.');
}
} else {
config.set('xpack.security.secureCookies', true);
}
}

View file

@ -5,7 +5,7 @@
*/
import Joi from 'joi';
import { wrapError } from '../../../../lib/errors';
import { wrapError } from '../../../../../../../../plugins/security/server';
export function initDeleteRolesApi(server, callWithRequest, routePreCheckLicenseFn) {
server.route({

View file

@ -6,7 +6,7 @@
import _ from 'lodash';
import Boom from 'boom';
import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD } from '../../../../../common/constants';
import { wrapError } from '../../../../lib/errors';
import { wrapError } from '../../../../../../../../plugins/security/server';
import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization';
export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application) {

View file

@ -7,7 +7,7 @@
import { flatten, pick, identity, intersection } from 'lodash';
import Joi from 'joi';
import { GLOBAL_RESOURCE } from '../../../../../common/constants';
import { wrapError } from '../../../../lib/errors';
import { wrapError } from '../../../../../../../../plugins/security/server';
import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization';
export function initPutRolesApi(

View file

@ -11,14 +11,15 @@ import sinon from 'sinon';
import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server';
import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request';
import { AuthenticationResult } from '../../../../../server/lib/authentication/authentication_result';
import { BasicCredentials } from '../../../../../server/lib/authentication/providers/basic';
import { AuthenticationResult, DeauthenticationResult } from '../../../../../../../../plugins/security/server';
import { initAuthenticateApi } from '../authenticate';
import { DeauthenticationResult } from '../../../../lib/authentication/deauthentication_result';
import { KibanaRequest } from '../../../../../../../../../src/core/server';
describe('Authentication routes', () => {
let serverStub;
let hStub;
let loginStub;
let logoutStub;
beforeEach(() => {
serverStub = serverFixture();
@ -28,14 +29,18 @@ describe('Authentication routes', () => {
redirect: sinon.stub(),
response: sinon.stub()
};
loginStub = sinon.stub();
logoutStub = sinon.stub();
initAuthenticateApi(serverStub);
initAuthenticateApi({
authc: { login: loginStub, logout: logoutStub },
config: { authc: { providers: ['basic'] } },
}, serverStub);
});
describe('login', () => {
let loginRoute;
let request;
let authenticateStub;
beforeEach(() => {
loginRoute = serverStub.route
@ -47,10 +52,6 @@ describe('Authentication routes', () => {
headers: {},
payload: { username: 'user', password: 'password' }
});
authenticateStub = serverStub.plugins.security.authenticate.withArgs(
sinon.match(BasicCredentials.decorateRequest(request, 'user', 'password'))
);
});
it('correctly defines route.', async () => {
@ -73,7 +74,7 @@ describe('Authentication routes', () => {
it('returns 500 if authentication throws unhandled exception.', async () => {
const unhandledException = new Error('Something went wrong.');
authenticateStub.throws(unhandledException);
loginStub.throws(unhandledException);
return loginRoute
.handler(request, hStub)
@ -89,7 +90,7 @@ describe('Authentication routes', () => {
it('returns 401 if authentication fails.', async () => {
const failureReason = new Error('Something went wrong.');
authenticateStub.returns(Promise.resolve(AuthenticationResult.failed(failureReason)));
loginStub.resolves(AuthenticationResult.failed(failureReason));
return loginRoute
.handler(request, hStub)
@ -101,9 +102,7 @@ describe('Authentication routes', () => {
});
it('returns 401 if authentication is not handled.', async () => {
authenticateStub.returns(
Promise.resolve(AuthenticationResult.notHandled())
);
loginStub.resolves(AuthenticationResult.notHandled());
return loginRoute
.handler(request, hStub)
@ -117,14 +116,17 @@ describe('Authentication routes', () => {
describe('authentication succeeds', () => {
it(`returns user data`, async () => {
const user = { username: 'user' };
authenticateStub.returns(
Promise.resolve(AuthenticationResult.succeeded(user))
);
loginStub.resolves(AuthenticationResult.succeeded({ username: 'user' }));
await loginRoute.handler(request, hStub);
sinon.assert.calledOnce(hStub.response);
sinon.assert.calledOnce(loginStub);
sinon.assert.calledWithExactly(
loginStub,
sinon.match.instanceOf(KibanaRequest),
{ provider: 'basic', value: { username: 'user', password: 'password' } }
);
});
});
@ -155,9 +157,7 @@ describe('Authentication routes', () => {
const request = requestFixture();
const unhandledException = new Error('Something went wrong.');
serverStub.plugins.security.deauthenticate
.withArgs(request)
.returns(Promise.reject(unhandledException));
logoutStub.rejects(unhandledException);
return logoutRoute
.handler(request, hStub)
@ -167,19 +167,22 @@ describe('Authentication routes', () => {
});
});
it('returns 500 if authenticator fails to deauthenticate.', async () => {
it('returns 500 if authenticator fails to logout.', async () => {
const request = requestFixture();
const failureReason = Boom.forbidden();
serverStub.plugins.security.deauthenticate
.withArgs(request)
.returns(Promise.resolve(DeauthenticationResult.failed(failureReason)));
logoutStub.resolves(DeauthenticationResult.failed(failureReason));
return logoutRoute
.handler(request, hStub)
.catch((response) => {
expect(response).to.be(Boom.boomify(failureReason));
sinon.assert.notCalled(hStub.redirect);
sinon.assert.calledOnce(logoutStub);
sinon.assert.calledWithExactly(
logoutStub,
sinon.match.instanceOf(KibanaRequest)
);
});
});
@ -199,11 +202,7 @@ describe('Authentication routes', () => {
it('redirects user to the URL returned by authenticator.', async () => {
const request = requestFixture();
serverStub.plugins.security.deauthenticate
.withArgs(request)
.returns(
Promise.resolve(DeauthenticationResult.redirectTo('https://custom.logout'))
);
logoutStub.resolves(DeauthenticationResult.redirectTo('https://custom.logout'));
await logoutRoute.handler(request, hStub);
@ -214,9 +213,7 @@ describe('Authentication routes', () => {
it('redirects user to the base path if deauthentication succeeds.', async () => {
const request = requestFixture();
serverStub.plugins.security.deauthenticate
.withArgs(request)
.returns(Promise.resolve(DeauthenticationResult.succeeded()));
logoutStub.resolves(DeauthenticationResult.succeeded());
await logoutRoute.handler(request, hStub);
@ -227,9 +224,7 @@ describe('Authentication routes', () => {
it('redirects user to the base path if deauthentication is not handled.', async () => {
const request = requestFixture();
serverStub.plugins.security.deauthenticate
.withArgs(request)
.returns(Promise.resolve(DeauthenticationResult.notHandled()));
logoutStub.resolves(DeauthenticationResult.notHandled());
await logoutRoute.handler(request, hStub);
@ -293,7 +288,7 @@ describe('Authentication routes', () => {
it('returns 500 if authentication throws unhandled exception.', async () => {
const unhandledException = new Error('Something went wrong.');
serverStub.plugins.security.authenticate.throws(unhandledException);
loginStub.throws(unhandledException);
const response = await samlAcsRoute.handler(request, hStub);
@ -308,9 +303,7 @@ describe('Authentication routes', () => {
it('returns 401 if authentication fails.', async () => {
const failureReason = new Error('Something went wrong.');
serverStub.plugins.security.authenticate.returns(
Promise.resolve(AuthenticationResult.failed(failureReason))
);
loginStub.resolves(AuthenticationResult.failed(failureReason));
const response = await samlAcsRoute.handler(request, hStub);
@ -321,9 +314,7 @@ describe('Authentication routes', () => {
});
it('returns 401 if authentication is not handled.', async () => {
serverStub.plugins.security.authenticate.returns(
Promise.resolve(AuthenticationResult.notHandled())
);
loginStub.resolves(AuthenticationResult.notHandled());
const response = await samlAcsRoute.handler(request, hStub);
@ -334,9 +325,7 @@ describe('Authentication routes', () => {
});
it('returns 401 if authentication completes with unexpected result.', async () => {
serverStub.plugins.security.authenticate.returns(
Promise.resolve(AuthenticationResult.succeeded({}))
);
loginStub.resolves(AuthenticationResult.succeeded({}));
const response = await samlAcsRoute.handler(request, hStub);
@ -347,9 +336,7 @@ describe('Authentication routes', () => {
});
it('redirects if required by the authentication process.', async () => {
serverStub.plugins.security.authenticate.returns(
Promise.resolve(AuthenticationResult.redirectTo('http://redirect-to/path'))
);
loginStub.resolves(AuthenticationResult.redirectTo('http://redirect-to/path'));
await samlAcsRoute.handler(request, hStub);

View file

@ -10,26 +10,28 @@ import sinon from 'sinon';
import { serverFixture } from '../../../../lib/__tests__/__fixtures__/server';
import { requestFixture } from '../../../../lib/__tests__/__fixtures__/request';
import { AuthenticationResult } from '../../../../../server/lib/authentication/authentication_result';
import { BasicCredentials } from '../../../../../server/lib/authentication/providers/basic';
import { AuthenticationResult, BasicCredentials } from '../../../../../../../../plugins/security/server';
import { initUsersApi } from '../users';
import * as ClientShield from '../../../../../../../server/lib/get_client_shield';
import { KibanaRequest } from '../../../../../../../../../src/core/server';
describe('User routes', () => {
const sandbox = sinon.createSandbox();
let clusterStub;
let serverStub;
let loginStub;
beforeEach(() => {
serverStub = serverFixture();
loginStub = sinon.stub();
// Cluster is returned by `getClient` function that is wrapped into `once` making cluster
// a static singleton, so we should use sandbox to set/reset its behavior between tests.
clusterStub = sinon.stub({ callWithRequest() {} });
sandbox.stub(ClientShield, 'getClient').returns(clusterStub);
initUsersApi(serverStub);
initUsersApi({ authc: { login: loginStub }, config: { authc: { providers: ['basic'] } } }, serverStub);
});
afterEach(() => sandbox.restore());
@ -98,13 +100,12 @@ describe('User routes', () => {
it('returns 401 if user can authenticate with new password.', async () => {
getUserStub.returns(Promise.resolve({}));
serverStub.plugins.security.authenticate
loginStub
.withArgs(
sinon.match(BasicCredentials.decorateRequest(request, 'user', 'new-password'))
sinon.match.instanceOf(KibanaRequest),
{ provider: 'basic', value: { username: 'user', password: 'new-password' } }
)
.returns(
Promise.resolve(AuthenticationResult.failed(new Error('Something went wrong.')))
);
.resolves(AuthenticationResult.failed(new Error('Something went wrong.')));
return changePasswordRoute
.handler(request)
@ -150,13 +151,12 @@ describe('User routes', () => {
it('successfully changes own password if provided old password is correct.', async () => {
getUserStub.returns(Promise.resolve({}));
serverStub.plugins.security.authenticate
loginStub
.withArgs(
sinon.match(BasicCredentials.decorateRequest(request, 'user', 'new-password'))
sinon.match.instanceOf(KibanaRequest),
{ provider: 'basic', value: { username: 'user', password: 'new-password' } }
)
.returns(
Promise.resolve(AuthenticationResult.succeeded({}))
);
.resolves(AuthenticationResult.succeeded({}));
const hResponseStub = { code: sinon.stub() };
const hStub = { response: sinon.stub().returns(hResponseStub) };
@ -190,7 +190,7 @@ describe('User routes', () => {
.handler(request)
.catch((response) => {
sinon.assert.notCalled(serverStub.plugins.security.getUser);
sinon.assert.notCalled(serverStub.plugins.security.authenticate);
sinon.assert.notCalled(loginStub);
expect(response.isBoom).to.be(true);
expect(response.output.payload).to.eql({
@ -208,7 +208,7 @@ describe('User routes', () => {
await changePasswordRoute.handler(request, hStub);
sinon.assert.notCalled(serverStub.plugins.security.getUser);
sinon.assert.notCalled(serverStub.plugins.security.authenticate);
sinon.assert.notCalled(loginStub);
sinon.assert.calledOnce(clusterStub.callWithRequest);
sinon.assert.calledWithExactly(

View file

@ -6,10 +6,11 @@
import Boom from 'boom';
import Joi from 'joi';
import { wrapError } from '../../../lib/errors';
import { canRedirectRequest } from '../../../lib/can_redirect_request';
import { schema } from '@kbn/config-schema';
import { canRedirectRequest, wrapError } from '../../../../../../../plugins/security/server';
import { KibanaRequest } from '../../../../../../../../src/core/server';
export function initAuthenticateApi(server) {
export function initAuthenticateApi({ authc: { login, logout }, config }, server) {
server.route({
method: 'POST',
@ -30,8 +31,14 @@ export function initAuthenticateApi(server) {
const { username, password } = request.payload;
try {
request.loginAttempt().setCredentials(username, password);
const authenticationResult = await server.plugins.security.authenticate(request);
// We should prefer `token` over `basic` if possible.
const providerToLoginWith = config.authc.providers.includes('token')
? 'token'
: 'basic';
const authenticationResult = await login(KibanaRequest.from(request), {
provider: providerToLoginWith,
value: { username, password }
});
if (!authenticationResult.succeeded()) {
throw Boom.unauthorized(authenticationResult.error);
@ -59,7 +66,11 @@ export function initAuthenticateApi(server) {
async handler(request, h) {
try {
// When authenticating using SAML we _expect_ to redirect to the SAML Identity provider.
const authenticationResult = await server.plugins.security.authenticate(request);
const authenticationResult = await login(KibanaRequest.from(request), {
provider: 'saml',
value: { samlResponse: request.payload.SAMLResponse }
});
if (authenticationResult.redirected()) {
return h.redirect(authenticationResult.redirectURL);
}
@ -94,7 +105,24 @@ export function initAuthenticateApi(server) {
try {
// We handle the fact that the user might get redirected to Kibana while already having an session
// Return an error notifying the user they are already logged in.
const authenticationResult = await server.plugins.security.authenticate(request);
const authenticationResult = await login(KibanaRequest.from(request), {
provider: 'oidc',
// Checks if the request object represents an HTTP request regarding authentication with OpenID Connect.
// This can be
// - An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication
// - An HTTP POST request with a parameter named `iss` as part of a 3rd party initiated authentication
// - An HTTP GET request with a query parameter named `code` as the response to a successful authentication from
// an OpenID Connect Provider
// - An HTTP GET request with a query parameter named `error` as the response to a failed authentication from
// an OpenID Connect Provider
value: {
code: request.query && request.query.code,
iss: (request.query && request.query.iss) || (request.payload && request.payload.iss),
loginHint:
(request.query && request.query.login_hint) ||
(request.payload && request.payload.login_hint),
},
});
if (authenticationResult.succeeded()) {
return Boom.forbidden(
'Sorry, you already have an active Kibana session. ' +
@ -120,12 +148,18 @@ export function initAuthenticateApi(server) {
auth: false
},
async handler(request, h) {
if (!canRedirectRequest(request)) {
if (!canRedirectRequest(KibanaRequest.from(request))) {
throw Boom.badRequest('Client should be able to process redirect response.');
}
try {
const deauthenticationResult = await server.plugins.security.deauthenticate(request);
const deauthenticationResult = await logout(
// Allow unknown query parameters as this endpoint can be hit by the 3rd-party with any
// set of query string parameters (e.g. SAML/OIDC logout request parameters).
KibanaRequest.from(request, {
query: schema.object({}, { allowUnknowns: true }),
})
);
if (deauthenticationResult.failed()) {
throw wrapError(deauthenticationResult.error);
}

View file

@ -6,7 +6,7 @@
import _ from 'lodash';
import { getClient } from '../../../../../../server/lib/get_client_shield';
import { wrapError } from '../../../lib/errors';
import { wrapError } from '../../../../../../../plugins/security/server';
export function initIndicesApi(server) {
const callWithRequest = getClient(server).callWithRequest;

View file

@ -9,11 +9,11 @@ import Boom from 'boom';
import Joi from 'joi';
import { getClient } from '../../../../../../server/lib/get_client_shield';
import { userSchema } from '../../../lib/user_schema';
import { wrapError } from '../../../lib/errors';
import { routePreCheckLicense } from '../../../lib/route_pre_check_license';
import { BasicCredentials } from '../../../../server/lib/authentication/providers/basic';
import { BasicCredentials, wrapError } from '../../../../../../../plugins/security/server';
import { KibanaRequest } from '../../../../../../../../src/core/server';
export function initUsersApi(server) {
export function initUsersApi({ authc: { login }, config }, server) {
const callWithRequest = getClient(server).callWithRequest;
const routePreCheckLicenseFn = routePreCheckLicense(server);
@ -105,8 +105,14 @@ export function initUsersApi(server) {
// Now we authenticate user with the new password again updating current session if any.
if (isCurrentUser) {
request.loginAttempt().setCredentials(username, newPassword);
const authenticationResult = await server.plugins.security.authenticate(request);
// We should prefer `token` over `basic` if possible.
const providerToLoginWith = config.authc.providers.includes('token')
? 'token'
: 'basic';
const authenticationResult = await login(KibanaRequest.from(request), {
provider: providerToLoginWith,
value: { username, password: newPassword }
});
if (!authenticationResult.succeeded()) {
throw Boom.unauthorized((authenticationResult.error));

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
export function initLoggedOutView(server) {
export function initLoggedOutView({ config: { cookieName } }, server) {
const config = server.config();
const loggedOut = server.getHiddenUiAppById('logged_out');
const cookieName = config.get('xpack.security.cookieName');
server.route({
method: 'GET',

View file

@ -8,9 +8,8 @@ import { get } from 'lodash';
import { parseNext } from '../../lib/parse_next';
export function initLoginView(server, xpackMainPlugin) {
export function initLoginView({ config: { cookieName } }, server, xpackMainPlugin) {
const config = server.config();
const cookieName = config.get('xpack.security.cookieName');
const login = server.getHiddenUiAppById('login');
function shouldShowLogin() {

View file

@ -22,9 +22,6 @@ function getServerMock(customization?: any) {
const getLicenseCheckResults = jest.fn().mockReturnValue({});
const defaultServerMock = {
plugins: {
security: {
isAuthenticated: jest.fn().mockReturnValue(true),
},
xpack_main: {
info: {
isAvailable: jest.fn().mockReturnValue(true),

View file

@ -8,17 +8,19 @@ import sinon from 'sinon';
import expect from '@kbn/expect';
import { replaceInjectedVars } from '../replace_injected_vars';
import { KibanaRequest } from '../../../../../../../src/core/server';
const buildRequest = (telemetryOptedIn = null, path = '/app/kibana') => {
const get = sinon.stub();
if (telemetryOptedIn === null) {
get.withArgs('telemetry', 'telemetry').returns(Promise.reject(new Error('not found exception')));
get.withArgs('telemetry', 'telemetry').rejects(new Error('not found exception'));
} else {
get.withArgs('telemetry', 'telemetry').returns(Promise.resolve({ attributes: { enabled: telemetryOptedIn } }));
get.withArgs('telemetry', 'telemetry').resolves({ attributes: { enabled: telemetryOptedIn } });
}
return {
path,
route: { settings: {} },
getSavedObjectsClient: () => {
return {
get,
@ -49,8 +51,11 @@ describe('replaceInjectedVars uiExport', () => {
},
});
sinon.assert.calledOnce(server.plugins.security.isAuthenticated);
expect(server.plugins.security.isAuthenticated.firstCall.args[0]).to.be(request);
sinon.assert.calledOnce(server.newPlatform.setup.plugins.security.authc.isAuthenticated);
sinon.assert.calledWithExactly(
server.newPlatform.setup.plugins.security.authc.isAuthenticated,
sinon.match.instanceOf(KibanaRequest)
);
});
it('sends the xpack info if security plugin is disabled', async () => {
@ -58,6 +63,7 @@ describe('replaceInjectedVars uiExport', () => {
const request = buildRequest();
const server = mockServer();
delete server.plugins.security;
delete server.newPlatform.setup.plugins.security;
const newVars = await replaceInjectedVars(originalInjectedVars, request, server);
expect(newVars).to.eql({
@ -137,7 +143,7 @@ describe('replaceInjectedVars uiExport', () => {
const originalInjectedVars = { a: 1 };
const request = buildRequest();
const server = mockServer();
server.plugins.security.isAuthenticated.returns(false);
server.newPlatform.setup.plugins.security.authc.isAuthenticated.returns(false);
const newVars = await replaceInjectedVars(originalInjectedVars, request, server);
expect(newVars).to.eql(originalInjectedVars);
@ -191,10 +197,13 @@ describe('replaceInjectedVars uiExport', () => {
function mockServer() {
const getLicenseCheckResults = sinon.stub().returns({});
return {
newPlatform: {
setup: {
plugins: { security: { authc: { isAuthenticated: sinon.stub().returns(true) } } }
}
},
plugins: {
security: {
isAuthenticated: sinon.stub().returns(true)
},
security: {},
xpack_main: {
getFeatures: () => [{
id: 'mockFeature',

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaRequest } from '../../../../../../src/core/server';
import { getTelemetryOptIn } from '../../../telemetry/server';
export async function replaceInjectedVars(originalInjectedVars, request, server) {
@ -16,7 +17,7 @@ export async function replaceInjectedVars(originalInjectedVars, request, server)
});
// security feature is disabled
if (!server.plugins.security) {
if (!server.plugins.security || !server.newPlatform.setup.plugins.security) {
return await withXpackInfo();
}
@ -26,7 +27,7 @@ export async function replaceInjectedVars(originalInjectedVars, request, server)
}
// request is not authenticated
if (!await server.plugins.security.isAuthenticated(request)) {
if (!await server.newPlatform.setup.plugins.security.authc.isAuthenticated(KibanaRequest.from(request))) {
return originalInjectedVars;
}

View file

@ -57,7 +57,6 @@
"@types/git-url-parse": "^9.0.0",
"@types/glob": "^7.1.1",
"@types/graphql": "^0.13.1",
"@types/hapi-auth-cookie": "^9.1.0",
"@types/history": "^4.6.2",
"@types/jest": "^24.0.9",
"@types/joi": "^13.4.2",
@ -180,6 +179,7 @@
"@elastic/numeral": "2.3.3",
"@elastic/request-crypto": "^1.0.2",
"@kbn/babel-preset": "1.0.0",
"@kbn/config-schema": "1.0.0",
"@kbn/elastic-idx": "1.0.0",
"@kbn/es-query": "1.0.0",
"@kbn/i18n": "1.0.0",
@ -240,7 +240,6 @@
"graphql-tools": "^3.0.2",
"h2o2": "^8.1.2",
"handlebars": "^4.1.2",
"hapi-auth-cookie": "^9.0.0",
"history": "4.9.0",
"history-extra": "^5.0.1",
"humps": "2.0.1",

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AuthenticatedUser } from './authenticated_user';
export function mockAuthenticatedUser(user: Partial<AuthenticatedUser> = {}) {
return {
username: 'user',
email: 'email',
full_name: 'full name',
roles: ['user-role'],
enabled: true,
authentication_realm: { name: 'native1', type: 'native' },
lookup_realm: { name: 'native1', type: 'native' },
...user,
};
}

View file

@ -4,5 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { AuthenticationResult } from './authentication_result';
export { DeauthenticationResult } from './deauthentication_result';
export { User, EditUser, getUserDisplayName } from './user';
export { AuthenticatedUser, canUserChangePassword } from './authenticated_user';

View file

@ -0,0 +1,8 @@
{
"id": "security",
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "security"],
"server": true,
"ui": false
}

View file

@ -5,7 +5,7 @@
*/
import Boom from 'boom';
import { AuthenticatedUser } from '../../../common/model';
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
import { AuthenticationResult } from './authentication_result';
describe('AuthenticationResult', () => {
@ -21,6 +21,7 @@ describe('AuthenticationResult', () => {
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
});
@ -44,6 +45,7 @@ describe('AuthenticationResult', () => {
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
@ -60,6 +62,7 @@ describe('AuthenticationResult', () => {
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
@ -77,8 +80,8 @@ describe('AuthenticationResult', () => {
);
});
it('correctly produces `succeeded` authentication result without state.', () => {
const user = { username: 'user' } as AuthenticatedUser;
it('correctly produces `succeeded` authentication result without state and authHeaders.', () => {
const user = mockAuthenticatedUser();
const authenticationResult = AuthenticationResult.succeeded(user);
expect(authenticationResult.succeeded()).toBe(true);
@ -88,14 +91,15 @@ describe('AuthenticationResult', () => {
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.error).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
it('correctly produces `succeeded` authentication result with state.', () => {
const user = { username: 'user' } as AuthenticatedUser;
it('correctly produces `succeeded` authentication result with state, but without authHeaders.', () => {
const user = mockAuthenticatedUser();
const state = { some: 'state' };
const authenticationResult = AuthenticationResult.succeeded(user, state);
const authenticationResult = AuthenticationResult.succeeded(user, { state });
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.failed()).toBe(false);
@ -104,6 +108,42 @@ describe('AuthenticationResult', () => {
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBe(state);
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.error).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
it('correctly produces `succeeded` authentication result with authHeaders, but without state.', () => {
const user = mockAuthenticatedUser();
const authHeaders = { authorization: 'some-token' };
const authenticationResult = AuthenticationResult.succeeded(user, { authHeaders });
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.failed()).toBe(false);
expect(authenticationResult.notHandled()).toBe(false);
expect(authenticationResult.redirected()).toBe(false);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBe(authHeaders);
expect(authenticationResult.error).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
it('correctly produces `succeeded` authentication result with both authHeaders and state.', () => {
const user = mockAuthenticatedUser();
const authHeaders = { authorization: 'some-token' };
const state = { some: 'state' };
const authenticationResult = AuthenticationResult.succeeded(user, { authHeaders, state });
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.failed()).toBe(false);
expect(authenticationResult.notHandled()).toBe(false);
expect(authenticationResult.redirected()).toBe(false);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBe(state);
expect(authenticationResult.authHeaders).toBe(authHeaders);
expect(authenticationResult.error).toBeUndefined();
expect(authenticationResult.redirectURL).toBeUndefined();
});
@ -128,6 +168,7 @@ describe('AuthenticationResult', () => {
expect(authenticationResult.redirectURL).toBe(redirectURL);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.error).toBeUndefined();
});
@ -143,6 +184,7 @@ describe('AuthenticationResult', () => {
expect(authenticationResult.redirectURL).toBe(redirectURL);
expect(authenticationResult.state).toBe(state);
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.error).toBeUndefined();
});
@ -176,21 +218,31 @@ describe('AuthenticationResult', () => {
});
it('depends on `state` for `succeeded`.', () => {
const mockUser = { username: 'u' } as AuthenticatedUser;
expect(AuthenticationResult.succeeded(mockUser, 'string').shouldUpdateState()).toBe(true);
expect(AuthenticationResult.succeeded(mockUser, 0).shouldUpdateState()).toBe(true);
expect(AuthenticationResult.succeeded(mockUser, true).shouldUpdateState()).toBe(true);
expect(AuthenticationResult.succeeded(mockUser, false).shouldUpdateState()).toBe(true);
expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldUpdateState()).toBe(
const mockUser = mockAuthenticatedUser();
expect(
AuthenticationResult.succeeded(mockUser, { state: 'string' }).shouldUpdateState()
).toBe(true);
expect(AuthenticationResult.succeeded(mockUser, { state: 0 }).shouldUpdateState()).toBe(true);
expect(AuthenticationResult.succeeded(mockUser, { state: true }).shouldUpdateState()).toBe(
true
);
expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldUpdateState()).toBe(
expect(AuthenticationResult.succeeded(mockUser, { state: false }).shouldUpdateState()).toBe(
true
);
expect(
AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldUpdateState()
).toBe(true);
expect(
AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldUpdateState()
).toBe(true);
expect(AuthenticationResult.succeeded(mockUser).shouldUpdateState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, undefined).shouldUpdateState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, null).shouldUpdateState()).toBe(false);
expect(
AuthenticationResult.succeeded(mockUser, { state: undefined }).shouldUpdateState()
).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, { state: null }).shouldUpdateState()).toBe(
false
);
});
});
@ -222,21 +274,31 @@ describe('AuthenticationResult', () => {
});
it('depends on `state` for `succeeded`.', () => {
const mockUser = { username: 'u' } as AuthenticatedUser;
expect(AuthenticationResult.succeeded(mockUser, null).shouldClearState()).toBe(true);
const mockUser = mockAuthenticatedUser();
expect(AuthenticationResult.succeeded(mockUser, { state: null }).shouldClearState()).toBe(
true
);
expect(AuthenticationResult.succeeded(mockUser).shouldClearState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, undefined).shouldClearState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, 'string').shouldClearState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, 0).shouldClearState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, true).shouldClearState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, false).shouldClearState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldClearState()).toBe(
expect(
AuthenticationResult.succeeded(mockUser, { state: undefined }).shouldClearState()
).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, { state: 'string' }).shouldClearState()).toBe(
false
);
expect(AuthenticationResult.succeeded(mockUser, { prop: 'object' }).shouldClearState()).toBe(
expect(AuthenticationResult.succeeded(mockUser, { state: 0 }).shouldClearState()).toBe(false);
expect(AuthenticationResult.succeeded(mockUser, { state: true }).shouldClearState()).toBe(
false
);
expect(AuthenticationResult.succeeded(mockUser, { state: false }).shouldClearState()).toBe(
false
);
expect(
AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldClearState()
).toBe(false);
expect(
AuthenticationResult.succeeded(mockUser, { state: { prop: 'object' } }).shouldClearState()
).toBe(false);
});
});
});

View file

@ -4,12 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AuthHeaders } from '../../../../../src/core/server';
import { AuthenticatedUser } from '../../common/model';
import { getErrorStatusCode } from '../errors';
/**
* Represents status that `AuthenticationResult` can be in.
*/
import { AuthenticatedUser } from '../../../common/model';
import { getErrorStatusCode } from '../errors';
enum AuthenticationResultStatus {
/**
* Authentication of the user can't be handled (e.g. supported credentials
@ -45,6 +46,7 @@ interface AuthenticationOptions {
redirectURL?: string;
state?: unknown;
user?: AuthenticatedUser;
authHeaders?: AuthHeaders;
}
/**
@ -62,14 +64,22 @@ export class AuthenticationResult {
/**
* Produces `AuthenticationResult` for the case when authentication succeeds.
* @param user User information retrieved as a result of successful authentication attempt.
* @param [authHeaders] Optional dictionary of the HTTP headers with authentication information.
* @param [state] Optional state to be stored and reused for the next request.
*/
public static succeeded(user: AuthenticatedUser, state?: unknown) {
public static succeeded(
user: AuthenticatedUser,
{ authHeaders, state }: { authHeaders?: AuthHeaders; state?: unknown } = {}
) {
if (!user) {
throw new Error('User should be specified.');
}
return new AuthenticationResult(AuthenticationResultStatus.Succeeded, { user, state });
return new AuthenticationResult(AuthenticationResultStatus.Succeeded, {
user,
authHeaders,
state,
});
}
/**
@ -112,6 +122,14 @@ export class AuthenticationResult {
return this.options.user;
}
/**
* Headers that include authentication information that should be used to authenticate user for any
* future requests (only available for `succeeded` result).
*/
public get authHeaders() {
return this.options.authHeaders;
}
/**
* State associated with the authenticated user (only available for `succeeded`
* and `redirected` results).

View file

@ -0,0 +1,654 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('./providers/basic', () => ({ BasicAuthenticationProvider: jest.fn() }));
import Boom from 'boom';
import { SessionStorage } from '../../../../../src/core/server';
import {
loggingServiceMock,
httpServiceMock,
httpServerMock,
elasticsearchServiceMock,
sessionStorageMock,
} from '../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
import { AuthenticationResult } from './authentication_result';
import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator';
import { DeauthenticationResult } from './deauthentication_result';
import { BasicAuthenticationProvider } from './providers';
function getMockOptions(config: Partial<AuthenticatorOptions['config']> = {}) {
return {
clusterClient: elasticsearchServiceMock.createClusterClient(),
basePath: httpServiceMock.createSetupContract().basePath,
loggers: loggingServiceMock.create(),
isSystemAPIRequest: jest.fn(),
getServerBaseURL: jest.fn(),
config: { sessionTimeout: null, authc: { providers: [], oidc: {}, saml: {} }, ...config },
sessionStorageFactory: sessionStorageMock.createFactory<ProviderSession>(),
};
}
describe('Authenticator', () => {
let mockBasicAuthenticationProvider: jest.Mocked<PublicMethodsOf<BasicAuthenticationProvider>>;
beforeEach(() => {
mockBasicAuthenticationProvider = {
login: jest.fn(),
authenticate: jest.fn(),
logout: jest.fn(),
};
jest
.requireMock('./providers/basic')
.BasicAuthenticationProvider.mockImplementation(() => mockBasicAuthenticationProvider);
});
afterEach(() => jest.clearAllMocks());
describe('initialization', () => {
it('fails if authentication providers are not configured.', () => {
const mockOptions = getMockOptions({ authc: { providers: [], oidc: {}, saml: {} } });
expect(() => new Authenticator(mockOptions)).toThrowError(
'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.'
);
});
it('fails if configured authentication provider is not known.', () => {
const mockOptions = getMockOptions({
authc: { providers: ['super-basic'], oidc: {}, saml: {} },
});
expect(() => new Authenticator(mockOptions)).toThrowError(
'Unsupported authentication provider name: super-basic.'
);
});
});
describe('`login` method', () => {
let authenticator: Authenticator;
let mockOptions: ReturnType<typeof getMockOptions>;
let mockSessionStorage: jest.Mocked<SessionStorage<ProviderSession>>;
beforeEach(() => {
mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } });
mockSessionStorage = sessionStorageMock.create();
mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage);
authenticator = new Authenticator(mockOptions);
});
it('fails if request is not provided.', async () => {
await expect(authenticator.login(undefined as any, undefined as any)).rejects.toThrowError(
'Request should be a valid "KibanaRequest" instance, was [undefined].'
);
});
it('fails if login attempt is not provided.', async () => {
await expect(
authenticator.login(httpServerMock.createKibanaRequest(), undefined as any)
).rejects.toThrowError(
'Login attempt should be an object with non-empty "provider" property.'
);
await expect(
authenticator.login(httpServerMock.createKibanaRequest(), {} as any)
).rejects.toThrowError(
'Login attempt should be an object with non-empty "provider" property.'
);
});
it('fails if an authentication provider fails.', async () => {
const request = httpServerMock.createKibanaRequest();
const failureReason = new Error('Not Authorized');
mockBasicAuthenticationProvider.login.mockResolvedValue(
AuthenticationResult.failed(failureReason)
);
const authenticationResult = await authenticator.login(request, {
provider: 'basic',
value: {},
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('returns user that authentication provider returns.', async () => {
const request = httpServerMock.createKibanaRequest();
const user = mockAuthenticatedUser();
mockBasicAuthenticationProvider.login.mockResolvedValue(
AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } })
);
const authenticationResult = await authenticator.login(request, {
provider: 'basic',
value: {},
});
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.authHeaders).toEqual({ authorization: 'Basic .....' });
});
it('creates session whenever authentication provider returns state', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
mockBasicAuthenticationProvider.login.mockResolvedValue(
AuthenticationResult.succeeded(user, { state: { authorization } })
);
const authenticationResult = await authenticator.login(request, {
provider: 'basic',
value: {},
});
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).toHaveBeenCalledTimes(1);
expect(mockSessionStorage.set).toHaveBeenCalledWith({
expires: null,
state: { authorization },
provider: 'basic',
});
});
it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => {
const request = httpServerMock.createKibanaRequest();
const authenticationResult = await authenticator.login(request, {
provider: 'token',
value: {},
});
expect(authenticationResult.notHandled()).toBe(true);
});
it('clears session if it belongs to a different provider.', async () => {
const state = { authorization: 'Basic xxx' };
const user = mockAuthenticatedUser();
const credentials = { username: 'user', password: 'password' };
const request = httpServerMock.createKibanaRequest();
mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user));
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' });
const authenticationResult = await authenticator.login(request, {
provider: 'basic',
value: credentials,
});
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(
request,
credentials,
null
);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).toHaveBeenCalled();
});
it('clears session if provider asked to do so.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
mockBasicAuthenticationProvider.login.mockResolvedValue(
AuthenticationResult.succeeded(user, { state: null })
);
const authenticationResult = await authenticator.login(request, {
provider: 'basic',
value: {},
});
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).toHaveBeenCalled();
});
});
describe('`authenticate` method', () => {
let authenticator: Authenticator;
let mockOptions: ReturnType<typeof getMockOptions>;
let mockSessionStorage: jest.Mocked<SessionStorage<ProviderSession>>;
beforeEach(() => {
mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } });
mockSessionStorage = sessionStorageMock.create<ProviderSession>();
mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage);
authenticator = new Authenticator(mockOptions);
});
it('fails if request is not provided.', async () => {
await expect(authenticator.authenticate(undefined as any)).rejects.toThrowError(
'Request should be a valid "KibanaRequest" instance, was [undefined].'
);
});
it('fails if an authentication provider fails.', async () => {
const request = httpServerMock.createKibanaRequest();
const failureReason = new Error('Not Authorized');
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.failed(failureReason)
);
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('returns user that authentication provider returns.', async () => {
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Basic ***' },
});
const user = mockAuthenticatedUser();
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } })
);
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.authHeaders).toEqual({ authorization: 'Basic .....' });
});
it('creates session whenever authentication provider returns state for system API requests', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
mockOptions.isSystemAPIRequest.mockReturnValue(true);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.succeeded(user, { state: { authorization } })
);
const systemAPIAuthenticationResult = await authenticator.authenticate(request);
expect(systemAPIAuthenticationResult.succeeded()).toBe(true);
expect(systemAPIAuthenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).toHaveBeenCalledTimes(1);
expect(mockSessionStorage.set).toHaveBeenCalledWith({
expires: null,
state: { authorization },
provider: 'basic',
});
});
it('creates session whenever authentication provider returns state for non-system API requests', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const authorization = `Basic ${Buffer.from('foo:bar').toString('base64')}`;
mockOptions.isSystemAPIRequest.mockReturnValue(false);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.succeeded(user, { state: { authorization } })
);
const systemAPIAuthenticationResult = await authenticator.authenticate(request);
expect(systemAPIAuthenticationResult.succeeded()).toBe(true);
expect(systemAPIAuthenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).toHaveBeenCalledTimes(1);
expect(mockSessionStorage.set).toHaveBeenCalledWith({
expires: null,
state: { authorization },
provider: 'basic',
});
});
it('does not extend session for system API calls.', async () => {
const user = mockAuthenticatedUser();
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(true);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.succeeded(user)
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('extends session for non-system API calls.', async () => {
const user = mockAuthenticatedUser();
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(false);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.succeeded(user)
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).toHaveBeenCalledTimes(1);
expect(mockSessionStorage.set).toHaveBeenCalledWith({
expires: null,
state,
provider: 'basic',
});
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('properly extends session timeout if it is defined.', async () => {
const user = mockAuthenticatedUser();
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
const currentDate = new Date(Date.UTC(2019, 10, 10)).valueOf();
// Create new authenticator with non-null `sessionTimeout`.
mockOptions = getMockOptions({
sessionTimeout: 3600 * 24,
authc: { providers: ['basic'], oidc: {}, saml: {} },
});
mockSessionStorage = sessionStorageMock.create();
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage);
authenticator = new Authenticator(mockOptions);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.succeeded(user)
);
jest.spyOn(Date, 'now').mockImplementation(() => currentDate);
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).toHaveBeenCalledTimes(1);
expect(mockSessionStorage.set).toHaveBeenCalledWith({
expires: currentDate + 3600 * 24,
state,
provider: 'basic',
});
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('does not touch session for system API calls if authentication fails with non-401 reason.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(true);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.failed(new Error('some error'))
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('does not touch session for non-system API calls if authentication fails with non-401 reason.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(false);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.failed(new Error('some error'))
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('replaces existing session with the one returned by authentication provider for system API requests', async () => {
const user = mockAuthenticatedUser();
const existingState = { authorization: 'Basic xxx' };
const newState = { authorization: 'Basic yyy' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(true);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.succeeded(user, { state: newState })
);
mockSessionStorage.get.mockResolvedValue({
expires: null,
state: existingState,
provider: 'basic',
});
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).toHaveBeenCalledTimes(1);
expect(mockSessionStorage.set).toHaveBeenCalledWith({
expires: null,
state: newState,
provider: 'basic',
});
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('replaces existing session with the one returned by authentication provider for non-system API requests', async () => {
const user = mockAuthenticatedUser();
const existingState = { authorization: 'Basic xxx' };
const newState = { authorization: 'Basic yyy' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(false);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.succeeded(user, { state: newState })
);
mockSessionStorage.get.mockResolvedValue({
expires: null,
state: existingState,
provider: 'basic',
});
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(mockSessionStorage.set).toHaveBeenCalledTimes(1);
expect(mockSessionStorage.set).toHaveBeenCalledWith({
expires: null,
state: newState,
provider: 'basic',
});
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('clears session if provider failed to authenticate system API request with 401 with active session.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(true);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.failed(Boom.unauthorized())
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).toHaveBeenCalled();
});
it('clears session if provider failed to authenticate non-system API request with 401 with active session.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(false);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.failed(Boom.unauthorized())
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).toHaveBeenCalled();
});
it('clears session if provider requested it via setting state to `null`.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.redirectTo('some-url', null)
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.redirected()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).toHaveBeenCalled();
});
it('does not clear session if provider can not handle system API request authentication with active session.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(true);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.notHandled()
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.notHandled()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('does not clear session if provider can not handle non-system API request authentication with active session.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(false);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.notHandled()
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.notHandled()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('clears session for system API request if it belongs to not configured provider.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(true);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.notHandled()
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.notHandled()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).toHaveBeenCalled();
});
it('clears session for non-system API request if it belongs to not configured provider.', async () => {
const state = { authorization: 'Basic xxx' };
const request = httpServerMock.createKibanaRequest();
mockOptions.isSystemAPIRequest.mockReturnValue(false);
mockBasicAuthenticationProvider.authenticate.mockResolvedValue(
AuthenticationResult.notHandled()
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' });
const authenticationResult = await authenticator.authenticate(request);
expect(authenticationResult.notHandled()).toBe(true);
expect(mockSessionStorage.set).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).toHaveBeenCalled();
});
});
describe('`logout` method', () => {
let authenticator: Authenticator;
let mockOptions: ReturnType<typeof getMockOptions>;
let mockSessionStorage: jest.Mocked<SessionStorage<ProviderSession>>;
beforeEach(() => {
mockOptions = getMockOptions({ authc: { providers: ['basic'], oidc: {}, saml: {} } });
mockSessionStorage = sessionStorageMock.create();
mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage);
authenticator = new Authenticator(mockOptions);
});
it('fails if request is not provided.', async () => {
await expect(authenticator.logout(undefined as any)).rejects.toThrowError(
'Request should be a valid "KibanaRequest" instance, was [undefined].'
);
});
it('returns `notHandled` if session does not exist.', async () => {
const request = httpServerMock.createKibanaRequest();
mockSessionStorage.get.mockResolvedValue(null);
const deauthenticationResult = await authenticator.logout(request);
expect(deauthenticationResult.notHandled()).toBe(true);
expect(mockSessionStorage.clear).not.toHaveBeenCalled();
});
it('clears session and returns whatever authentication provider returns.', async () => {
const request = httpServerMock.createKibanaRequest();
const state = { authorization: 'Basic xxx' };
mockBasicAuthenticationProvider.logout.mockResolvedValue(
DeauthenticationResult.redirectTo('some-url')
);
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'basic' });
const deauthenticationResult = await authenticator.logout(request);
expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1);
expect(mockSessionStorage.clear).toHaveBeenCalled();
expect(deauthenticationResult.redirected()).toBe(true);
expect(deauthenticationResult.redirectURL).toBe('some-url');
});
it('only clears session if it belongs to not configured provider.', async () => {
const request = httpServerMock.createKibanaRequest();
const state = { authorization: 'Bearer xxx' };
mockSessionStorage.get.mockResolvedValue({ expires: null, state, provider: 'token' });
const deauthenticationResult = await authenticator.logout(request);
expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled();
expect(mockSessionStorage.clear).toHaveBeenCalled();
expect(deauthenticationResult.notHandled()).toBe(true);
});
});
});

View file

@ -0,0 +1,415 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
SessionStorageFactory,
SessionStorage,
KibanaRequest,
LoggerFactory,
Logger,
HttpServiceSetup,
ClusterClient,
} from '../../../../../src/core/server';
import { ConfigType } from '../config';
import { getErrorStatusCode } from '../errors';
import {
AuthenticationProviderOptions,
AuthenticationProviderSpecificOptions,
BaseAuthenticationProvider,
BasicAuthenticationProvider,
KerberosAuthenticationProvider,
SAMLAuthenticationProvider,
TokenAuthenticationProvider,
OIDCAuthenticationProvider,
isSAMLRequestQuery,
} from './providers';
import { AuthenticationResult } from './authentication_result';
import { DeauthenticationResult } from './deauthentication_result';
import { Tokens } from './tokens';
/**
* The shape of the session that is actually stored in the cookie.
*/
export interface ProviderSession {
/**
* Name/type of the provider this session belongs to.
*/
provider: string;
/**
* The Unix time in ms when the session should be considered expired. If `null`, session will stay
* active until the browser is closed.
*/
expires: number | null;
/**
* Session value that is fed to the authentication provider. The shape is unknown upfront and
* entirely determined by the authentication provider that owns the current session.
*/
state: unknown;
}
/**
* The shape of the login attempt.
*/
export interface ProviderLoginAttempt {
/**
* Name/type of the provider this login attempt is targeted for.
*/
provider: string;
/**
* Login attempt can have any form and defined by the specific provider.
*/
value: unknown;
}
export interface AuthenticatorOptions {
config: Pick<ConfigType, 'sessionTimeout' | 'authc'>;
basePath: HttpServiceSetup['basePath'];
loggers: LoggerFactory;
clusterClient: PublicMethodsOf<ClusterClient>;
sessionStorageFactory: SessionStorageFactory<ProviderSession>;
isSystemAPIRequest: (request: KibanaRequest) => boolean;
getServerBaseURL: () => string;
}
// Mapping between provider key defined in the config and authentication
// provider class that can handle specific authentication mechanism.
const providerMap = new Map<
string,
new (
options: AuthenticationProviderOptions,
providerSpecificOptions?: AuthenticationProviderSpecificOptions
) => BaseAuthenticationProvider
>([
['basic', BasicAuthenticationProvider],
['kerberos', KerberosAuthenticationProvider],
['saml', SAMLAuthenticationProvider],
['token', TokenAuthenticationProvider],
['oidc', OIDCAuthenticationProvider],
]);
function assertRequest(request: KibanaRequest) {
if (!(request instanceof KibanaRequest)) {
throw new Error(`Request should be a valid "KibanaRequest" instance, was [${typeof request}].`);
}
}
function assertLoginAttempt(attempt: ProviderLoginAttempt) {
if (!attempt || !attempt.provider || typeof attempt.provider !== 'string') {
throw new Error('Login attempt should be an object with non-empty "provider" property.');
}
}
/**
* Instantiates authentication provider based on the provider key from config.
* @param providerType Provider type key.
* @param options Options to pass to provider's constructor.
* @param providerSpecificOptions Options that are specific to {@param providerType}.
*/
function instantiateProvider(
providerType: string,
options: AuthenticationProviderOptions,
providerSpecificOptions?: AuthenticationProviderSpecificOptions
) {
const ProviderClassName = providerMap.get(providerType);
if (!ProviderClassName) {
throw new Error(`Unsupported authentication provider name: ${providerType}.`);
}
return new ProviderClassName(options, providerSpecificOptions);
}
/**
* Authenticator is responsible for authentication of the request using chain of
* authentication providers. The chain is essentially a prioritized list of configured
* providers (typically of various types). The order of the list determines the order in
* which the providers will be consulted. During the authentication process, Authenticator
* will try to authenticate the request via one provider at a time. Once one of the
* providers successfully authenticates the request, the authentication is considered
* to be successful and the authenticated user will be associated with the request.
* If provider cannot authenticate the request, the next in line provider in the chain
* will be used. If all providers in the chain could not authenticate the request,
* the authentication is then considered to be unsuccessful and an authentication error
* will be returned.
*/
export class Authenticator {
/**
* List of configured and instantiated authentication providers.
*/
private readonly providers: Map<string, BaseAuthenticationProvider>;
/**
* Session duration in ms. If `null` session will stay active until the browser is closed.
*/
private readonly ttl: number | null = null;
/**
* Internal authenticator logger.
*/
private readonly logger: Logger;
/**
* Instantiates Authenticator and bootstrap configured providers.
* @param options Authenticator options.
*/
constructor(private readonly options: Readonly<AuthenticatorOptions>) {
this.logger = options.loggers.get('authenticator');
const providerCommonOptions = {
client: this.options.clusterClient,
basePath: this.options.basePath,
tokens: new Tokens({
client: this.options.clusterClient,
logger: this.options.loggers.get('tokens'),
}),
getServerBaseURL: this.options.getServerBaseURL,
};
const authProviders = this.options.config.authc.providers;
if (authProviders.length === 0) {
throw new Error(
'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.'
);
}
this.providers = new Map(
authProviders.map(providerType => {
const providerSpecificOptions = this.options.config.authc.hasOwnProperty(providerType)
? (this.options.config.authc as Record<string, any>)[providerType]
: undefined;
return [
providerType,
instantiateProvider(
providerType,
Object.freeze({ ...providerCommonOptions, logger: options.loggers.get(providerType) }),
providerSpecificOptions
),
] as [string, BaseAuthenticationProvider];
})
);
this.ttl = this.options.config.sessionTimeout;
}
/**
* Performs the initial login request using the provider login attempt description.
* @param request Request instance.
* @param attempt Login attempt description.
*/
async login(request: KibanaRequest, attempt: ProviderLoginAttempt) {
assertRequest(request);
assertLoginAttempt(attempt);
// If there is an attempt to login with a provider that isn't enabled, we should fail.
const provider = this.providers.get(attempt.provider);
if (provider === undefined) {
this.logger.debug(
`Login attempt for provider "${attempt.provider}" is detected, but it isn't enabled.`
);
return AuthenticationResult.notHandled();
}
this.logger.debug(`Performing login using "${attempt.provider}" provider.`);
const sessionStorage = this.options.sessionStorageFactory.asScoped(request);
// If we detect an existing session that belongs to a different provider than the one request to
// perform a login we should clear such session.
let existingSession = await this.getSessionValue(sessionStorage);
if (existingSession && existingSession.provider !== attempt.provider) {
this.logger.debug(
`Clearing existing session of another ("${existingSession.provider}") provider.`
);
sessionStorage.clear();
existingSession = null;
}
const authenticationResult = await provider.login(
request,
attempt.value,
existingSession && existingSession.state
);
// There are two possible cases when we'd want to clear existing state:
// 1. If provider owned the state (e.g. intermediate state used for multi step login), but failed
// to login, that likely means that state is not valid anymore and we should clear it.
// 2. Also provider can specifically ask to clear state by setting it to `null` even if
// authentication attempt didn't fail (e.g. custom realm could "pin" client/request identity to
// a server-side only session established during multi step login that relied on intermediate
// client-side state which isn't needed anymore).
const shouldClearSession =
authenticationResult.shouldClearState() ||
(authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401);
if (existingSession && shouldClearSession) {
sessionStorage.clear();
} else if (authenticationResult.shouldUpdateState()) {
sessionStorage.set({
state: authenticationResult.state,
provider: attempt.provider,
expires: this.ttl && Date.now() + this.ttl,
});
}
return authenticationResult;
}
/**
* Performs request authentication using configured chain of authentication providers.
* @param request Request instance.
*/
async authenticate(request: KibanaRequest) {
assertRequest(request);
const sessionStorage = this.options.sessionStorageFactory.asScoped(request);
const existingSession = await this.getSessionValue(sessionStorage);
let authenticationResult = AuthenticationResult.notHandled();
for (const [providerType, provider] of this.providerIterator(existingSession)) {
// Check if current session has been set by this provider.
const ownsSession = existingSession && existingSession.provider === providerType;
authenticationResult = await provider.authenticate(
request,
ownsSession ? existingSession!.state : null
);
this.updateSessionValue(sessionStorage, {
providerType,
isSystemAPIRequest: this.options.isSystemAPIRequest(request),
authenticationResult,
existingSession: ownsSession ? existingSession : null,
});
if (
authenticationResult.failed() ||
authenticationResult.succeeded() ||
authenticationResult.redirected()
) {
return authenticationResult;
}
}
return authenticationResult;
}
/**
* Deauthenticates current request.
* @param request Request instance.
*/
async logout(request: KibanaRequest) {
assertRequest(request);
const sessionStorage = this.options.sessionStorageFactory.asScoped(request);
const sessionValue = await this.getSessionValue(sessionStorage);
if (sessionValue) {
sessionStorage.clear();
return this.providers.get(sessionValue.provider)!.logout(request, sessionValue.state);
}
// Normally when there is no active session in Kibana, `logout` method shouldn't do anything
// and user will eventually be redirected to the home page to log in. But if SAML is supported there
// is a special case when logout is initiated by the IdP or another SP, then IdP will request _every_
// SP associated with the current user session to do the logout. So if Kibana (without active session)
// receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP
// with correct logout response and only Elasticsearch knows how to do that.
if (isSAMLRequestQuery(request.query) && this.providers.has('saml')) {
return this.providers.get('saml')!.logout(request);
}
return DeauthenticationResult.notHandled();
}
/**
* Returns provider iterator where providers are sorted in the order of priority (based on the session ownership).
* @param sessionValue Current session value.
*/
private *providerIterator(
sessionValue: ProviderSession | null
): IterableIterator<[string, BaseAuthenticationProvider]> {
// If there is no session to predict which provider to use first, let's use the order
// providers are configured in. Otherwise return provider that owns session first, and only then the rest
// of providers.
if (!sessionValue) {
yield* this.providers;
} else {
yield [sessionValue.provider, this.providers.get(sessionValue.provider)!];
for (const [providerType, provider] of this.providers) {
if (providerType !== sessionValue.provider) {
yield [providerType, provider];
}
}
}
}
/**
* Extracts session value for the specified request. Under the hood it can
* clear session if it belongs to the provider that is not available.
* @param sessionStorage Session storage instance.
*/
private async getSessionValue(sessionStorage: SessionStorage<ProviderSession>) {
let sessionValue = await sessionStorage.get();
// If for some reason we have a session stored for the provider that is not available
// (e.g. when user was logged in with one provider, but then configuration has changed
// and that provider is no longer available), then we should clear session entirely.
if (sessionValue && !this.providers.has(sessionValue.provider)) {
sessionStorage.clear();
sessionValue = null;
}
return sessionValue;
}
private updateSessionValue(
sessionStorage: SessionStorage<ProviderSession>,
{
providerType,
authenticationResult,
existingSession,
isSystemAPIRequest,
}: {
providerType: string;
authenticationResult: AuthenticationResult;
existingSession: ProviderSession | null;
isSystemAPIRequest: boolean;
}
) {
if (!existingSession && !authenticationResult.shouldUpdateState()) {
return;
}
// If authentication succeeds or requires redirect we should automatically extend existing user session,
// unless authentication has been triggered by a system API request. In case provider explicitly returns new
// state we should store it in the session regardless of whether it's a system API request or not.
const sessionCanBeUpdated =
(authenticationResult.succeeded() || authenticationResult.redirected()) &&
(authenticationResult.shouldUpdateState() || !isSystemAPIRequest);
// If provider owned the session, but failed to authenticate anyway, that likely means that
// session is not valid and we should clear it. Also provider can specifically ask to clear
// session by setting it to `null` even if authentication attempt didn't fail.
if (
authenticationResult.shouldClearState() ||
(authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401)
) {
sessionStorage.clear();
} else if (sessionCanBeUpdated) {
sessionStorage.set({
state: authenticationResult.shouldUpdateState()
? authenticationResult.state
: existingSession!.state,
provider: providerType,
expires: this.ttl && Date.now() + this.ttl,
});
}
}
}

View file

@ -4,24 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { requestFixture } from './__tests__/__fixtures__/request';
import { httpServerMock } from '../../../../../src/core/server/http/http_server.mocks';
import { canRedirectRequest } from './can_redirect_request';
describe('lib/can_redirect_request', () => {
describe('can_redirect_request', () => {
it('returns true if request does not have either a kbn-version or kbn-xsrf header', () => {
expect(canRedirectRequest(requestFixture())).toBe(true);
expect(canRedirectRequest(httpServerMock.createKibanaRequest())).toBe(true);
});
it('returns false if request has a kbn-version header', () => {
const request = requestFixture();
request.raw.req.headers['kbn-version'] = 'something';
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-version': 'something' } });
expect(canRedirectRequest(request)).toBe(false);
});
it('returns false if request has a kbn-xsrf header', () => {
const request = requestFixture();
request.raw.req.headers['kbn-xsrf'] = 'something';
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'something' } });
expect(canRedirectRequest(request)).toBe(false);
});

View file

@ -4,8 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Request } from 'hapi';
import { contains, get, has } from 'lodash';
import { KibanaRequest } from '../../../../../src/core/server';
const ROUTE_TAG_API = 'api';
const KIBANA_XSRF_HEADER = 'kbn-xsrf';
@ -16,11 +15,12 @@ const KIBANA_VERSION_HEADER = 'kbn-version';
* only for non-AJAX and non-API requests.
* @param request HapiJS request instance to check redirection possibility for.
*/
export function canRedirectRequest(request: Request) {
const hasVersionHeader = has(request.raw.req.headers, KIBANA_VERSION_HEADER);
const hasXsrfHeader = has(request.raw.req.headers, KIBANA_XSRF_HEADER);
export function canRedirectRequest(request: KibanaRequest) {
const headers = request.headers;
const hasVersionHeader = headers.hasOwnProperty(KIBANA_VERSION_HEADER);
const hasXsrfHeader = headers.hasOwnProperty(KIBANA_XSRF_HEADER);
const isApiRoute = contains(get(request, 'route.settings.tags'), ROUTE_TAG_API);
const isApiRoute = request.route.options.tags.includes(ROUTE_TAG_API);
const isAjaxRequest = hasVersionHeader || hasXsrfHeader;
return !isApiRoute && !isAjaxRequest;

View file

@ -0,0 +1,350 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('./authenticator');
import Boom from 'boom';
import { errors } from 'elasticsearch';
import { first } from 'rxjs/operators';
import {
loggingServiceMock,
coreMock,
httpServerMock,
httpServiceMock,
elasticsearchServiceMock,
} from '../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock';
import {
AuthenticationHandler,
AuthToolkit,
ClusterClient,
CoreSetup,
ElasticsearchErrorHelpers,
KibanaRequest,
LoggerFactory,
ScopedClusterClient,
} from '../../../../../src/core/server';
import { AuthenticatedUser } from '../../common/model';
import { ConfigType, createConfig$ } from '../config';
import { getErrorStatusCode } from '../errors';
import { LegacyAPI } from '../plugin';
import { AuthenticationResult } from './authentication_result';
import { setupAuthentication } from '.';
function mockXPackFeature({ isEnabled = true }: Partial<{ isEnabled: boolean }> = {}) {
return {
isEnabled: jest.fn().mockReturnValue(isEnabled),
isAvailable: jest.fn().mockReturnValue(true),
registerLicenseCheckResultsGenerator: jest.fn(),
getLicenseCheckResults: jest.fn(),
};
}
describe('setupAuthentication()', () => {
let mockSetupAuthenticationParams: {
config: ConfigType;
loggers: LoggerFactory;
getLegacyAPI(): LegacyAPI;
core: MockedKeys<CoreSetup>;
clusterClient: jest.Mocked<PublicMethodsOf<ClusterClient>>;
};
let mockXpackInfo: jest.Mocked<LegacyAPI['xpackInfo']>;
let mockScopedClusterClient: jest.Mocked<PublicMethodsOf<ScopedClusterClient>>;
beforeEach(async () => {
mockXpackInfo = {
isAvailable: jest.fn().mockReturnValue(true),
feature: jest.fn().mockReturnValue(mockXPackFeature()),
};
const mockConfig$ = createConfig$(
coreMock.createPluginInitializerContext({
encryptionKey: 'ab'.repeat(16),
secureCookies: true,
cookieName: 'my-sid-cookie',
authc: { providers: ['basic'] },
public: {},
}),
true
);
mockSetupAuthenticationParams = {
core: coreMock.createSetup(),
config: await mockConfig$.pipe(first()).toPromise(),
clusterClient: elasticsearchServiceMock.createClusterClient(),
loggers: loggingServiceMock.create(),
getLegacyAPI: jest.fn().mockReturnValue({ xpackInfo: mockXpackInfo }),
};
mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
mockSetupAuthenticationParams.clusterClient.asScoped.mockReturnValue(
(mockScopedClusterClient as unknown) as jest.Mocked<ScopedClusterClient>
);
});
afterEach(() => jest.clearAllMocks());
it('properly initializes session storage and registers auth handler', async () => {
const config = {
encryptionKey: 'ab'.repeat(16),
secureCookies: true,
cookieName: 'my-sid-cookie',
authc: { providers: ['basic'] },
};
await setupAuthentication(mockSetupAuthenticationParams);
expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledTimes(1);
expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledWith(
expect.any(Function)
);
expect(
mockSetupAuthenticationParams.core.http.createCookieSessionStorageFactory
).toHaveBeenCalledTimes(1);
expect(
mockSetupAuthenticationParams.core.http.createCookieSessionStorageFactory
).toHaveBeenCalledWith({
encryptionKey: config.encryptionKey,
isSecure: config.secureCookies,
name: config.cookieName,
validate: expect.any(Function),
});
});
describe('authentication handler', () => {
let authHandler: AuthenticationHandler;
let authenticate: jest.SpyInstance<Promise<AuthenticationResult>, [KibanaRequest]>;
let mockAuthToolkit: jest.Mocked<AuthToolkit>;
beforeEach(async () => {
mockAuthToolkit = httpServiceMock.createAuthToolkit();
await setupAuthentication(mockSetupAuthenticationParams);
authHandler = mockSetupAuthenticationParams.core.http.registerAuth.mock.calls[0][0];
authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0]
.authenticate;
});
it('replies with no credentials when security is disabled in elasticsearch', async () => {
const mockRequest = httpServerMock.createKibanaRequest();
mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false }));
await authHandler(mockRequest, mockAuthToolkit);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockAuthToolkit.rejected).not.toHaveBeenCalled();
expect(authenticate).not.toHaveBeenCalled();
});
it('continues request with credentials on success', async () => {
const mockRequest = httpServerMock.createKibanaRequest();
const mockUser = mockAuthenticatedUser();
const mockAuthHeaders = { authorization: 'Basic xxx' };
authenticate.mockResolvedValue(
AuthenticationResult.succeeded(mockUser, { authHeaders: mockAuthHeaders })
);
await authHandler(mockRequest, mockAuthToolkit);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.authenticated).toHaveBeenCalledWith({
state: mockUser,
headers: mockAuthHeaders,
});
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
expect(mockAuthToolkit.rejected).not.toHaveBeenCalled();
expect(authenticate).toHaveBeenCalledTimes(1);
expect(authenticate).toHaveBeenCalledWith(mockRequest);
});
it('redirects user if redirection is requested by the authenticator', async () => {
authenticate.mockResolvedValue(AuthenticationResult.redirectTo('/some/url'));
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
expect(mockAuthToolkit.redirected).toHaveBeenCalledTimes(1);
expect(mockAuthToolkit.redirected).toHaveBeenCalledWith('/some/url');
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.rejected).not.toHaveBeenCalled();
});
it('rejects with `Internal Server Error` when `authenticate` throws unhandled exception', async () => {
authenticate.mockRejectedValue(new Error('something went wrong'));
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1);
const [[error]] = mockAuthToolkit.rejected.mock.calls;
expect(error.message).toBe('something went wrong');
expect(getErrorStatusCode(error)).toBe(500);
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
});
it('rejects with wrapped original error when `authenticate` fails to authenticate user', async () => {
const esError = Boom.badRequest('some message');
authenticate.mockResolvedValue(AuthenticationResult.failed(esError));
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1);
const [[error]] = mockAuthToolkit.rejected.mock.calls;
expect(error).toBe(esError);
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
});
it('includes `WWW-Authenticate` header if `authenticate` fails to authenticate user and provides challenges', async () => {
const originalError = Boom.unauthorized('some message');
originalError.output.headers['WWW-Authenticate'] = [
'Basic realm="Access to prod", charset="UTF-8"',
'Basic',
'Negotiate',
] as any;
authenticate.mockResolvedValue(AuthenticationResult.failed(originalError, ['Negotiate']));
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1);
const [[error]] = mockAuthToolkit.rejected.mock.calls;
expect(error.message).toBe(originalError.message);
expect((error as Boom).output.headers).toEqual({ 'WWW-Authenticate': ['Negotiate'] });
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
});
it('returns `unauthorized` when authentication can not be handled', async () => {
authenticate.mockResolvedValue(AuthenticationResult.notHandled());
await authHandler(httpServerMock.createKibanaRequest(), mockAuthToolkit);
expect(mockAuthToolkit.rejected).toHaveBeenCalledTimes(1);
const [[error]] = mockAuthToolkit.rejected.mock.calls;
expect(error.message).toBe('Unauthorized');
expect(getErrorStatusCode(error)).toBe(401);
expect(mockAuthToolkit.authenticated).not.toHaveBeenCalled();
expect(mockAuthToolkit.redirected).not.toHaveBeenCalled();
});
});
describe('getServerBaseURL()', () => {
let getServerBaseURL: () => string;
beforeEach(async () => {
(mockSetupAuthenticationParams.getLegacyAPI as jest.Mock).mockReturnValue({
serverConfig: { protocol: 'test-protocol', hostname: 'test-hostname', port: 1234 },
});
await setupAuthentication(mockSetupAuthenticationParams);
getServerBaseURL = jest.requireMock('./authenticator').Authenticator.mock.calls[0][0]
.getServerBaseURL;
});
it('falls back to legacy server config if `public` config is not specified', async () => {
expect(getServerBaseURL()).toBe('test-protocol://test-hostname:1234');
});
it('respects `public` config if it is specified', async () => {
mockSetupAuthenticationParams.config.public = {
protocol: 'https',
} as ConfigType['public'];
expect(getServerBaseURL()).toBe('https://test-hostname:1234');
mockSetupAuthenticationParams.config.public = {
hostname: 'elastic.co',
} as ConfigType['public'];
expect(getServerBaseURL()).toBe('test-protocol://elastic.co:1234');
mockSetupAuthenticationParams.config.public = {
port: 4321,
} as ConfigType['public'];
expect(getServerBaseURL()).toBe('test-protocol://test-hostname:4321');
mockSetupAuthenticationParams.config.public = {
protocol: 'https',
hostname: 'elastic.co',
port: 4321,
} as ConfigType['public'];
expect(getServerBaseURL()).toBe('https://elastic.co:4321');
});
});
describe('getCurrentUser()', () => {
let getCurrentUser: (r: KibanaRequest) => Promise<AuthenticatedUser | null>;
beforeEach(async () => {
getCurrentUser = (await setupAuthentication(mockSetupAuthenticationParams)).getCurrentUser;
});
it('returns `null` if Security is disabled', async () => {
mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false }));
await expect(getCurrentUser(httpServerMock.createKibanaRequest())).resolves.toBe(null);
});
it('fails if `authenticate` call fails', async () => {
const failureReason = new Error('Something went wrong');
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
await expect(getCurrentUser(httpServerMock.createKibanaRequest())).rejects.toBe(
failureReason
);
});
it('returns result of `authenticate` call.', async () => {
const mockUser = mockAuthenticatedUser();
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockUser);
await expect(getCurrentUser(httpServerMock.createKibanaRequest())).resolves.toBe(mockUser);
});
});
describe('isAuthenticated()', () => {
let isAuthenticated: (r: KibanaRequest) => Promise<boolean>;
beforeEach(async () => {
isAuthenticated = (await setupAuthentication(mockSetupAuthenticationParams)).isAuthenticated;
});
it('returns `true` if Security is disabled', async () => {
mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false }));
await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(true);
});
it('returns `true` if `authenticate` succeeds.', async () => {
const mockUser = mockAuthenticatedUser();
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(mockUser);
await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(true);
});
it('returns `false` if `authenticate` fails with 401.', async () => {
const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(false);
});
it('fails if `authenticate` call fails with unknown reason', async () => {
const failureReason = new errors.BadRequest();
mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason);
await expect(isAuthenticated(httpServerMock.createKibanaRequest())).rejects.toBe(
failureReason
);
});
});
});

View file

@ -0,0 +1,157 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import {
ClusterClient,
CoreSetup,
KibanaRequest,
LoggerFactory,
} from '../../../../../src/core/server';
import { AuthenticatedUser } from '../../common/model';
import { ConfigType } from '../config';
import { getErrorStatusCode, wrapError } from '../errors';
import { Authenticator, ProviderSession } from './authenticator';
import { LegacyAPI } from '../plugin';
export { canRedirectRequest } from './can_redirect_request';
export { Authenticator, ProviderLoginAttempt } from './authenticator';
export { AuthenticationResult } from './authentication_result';
export { DeauthenticationResult } from './deauthentication_result';
export { BasicCredentials } from './providers';
interface SetupAuthenticationParams {
core: CoreSetup;
clusterClient: PublicMethodsOf<ClusterClient>;
config: ConfigType;
loggers: LoggerFactory;
getLegacyAPI(): LegacyAPI;
}
export async function setupAuthentication({
core,
clusterClient,
config,
loggers,
getLegacyAPI,
}: SetupAuthenticationParams) {
const authLogger = loggers.get('authentication');
const isSecurityFeatureDisabled = () => {
const xpackInfo = getLegacyAPI().xpackInfo;
return xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled();
};
/**
* Retrieves server protocol name/host name/port and merges it with `xpack.security.public` config
* to construct a server base URL (deprecated, used by the SAML provider only).
*/
const getServerBaseURL = () => {
const serverConfig = {
...getLegacyAPI().serverConfig,
...config.public,
};
return `${serverConfig.protocol}://${serverConfig.hostname}:${serverConfig.port}`;
};
/**
* Retrieves currently authenticated user associated with the specified request.
* @param request
*/
const getCurrentUser = async (request: KibanaRequest) => {
if (isSecurityFeatureDisabled()) {
return null;
}
return (await clusterClient
.asScoped(request)
.callAsCurrentUser('shield.authenticate')) as AuthenticatedUser;
};
const authenticator = new Authenticator({
clusterClient,
basePath: core.http.basePath,
config: { sessionTimeout: config.sessionTimeout, authc: config.authc },
isSystemAPIRequest: (request: KibanaRequest) => getLegacyAPI().isSystemAPIRequest(request),
getServerBaseURL,
loggers,
sessionStorageFactory: await core.http.createCookieSessionStorageFactory({
encryptionKey: config.encryptionKey,
isSecure: config.secureCookies,
name: config.cookieName,
validate: (sessionValue: ProviderSession) =>
!(sessionValue.expires && sessionValue.expires < Date.now()),
}),
});
authLogger.debug('Successfully initialized authenticator.');
core.http.registerAuth(async (request, t) => {
// If security is disabled continue with no user credentials and delete the client cookie as well.
if (isSecurityFeatureDisabled()) {
return t.authenticated();
}
let authenticationResult;
try {
authenticationResult = await authenticator.authenticate(request);
} catch (err) {
authLogger.error(err);
return t.rejected(wrapError(err));
}
if (authenticationResult.succeeded()) {
return t.authenticated({
state: (authenticationResult.user as unknown) as Record<string, unknown>,
headers: authenticationResult.authHeaders,
});
}
if (authenticationResult.redirected()) {
// Some authentication mechanisms may require user to be redirected to another location to
// initiate or complete authentication flow. It can be Kibana own login page for basic
// authentication (username and password) or arbitrary external page managed by 3rd party
// Identity Provider for SSO authentication mechanisms. Authentication provider is the one who
// decides what location user should be redirected to.
return t.redirected(authenticationResult.redirectURL!);
}
if (authenticationResult.failed()) {
authLogger.info(`Authentication attempt failed: ${authenticationResult.error!.message}`);
const error = wrapError(authenticationResult.error);
if (authenticationResult.challenges) {
error.output.headers['WWW-Authenticate'] = authenticationResult.challenges as any;
}
return t.rejected(error);
}
return t.rejected(Boom.unauthorized());
});
authLogger.debug('Successfully registered core authentication handler.');
return {
login: authenticator.login.bind(authenticator),
logout: authenticator.logout.bind(authenticator),
getCurrentUser,
isAuthenticated: async (request: KibanaRequest) => {
try {
await getCurrentUser(request);
} catch (err) {
// Don't swallow server errors.
if (getErrorStatusCode(err) !== 401) {
throw err;
}
return false;
}
return true;
},
};
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import sinon from 'sinon';
import { ScopedClusterClient } from '../../../../../../src/core/server';
import { Tokens } from '../tokens';
import { loggingServiceMock, httpServiceMock } from '../../../../../../src/core/server/mocks';
export type MockAuthenticationProviderOptions = ReturnType<
typeof mockAuthenticationProviderOptions
>;
export function mockScopedClusterClient(
client: MockAuthenticationProviderOptions['client'],
requestMatcher: sinon.SinonMatcher = sinon.match.any
) {
const scopedClusterClient = sinon.createStubInstance(ScopedClusterClient);
client.asScoped.withArgs(requestMatcher).returns(scopedClusterClient);
return scopedClusterClient;
}
export function mockAuthenticationProviderOptions() {
const logger = loggingServiceMock.create().get();
const basePath = httpServiceMock.createSetupContract().basePath;
basePath.get.mockReturnValue('/base-path');
return {
getServerBaseURL: () => 'test-protocol://test-hostname:1234',
client: { callAsInternalUser: sinon.stub(), asScoped: sinon.stub(), close: sinon.stub() },
logger,
basePath,
tokens: sinon.createStubInstance(Tokens),
};
}

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
KibanaRequest,
Logger,
HttpServiceSetup,
ClusterClient,
Headers,
} from '../../../../../../src/core/server';
import { AuthenticatedUser } from '../../../common/model';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { Tokens } from '../tokens';
/**
* Represents available provider options.
*/
export interface AuthenticationProviderOptions {
getServerBaseURL: () => string;
basePath: HttpServiceSetup['basePath'];
client: PublicMethodsOf<ClusterClient>;
logger: Logger;
tokens: PublicMethodsOf<Tokens>;
}
/**
* Represents available provider specific options.
*/
export type AuthenticationProviderSpecificOptions = Record<string, unknown>;
/**
* Base class that all authentication providers should extend.
*/
export abstract class BaseAuthenticationProvider {
/**
* Logger instance bound to a specific provider context.
*/
protected readonly logger: Logger;
/**
* Instantiates AuthenticationProvider.
* @param options Provider options object.
*/
constructor(protected readonly options: Readonly<AuthenticationProviderOptions>) {
this.logger = options.logger;
}
/**
* Performs initial login request and creates user session. Provider isn't required to implement
* this method if it doesn't support initial login request.
* @param request Request instance.
* @param loginAttempt Login attempt associated with the provider.
* @param [state] Optional state object associated with the provider.
*/
async login(
request: KibanaRequest,
loginAttempt: unknown,
state?: unknown
): Promise<AuthenticationResult> {
return AuthenticationResult.notHandled();
}
/**
* Performs request authentication based on the session created during login or other information
* associated with the request (e.g. `Authorization` HTTP header).
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
abstract authenticate(request: KibanaRequest, state?: unknown): Promise<AuthenticationResult>;
/**
* Invalidates user session associated with the request.
* @param request Request instance.
* @param [state] Optional state object associated with the provider that needs to be invalidated.
*/
abstract logout(request: KibanaRequest, state?: unknown): Promise<DeauthenticationResult>;
/**
* Queries Elasticsearch `_authenticate` endpoint to authenticate request and retrieve the user
* information of authenticated user.
* @param request Request instance.
* @param [authHeaders] Optional `Headers` dictionary to send with the request.
*/
protected async getUser(request: KibanaRequest, authHeaders: Headers = {}) {
return (await this.options.client
.asScoped({ headers: { ...request.headers, ...authHeaders } })
.callAsCurrentUser('shield.authenticate')) as AuthenticatedUser;
}
}

View file

@ -5,34 +5,80 @@
*/
import sinon from 'sinon';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../login_attempt';
import { mockAuthenticationProviderOptions } from './base.mock';
import { httpServerMock } from '../../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { mockAuthenticationProviderOptions, mockScopedClusterClient } from './base.mock';
import { BasicAuthenticationProvider, BasicCredentials } from './basic';
function generateAuthorizationHeader(username: string, password: string) {
const {
headers: { authorization },
} = BasicCredentials.decorateRequest(requestFixture(), username, password);
} = BasicCredentials.decorateRequest(
{ headers: {} as Record<string, string> },
username,
password
);
return authorization;
return authorization as string;
}
describe('BasicAuthenticationProvider', () => {
describe('`authenticate` method', () => {
let provider: BasicAuthenticationProvider;
let callWithRequest: sinon.SinonStub;
beforeEach(() => {
const providerOptions = mockAuthenticationProviderOptions();
callWithRequest = providerOptions.client.callWithRequest;
provider = new BasicAuthenticationProvider(providerOptions);
let provider: BasicAuthenticationProvider;
let mockOptions: ReturnType<typeof mockAuthenticationProviderOptions>;
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions();
provider = new BasicAuthenticationProvider(mockOptions);
});
describe('`login` method', () => {
it('succeeds with valid login attempt, creates session and authHeaders', async () => {
const user = mockAuthenticatedUser();
const credentials = { username: 'user', password: 'password' };
const authorization = generateAuthorizationHeader(credentials.username, credentials.password);
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.login(
httpServerMock.createKibanaRequest(),
credentials
);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ authorization });
expect(authenticationResult.authHeaders).toEqual({ authorization });
});
it('fails if user cannot be retrieved during login attempt', async () => {
const request = httpServerMock.createKibanaRequest();
const credentials = { username: 'user', password: 'password' };
const authorization = generateAuthorizationHeader(credentials.username, credentials.password);
const authenticationError = new Error('Some error');
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(authenticationError);
const authenticationResult = await provider.login(request, credentials);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toEqual(authenticationError);
});
});
describe('`authenticate` method', () => {
it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => {
// Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and
// avoid triggering of redirect logic.
const authenticationResult = await provider.authenticate(
requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }),
httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }),
null
);
@ -41,75 +87,51 @@ describe('BasicAuthenticationProvider', () => {
it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({
path: '/some-path # that needs to be encoded',
basePath: '/s/foo',
}),
httpServerMock.createKibanaRequest({ path: '/s/foo/some-path # that needs to be encoded' }),
null
);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'/base-path/login?next=%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded'
'/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded'
);
});
it('does not handle authentication if state exists, but authorization property is missing.', async () => {
const authenticationResult = await provider.authenticate(requestFixture(), {});
const authenticationResult = await provider.authenticate(
httpServerMock.createKibanaRequest(),
{}
);
expect(authenticationResult.notHandled()).toBe(true);
});
it('succeeds with valid login attempt and stores in session', async () => {
const user = { username: 'user' };
const authorization = generateAuthorizationHeader('user', 'password');
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ authorization });
sinon.assert.calledOnce(callWithRequest);
});
it('succeeds if only `authorization` header is available.', async () => {
const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password');
const user = { username: 'user' };
const request = httpServerMock.createKibanaRequest({
headers: { authorization: generateAuthorizationHeader('user', 'password') },
});
const user = mockAuthenticatedUser();
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: request.headers }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
sinon.assert.calledOnce(callWithRequest);
});
it('does not return session state for header-based auth', async () => {
const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password');
const user = { username: 'user' };
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.state).not.toEqual({
authorization: request.headers.authorization,
});
// Session state and authHeaders aren't returned for header-based auth.
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
});
it('succeeds if only state is available.', async () => {
const request = requestFixture();
const user = { username: 'user' };
const request = httpServerMock.createKibanaRequest();
const user = mockAuthenticatedUser();
const authorization = generateAuthorizationHeader('user', 'password');
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, { authorization });
@ -117,27 +139,29 @@ describe('BasicAuthenticationProvider', () => {
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toBeUndefined();
sinon.assert.calledOnce(callWithRequest);
expect(authenticationResult.authHeaders).toEqual({ authorization });
});
it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => {
const request = requestFixture({ headers: { authorization: 'Bearer ***' } });
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Bearer ***' },
});
const authorization = generateAuthorizationHeader('user', 'password');
const authenticationResult = await provider.authenticate(request, { authorization });
sinon.assert.notCalled(callWithRequest);
sinon.assert.notCalled(mockOptions.client.asScoped);
expect(request.headers.authorization).toBe('Bearer ***');
expect(authenticationResult.notHandled()).toBe(true);
});
it('fails if state contains invalid credentials.', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const authorization = generateAuthorizationHeader('user', 'password');
const authenticationError = new Error('Forbidden');
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(authenticationError);
const authenticationResult = await provider.authenticate(request, { authorization });
@ -146,45 +170,45 @@ describe('BasicAuthenticationProvider', () => {
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.error).toBe(authenticationError);
sinon.assert.calledOnce(callWithRequest);
});
it('authenticates only via `authorization` header even if state is available.', async () => {
const request = BasicCredentials.decorateRequest(requestFixture(), 'user', 'password');
const user = { username: 'user' };
const authorization = generateAuthorizationHeader('user1', 'password2');
const request = httpServerMock.createKibanaRequest({
headers: { authorization: generateAuthorizationHeader('user', 'password') },
});
const user = mockAuthenticatedUser();
// GetUser will be called with request's `authorization` header.
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: request.headers }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, { authorization });
const authorizationInState = generateAuthorizationHeader('user1', 'password2');
const authenticationResult = await provider.authenticate(request, {
authorization: authorizationInState,
});
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).not.toEqual({
authorization: request.headers.authorization,
});
sinon.assert.calledOnce(callWithRequest);
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
});
});
describe('`deauthenticate` method', () => {
let provider: BasicAuthenticationProvider;
beforeEach(() => {
provider = new BasicAuthenticationProvider(mockAuthenticationProviderOptions());
});
describe('`logout` method', () => {
it('always redirects to the login page.', async () => {
const request = requestFixture();
const deauthenticateResult = await provider.deauthenticate(request);
const request = httpServerMock.createKibanaRequest();
const deauthenticateResult = await provider.logout(request);
expect(deauthenticateResult.redirected()).toBe(true);
expect(deauthenticateResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT');
});
it('passes query string parameters to the login page.', async () => {
const request = requestFixture({ search: '?next=%2Fapp%2Fml&msg=SESSION_EXPIRED' });
const deauthenticateResult = await provider.deauthenticate(request);
const request = httpServerMock.createKibanaRequest({
query: { next: '/app/ml', msg: 'SESSION_EXPIRED' },
});
const deauthenticateResult = await provider.logout(request);
expect(deauthenticateResult.redirected()).toBe(true);
expect(deauthenticateResult.redirectURL).toBe(
'/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED'
@ -215,8 +239,8 @@ describe('BasicAuthenticationProvider', () => {
});
it('`decorateRequest` correctly sets authorization header.', () => {
const oneRequest = requestFixture();
const anotherRequest = requestFixture({ headers: { authorization: 'Basic ***' } });
const oneRequest = { headers: {} as Record<string, string> };
const anotherRequest = { headers: { authorization: 'Basic ***' } };
BasicCredentials.decorateRequest(oneRequest, 'one-user', 'one-password');
BasicCredentials.decorateRequest(anotherRequest, 'another-user', 'another-password');

View file

@ -0,0 +1,209 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable max-classes-per-file */
import { FakeRequest, KibanaRequest } from '../../../../../../src/core/server';
import { canRedirectRequest } from '../can_redirect_request';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { BaseAuthenticationProvider } from './base';
/**
* Utility class that knows how to decorate request with proper Basic authentication headers.
*/
export class BasicCredentials {
/**
* Takes provided `username` and `password`, transforms them into proper `Basic ***` authorization
* header and decorates passed request with it.
* @param request Request instance.
* @param username User name.
* @param password User password.
*/
public static decorateRequest<T extends KibanaRequest | FakeRequest>(
request: T,
username: string,
password: string
) {
const typeOfRequest = typeof request;
if (!request || typeOfRequest !== 'object') {
throw new Error('Request should be a valid object.');
}
if (!username || typeof username !== 'string') {
throw new Error('Username should be a valid non-empty string.');
}
if (!password || typeof password !== 'string') {
throw new Error('Password should be a valid non-empty string.');
}
const basicCredentials = Buffer.from(`${username}:${password}`).toString('base64');
request.headers.authorization = `Basic ${basicCredentials}`;
return request;
}
}
/**
* Describes the parameters that are required by the provider to process the initial login request.
*/
interface ProviderLoginAttempt {
username: string;
password: string;
}
/**
* The state supported by the provider.
*/
interface ProviderState {
/**
* Content of the HTTP authorization header (`Basic base-64-of-username:password`) that is based
* on user credentials used at login time and that should be provided with every request to the
* Elasticsearch on behalf of the authenticated user.
*/
authorization?: string;
}
/**
* Provider that supports request authentication via Basic HTTP Authentication.
*/
export class BasicAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Performs initial login request using username and password.
* @param request Request instance.
* @param attempt User credentials.
* @param [state] Optional state object associated with the provider.
*/
public async login(
request: KibanaRequest,
{ username, password }: ProviderLoginAttempt,
state?: ProviderState | null
) {
this.logger.debug('Trying to perform a login.');
try {
const { headers: authHeaders } = BasicCredentials.decorateRequest(
{ headers: {} },
username,
password
);
const user = await this.getUser(request, authHeaders);
this.logger.debug('Login has been successfully performed.');
return AuthenticationResult.succeeded(user, { authHeaders, state: authHeaders });
} catch (err) {
this.logger.debug(`Failed to perform a login: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
/**
* Performs request authentication using Basic HTTP Authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`);
// try header-based auth
const {
authenticationResult: headerAuthResult,
headerNotRecognized,
} = await this.authenticateViaHeader(request);
if (headerNotRecognized) {
return headerAuthResult;
}
let authenticationResult = headerAuthResult;
if (authenticationResult.notHandled() && state) {
authenticationResult = await this.authenticateViaState(request, state);
} else if (authenticationResult.notHandled() && canRedirectRequest(request)) {
// If we couldn't handle authentication let's redirect user to the login page.
const nextURL = encodeURIComponent(
`${this.options.basePath.get(request)}${request.url.path}`
);
authenticationResult = AuthenticationResult.redirectTo(
`${this.options.basePath.get(request)}/login?next=${nextURL}`
);
}
return authenticationResult;
}
/**
* Redirects user to the login page preserving query string parameters.
* @param request Request instance.
*/
public async logout(request: KibanaRequest) {
// Query string may contain the path where logout has been called or
// logout reason that login page may need to know.
const queryString = request.url.search || `?msg=LOGGED_OUT`;
return DeauthenticationResult.redirectTo(
`${this.options.basePath.get(request)}/login${queryString}`
);
}
/**
* Validates whether request contains `Basic ***` Authorization header and just passes it
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: KibanaRequest) {
this.logger.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization || typeof authorization !== 'string') {
this.logger.debug('Authorization header is not presented.');
return { authenticationResult: AuthenticationResult.notHandled() };
}
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'basic') {
this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`);
return {
authenticationResult: AuthenticationResult.notHandled(),
headerNotRecognized: true,
};
}
try {
const user = await this.getUser(request);
this.logger.debug('Request has been authenticated via header.');
return { authenticationResult: AuthenticationResult.succeeded(user) };
} catch (err) {
this.logger.debug(`Failed to authenticate request via header: ${err.message}`);
return { authenticationResult: AuthenticationResult.failed(err) };
}
}
/**
* Tries to extract authorization header from the state and adds it to the request before
* it's forwarded to Elasticsearch backend.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: KibanaRequest, { authorization }: ProviderState) {
this.logger.debug('Trying to authenticate via state.');
if (!authorization) {
this.logger.debug('Access token is not found in state.');
return AuthenticationResult.notHandled();
}
try {
const authHeaders = { authorization };
const user = await this.getUser(request, authHeaders);
this.logger.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user, { authHeaders });
} catch (err) {
this.logger.debug(`Failed to authenticate request via state: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
}

View file

@ -7,10 +7,10 @@
export {
BaseAuthenticationProvider,
AuthenticationProviderOptions,
RequestWithLoginAttempt,
AuthenticationProviderSpecificOptions,
} from './base';
export { BasicAuthenticationProvider, BasicCredentials } from './basic';
export { KerberosAuthenticationProvider } from './kerberos';
export { SAMLAuthenticationProvider } from './saml';
export { SAMLAuthenticationProvider, isSAMLRequestQuery } from './saml';
export { TokenAuthenticationProvider } from './token';
export { OIDCAuthenticationProvider } from './oidc';

View file

@ -0,0 +1,478 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { errors } from 'elasticsearch';
import sinon from 'sinon';
import { httpServerMock } from '../../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import {
MockAuthenticationProviderOptions,
mockAuthenticationProviderOptions,
mockScopedClusterClient,
} from './base.mock';
import { KerberosAuthenticationProvider } from './kerberos';
import { ElasticsearchErrorHelpers } from '../../../../../../src/core/server/elasticsearch';
describe('KerberosAuthenticationProvider', () => {
let provider: KerberosAuthenticationProvider;
let mockOptions: MockAuthenticationProviderOptions;
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions();
provider = new KerberosAuthenticationProvider(mockOptions);
});
describe('`authenticate` method', () => {
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Basic some:credentials' },
});
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.notCalled(mockOptions.client.asScoped);
sinon.assert.notCalled(mockOptions.client.callAsInternalUser);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests that can be authenticated without `Negotiate` header.', async () => {
const request = httpServerMock.createKibanaRequest();
mockScopedClusterClient(
mockOptions.client,
sinon.match({
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
})
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves({});
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests if backend does not support Kerberos.', async () => {
const request = httpServerMock.createKibanaRequest();
mockScopedClusterClient(
mockOptions.client,
sinon.match({
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
})
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()));
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('fails if state is present, but backend does not support Kerberos.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' };
mockScopedClusterClient(mockOptions.client)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()));
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toBeUndefined();
});
it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => {
const request = httpServerMock.createKibanaRequest();
mockScopedClusterClient(
mockOptions.client,
sinon.match({
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
})
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(
ElasticsearchErrorHelpers.decorateNotAuthorizedError(
new (errors.AuthenticationException as any)('Unauthorized', {
body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } },
})
)
);
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
it('fails if request authentication is failed with non-401 error.', async () => {
const request = httpServerMock.createKibanaRequest();
mockScopedClusterClient(mockOptions.client)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(new errors.ServiceUnavailable());
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('status', 503);
expect(authenticationResult.challenges).toBeUndefined();
});
it('gets an token pair in exchange to SPNEGO one and stores it in the state.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'negotiate spnego' },
});
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer some-token' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
mockOptions.client.callAsInternalUser
.withArgs('shield.getAccessToken')
.resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' });
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.getAccessToken',
{ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } }
);
expect(request.headers.authorization).toBe('negotiate spnego');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer some-token' });
expect(authenticationResult.state).toEqual({
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
});
});
it('fails if could not retrieve an access token in exchange to SPNEGO one.', async () => {
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'negotiate spnego' },
});
const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
mockOptions.client.callAsInternalUser
.withArgs('shield.getAccessToken')
.rejects(failureReason);
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.getAccessToken',
{ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } }
);
expect(request.headers.authorization).toBe('negotiate spnego');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.challenges).toBeUndefined();
});
it('fails if could not retrieve user using the new access token.', async () => {
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'negotiate spnego' },
});
const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer some-token' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(failureReason);
mockOptions.client.callAsInternalUser
.withArgs('shield.getAccessToken')
.resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' });
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.getAccessToken',
{ body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' } }
);
expect(request.headers.authorization).toBe('negotiate spnego');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
expect(authenticationResult.challenges).toBeUndefined();
});
it('succeeds if state contains a valid token.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const authorization = `Bearer ${tokenPair.accessToken}`;
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.authHeaders).toEqual({ authorization });
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
});
it('succeeds with valid session even if requiring a token refresh', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()));
mockOptions.tokens.refresh
.withArgs(tokenPair.refreshToken)
.resolves({ accessToken: 'newfoo', refreshToken: 'newbar' });
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer newfoo' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledOnce(mockOptions.tokens.refresh);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer newfoo' });
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' });
expect(request.headers).not.toHaveProperty('authorization');
});
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const failureReason = new errors.InternalServerError('Token is not valid!');
const scopedClusterClient = mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
);
scopedClusterClient.callAsCurrentUser.withArgs('shield.authenticate').rejects(failureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
sinon.assert.neverCalledWith(scopedClusterClient.callAsCurrentUser, 'shield.getAccessToken');
});
it('fails with `Negotiate` challenge if both access and refresh tokens from the state are expired and backend supports Kerberos.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' };
mockScopedClusterClient(mockOptions.client)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(
ElasticsearchErrorHelpers.decorateNotAuthorizedError(
new (errors.AuthenticationException as any)('Unauthorized', {
body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } },
})
)
);
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
it('fails with `Negotiate` challenge if both access and refresh token documents are missing and backend supports Kerberos.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: {} });
const tokenPair = { accessToken: 'missing-token', refreshToken: 'missing-refresh-token' };
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({
statusCode: 500,
body: { error: { reason: 'token document is missing and must be present' } },
});
mockScopedClusterClient(
mockOptions.client,
sinon.match({
headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` },
})
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(
ElasticsearchErrorHelpers.decorateNotAuthorizedError(
new (errors.AuthenticationException as any)('Unauthorized', {
body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } },
})
)
);
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toHaveProperty('output.statusCode', 401);
expect(authenticationResult.challenges).toEqual(['Negotiate']);
});
it('succeeds if `authorization` contains a valid token.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Bearer some-valid-token' },
});
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer some-valid-token' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
});
it('fails if token from `authorization` header is rejected.', async () => {
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Bearer some-invalid-token' },
});
const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(failureReason);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Bearer some-invalid-token' },
});
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer some-invalid-token' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(failureReason);
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
});
describe('`logout` method', () => {
it('returns `notHandled` if state is not presented.', async () => {
const request = httpServerMock.createKibanaRequest();
let deauthenticateResult = await provider.logout(request);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.logout(request, null);
expect(deauthenticateResult.notHandled()).toBe(true);
sinon.assert.notCalled(mockOptions.tokens.invalidate);
});
it('fails if `tokens.invalidate` fails', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const failureReason = new Error('failed to delete token');
mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason);
const authenticationResult = await provider.logout(request, tokenPair);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to `/logged_out` page if tokens are invalidated successfully.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
mockOptions.tokens.invalidate.withArgs(tokenPair).resolves();
const authenticationResult = await provider.logout(request, tokenPair);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/logged_out');
});
});
});

View file

@ -0,0 +1,279 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import {
ElasticsearchError,
ElasticsearchErrorHelpers,
KibanaRequest,
} from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { BaseAuthenticationProvider } from './base';
import { Tokens, TokenPair } from '../tokens';
/**
* The state supported by the provider.
*/
type ProviderState = TokenPair;
/**
* Parses request's `Authorization` HTTP header if present and extracts authentication scheme.
* @param request Request instance to extract authentication scheme for.
*/
function getRequestAuthenticationScheme(request: KibanaRequest) {
const authorization = request.headers.authorization;
if (!authorization || typeof authorization !== 'string') {
return '';
}
return authorization.split(/\s+/)[0].toLowerCase();
}
/**
* Provider that supports Kerberos request authentication.
*/
export class KerberosAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Performs Kerberos request authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`);
const authenticationScheme = getRequestAuthenticationScheme(request);
if (
authenticationScheme &&
(authenticationScheme !== 'negotiate' && authenticationScheme !== 'bearer')
) {
this.logger.debug(`Unsupported authentication scheme: ${authenticationScheme}`);
return AuthenticationResult.notHandled();
}
let authenticationResult = AuthenticationResult.notHandled();
if (authenticationScheme) {
// We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore.
authenticationResult =
authenticationScheme === 'bearer'
? await this.authenticateWithBearerScheme(request)
: await this.authenticateWithNegotiateScheme(request);
}
if (state && authenticationResult.notHandled()) {
authenticationResult = await this.authenticateViaState(request, state);
if (
authenticationResult.failed() &&
Tokens.isAccessTokenExpiredError(authenticationResult.error)
) {
authenticationResult = await this.authenticateViaRefreshToken(request, state);
}
}
// If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can
// start authentication mechanism negotiation, otherwise just return authentication result we have.
return authenticationResult.notHandled()
? await this.authenticateViaSPNEGO(request, state)
: authenticationResult;
}
/**
* Invalidates access token retrieved in exchange for SPNEGO token if it exists.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
public async logout(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to log user out via ${request.url.path}.`);
if (!state) {
this.logger.debug('There is no access token invalidate.');
return DeauthenticationResult.notHandled();
}
try {
await this.options.tokens.invalidate(state);
} catch (err) {
this.logger.debug(`Failed invalidating access and/or refresh tokens: ${err.message}`);
return DeauthenticationResult.failed(err);
}
return DeauthenticationResult.redirectTo('/logged_out');
}
/**
* Tries to authenticate request with `Negotiate ***` Authorization header by passing it to the Elasticsearch backend to
* get an access token in exchange.
* @param request Request instance.
*/
private async authenticateWithNegotiateScheme(request: KibanaRequest) {
this.logger.debug('Trying to authenticate request using "Negotiate" authentication scheme.');
const [, kerberosTicket] = (request.headers.authorization as string).split(/\s+/);
// First attempt to exchange SPNEGO token for an access token.
let tokens: { access_token: string; refresh_token: string };
try {
tokens = await this.options.client.callAsInternalUser('shield.getAccessToken', {
body: { grant_type: '_kerberos', kerberos_ticket: kerberosTicket },
});
} catch (err) {
this.logger.debug(`Failed to exchange SPNEGO token for an access token: ${err.message}`);
return AuthenticationResult.failed(err);
}
this.logger.debug('Get token API request to Elasticsearch successful');
try {
// Then attempt to query for the user details using the new token
const authHeaders = { authorization: `Bearer ${tokens.access_token}` };
const user = await this.getUser(request, authHeaders);
this.logger.debug('User has been authenticated with new access token');
return AuthenticationResult.succeeded(user, {
authHeaders,
state: { accessToken: tokens.access_token, refreshToken: tokens.refresh_token },
});
} catch (err) {
this.logger.debug(`Failed to authenticate request via access token: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
/**
* Tries to authenticate request with `Bearer ***` Authorization header by passing it to the Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateWithBearerScheme(request: KibanaRequest) {
this.logger.debug('Trying to authenticate request using "Bearer" authentication scheme.');
try {
const user = await this.getUser(request);
this.logger.debug('Request has been authenticated using "Bearer" authentication scheme.');
return AuthenticationResult.succeeded(user);
} catch (err) {
this.logger.debug(
`Failed to authenticate request using "Bearer" authentication scheme: ${err.message}`
);
return AuthenticationResult.failed(err);
}
}
/**
* Tries to extract access token from state and adds it to the request before it's
* forwarded to Elasticsearch backend.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) {
this.logger.debug('Trying to authenticate via state.');
if (!accessToken) {
this.logger.debug('Access token is not found in state.');
return AuthenticationResult.notHandled();
}
try {
const authHeaders = { authorization: `Bearer ${accessToken}` };
const user = await this.getUser(request, authHeaders);
this.logger.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user, { authHeaders });
} catch (err) {
this.logger.debug(`Failed to authenticate request via state: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
/**
* This method is only called when authentication via access token stored in the state failed because of expired
* token. So we should use refresh token, that is also stored in the state, to extend expired access token and
* authenticate user with it.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(request: KibanaRequest, state: ProviderState) {
this.logger.debug('Trying to refresh access token.');
let refreshedTokenPair: TokenPair | null;
try {
refreshedTokenPair = await this.options.tokens.refresh(state.refreshToken);
} catch (err) {
return AuthenticationResult.failed(err);
}
// If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO.
if (refreshedTokenPair === null) {
this.logger.debug(
'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.'
);
return this.authenticateViaSPNEGO(request, state);
}
try {
const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` };
const user = await this.getUser(request, authHeaders);
this.logger.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair });
} catch (err) {
this.logger.debug(
`Failed to authenticate user using newly refreshed access token: ${err.message}`
);
return AuthenticationResult.failed(err);
}
}
/**
* Tries to query Elasticsearch and see if we can rely on SPNEGO to authenticate user.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
private async authenticateViaSPNEGO(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug('Trying to authenticate request via SPNEGO.');
// Try to authenticate current request with Elasticsearch to see whether it supports SPNEGO.
let elasticsearchError: ElasticsearchError;
try {
await this.getUser(request, {
// We should send a fake SPNEGO token to Elasticsearch to make sure Kerberos realm is included
// into authentication chain and adds a `WWW-Authenticate: Negotiate` header to the error
// response. Otherwise it may not be even consulted if request can be authenticated by other
// means (e.g. when anonymous access is enabled in Elasticsearch).
authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}`,
});
this.logger.debug('Request was not supposed to be authenticated, ignoring result.');
return AuthenticationResult.notHandled();
} catch (err) {
// Fail immediately if we get unexpected error (e.g. ES isn't available). We should not touch
// session cookie in this case.
if (!ElasticsearchErrorHelpers.isNotAuthorizedError(err)) {
return AuthenticationResult.failed(err);
}
elasticsearchError = err;
}
const challenges = ([] as string[]).concat(
elasticsearchError.output.headers['WWW-Authenticate']
);
if (challenges.some(challenge => challenge.toLowerCase() === 'negotiate')) {
this.logger.debug(`SPNEGO is supported by the backend, challenges are: [${challenges}].`);
return AuthenticationResult.failed(Boom.unauthorized(), ['Negotiate']);
}
this.logger.debug(`SPNEGO is not supported by the backend, challenges are: [${challenges}].`);
// If we failed to do SPNEGO and have a session with expired token that belongs to Kerberos
// authentication provider then it means Elasticsearch isn't configured to use Kerberos anymore.
// In this case we should reply with the `401` error and allow Authenticator to clear the cookie.
// Otherwise give a chance to the next authentication provider to authenticate request.
return state
? AuthenticationResult.failed(Boom.unauthorized())
: AuthenticationResult.notHandled();
}
}

View file

@ -6,30 +6,27 @@
import sinon from 'sinon';
import Boom from 'boom';
import { LoginAttempt } from '../login_attempt';
import { mockAuthenticationProviderOptions } from './base.mock';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { httpServerMock } from '../../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import {
MockAuthenticationProviderOptions,
mockAuthenticationProviderOptions,
mockScopedClusterClient,
} from './base.mock';
import { OIDCAuthenticationProvider } from './oidc';
describe('OIDCAuthenticationProvider', () => {
let provider: OIDCAuthenticationProvider;
let callWithRequest: sinon.SinonStub;
let callWithInternalUser: sinon.SinonStub;
let tokens: ReturnType<typeof mockAuthenticationProviderOptions>['tokens'];
let mockOptions: MockAuthenticationProviderOptions;
beforeEach(() => {
const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' });
const providerSpecificOptions = { realm: 'oidc1' };
callWithRequest = providerOptions.client.callWithRequest;
callWithInternalUser = providerOptions.client.callWithInternalUser;
tokens = providerOptions.tokens;
provider = new OIDCAuthenticationProvider(providerOptions, providerSpecificOptions);
mockOptions = mockAuthenticationProviderOptions();
provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' });
});
it('throws if `realm` option is not specified', () => {
const providerOptions = mockAuthenticationProviderOptions({ basePath: '/test-base-path' });
const providerOptions = mockAuthenticationProviderOptions();
expect(() => new OIDCAuthenticationProvider(providerOptions)).toThrowError(
'Realm name must be specified'
@ -42,75 +39,11 @@ describe('OIDCAuthenticationProvider', () => {
);
});
describe('`authenticate` method', () => {
it('does not handle AJAX request that can not be authenticated.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
describe('`login` method', () => {
it('redirects third party initiated login attempts to the OpenId Connect Provider.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' });
const authenticationResult = await provider.authenticate(request, null);
expect(authenticationResult.notHandled()).toBe(true);
});
it('does not handle requests with non-empty `loginAttempt`.', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
sinon.assert.notCalled(callWithInternalUser);
expect(authenticationResult.notHandled()).toBe(true);
});
it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
callWithInternalUser.withArgs('shield.oidcPrepare').resolves({
state: 'statevalue',
nonce: 'noncevalue',
redirect:
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc',
});
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', {
body: { realm: `oidc1` },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc'
);
expect(authenticationResult.state).toEqual({
state: 'statevalue',
nonce: 'noncevalue',
nextURL: `/s/foo/some-path`,
});
});
it('redirects third party initiated authentications to the OpenId Connect Provider.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc',
search: '?iss=theissuer&login_hint=loginhint',
basePath: '/s/foo',
});
callWithInternalUser.withArgs('shield.oidcPrepare').resolves({
mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({
state: 'statevalue',
nonce: 'noncevalue',
redirect:
@ -122,10 +55,13 @@ describe('OIDCAuthenticationProvider', () => {
'&login_hint=loginhint',
});
const authenticationResult = await provider.authenticate(request, null);
const authenticationResult = await provider.login(request, {
iss: 'theissuer',
loginHint: 'loginhint',
});
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', {
body: { iss: `theissuer`, login_hint: `loginhint` },
sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', {
body: { iss: 'theissuer', login_hint: 'loginhint' },
});
expect(authenticationResult.redirected()).toBe(true);
@ -140,52 +76,39 @@ describe('OIDCAuthenticationProvider', () => {
expect(authenticationResult.state).toEqual({
state: 'statevalue',
nonce: 'noncevalue',
nextURL: `/s/foo/`,
nextURL: '/base-path/',
});
});
it('fails if OpenID Connect authentication request preparation fails.', async () => {
const request = requestFixture({ path: '/some-path' });
const failureReason = new Error('Realm is misconfigured!');
callWithInternalUser.withArgs('shield.oidcPrepare').returns(Promise.reject(failureReason));
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', {
body: { realm: `oidc1` },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('gets token and redirects user to requested URL if OIDC authentication response is valid.', async () => {
const request = requestFixture({
const request = httpServerMock.createKibanaRequest({
path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
search: '?code=somecodehere&state=somestatehere',
});
callWithInternalUser
mockOptions.client.callAsInternalUser
.withArgs('shield.oidcAuthenticate')
.resolves({ access_token: 'some-token', refresh_token: 'some-refresh-token' });
const authenticationResult = await provider.authenticate(request, {
state: 'statevalue',
nonce: 'noncevalue',
nextURL: '/test-base-path/some-path',
});
const authenticationResult = await provider.login(
request,
{ code: 'somecodehere' },
{ state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path' }
);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', {
body: {
state: 'statevalue',
nonce: 'noncevalue',
redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
},
});
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.oidcAuthenticate',
{
body: {
state: 'statevalue',
nonce: 'noncevalue',
redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
},
}
);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/some-path');
expect(authenticationResult.redirectURL).toBe('/base-path/some-path');
expect(authenticationResult.state).toEqual({
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
@ -193,16 +116,15 @@ describe('OIDCAuthenticationProvider', () => {
});
it('fails if authentication response is presented but session state does not contain the state parameter.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc',
search: '?code=somecodehere&state=somestatehere',
});
const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' });
const authenticationResult = await provider.authenticate(request, {
nextURL: '/test-base-path/some-path',
});
const authenticationResult = await provider.login(
request,
{ code: 'somecodehere' },
{ nextURL: '/base-path/some-path' }
);
sinon.assert.notCalled(callWithInternalUser);
sinon.assert.notCalled(mockOptions.client.callAsInternalUser);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toEqual(
@ -213,17 +135,15 @@ describe('OIDCAuthenticationProvider', () => {
});
it('fails if authentication response is presented but session state does not contain redirect URL.', async () => {
const request = requestFixture({
path: '/api/security/v1/oidc',
search: '?code=somecodehere&state=somestatehere',
});
const request = httpServerMock.createKibanaRequest({ path: '/api/security/v1/oidc' });
const authenticationResult = await provider.authenticate(request, {
state: 'statevalue',
nonce: 'noncevalue',
});
const authenticationResult = await provider.login(
request,
{ code: 'somecodehere' },
{ state: 'statevalue', nonce: 'noncevalue' }
);
sinon.assert.notCalled(callWithInternalUser);
sinon.assert.notCalled(mockOptions.client.callAsInternalUser);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toEqual(
@ -234,161 +154,65 @@ describe('OIDCAuthenticationProvider', () => {
});
it('fails if session state is not presented.', async () => {
const request = requestFixture({
const request = httpServerMock.createKibanaRequest({
path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
search: '?code=somecodehere&state=somestatehere',
});
const authenticationResult = await provider.authenticate(request, {});
const authenticationResult = await provider.login(request, { code: 'somecodehere' }, {});
sinon.assert.notCalled(callWithInternalUser);
sinon.assert.notCalled(mockOptions.client.callAsInternalUser);
expect(authenticationResult.failed()).toBe(true);
});
it('fails if code is invalid.', async () => {
const request = requestFixture({
const request = httpServerMock.createKibanaRequest({
path: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
search: '?code=somecodehere&state=somestatehere',
});
const failureReason = new Error(
'Failed to exchange code for Id Token using the Token Endpoint.'
);
callWithInternalUser
mockOptions.client.callAsInternalUser
.withArgs('shield.oidcAuthenticate')
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.authenticate(request, {
state: 'statevalue',
nonce: 'noncevalue',
nextURL: '/test-base-path/some-path',
});
const authenticationResult = await provider.login(
request,
{ code: 'somecodehere' },
{ state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path' }
);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcAuthenticate', {
body: {
state: 'statevalue',
nonce: 'noncevalue',
redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
},
});
sinon.assert.calledWithExactly(
mockOptions.client.callAsInternalUser,
'shield.oidcAuthenticate',
{
body: {
state: 'statevalue',
nonce: 'noncevalue',
redirect_uri: '/api/security/v1/oidc?code=somecodehere&state=somestatehere',
},
}
);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
});
it('succeeds if state contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture();
describe('`authenticate` method', () => {
it('does not handle AJAX request that can not be authenticated.', async () => {
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request, null);
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBe(undefined);
});
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic some:credentials' } });
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = requestFixture();
it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
const failureReason = new Error('Token is not valid!');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-invalid-token',
refreshToken: 'some-invalid-refresh-token',
});
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
sinon.assert.neverCalledWith(callWithRequest, 'shield.getAccessToken');
});
it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => {
const user = { username: 'user' };
const request = requestFixture();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer new-access-token' } }),
'shield.authenticate'
)
.resolves(user);
tokens.refresh
.withArgs(tokenPair.refreshToken)
.resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' });
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers.authorization).toBe('Bearer new-access-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toEqual({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
});
it('fails if token from the state is expired and refresh attempt failed too.', async () => {
const request = requestFixture();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
const refreshFailureReason = {
statusCode: 500,
message: 'Something is wrong with refresh token.',
};
tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(refreshFailureReason);
});
it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => {
const request = requestFixture({ path: '/some-path', basePath: '/s/foo' });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
callWithInternalUser.withArgs('shield.oidcPrepare').resolves({
mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({
state: 'statevalue',
nonce: 'noncevalue',
redirect:
@ -399,18 +223,9 @@ describe('OIDCAuthenticationProvider', () => {
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc',
});
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
const authenticationResult = await provider.authenticate(request, null);
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcPrepare', {
sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', {
body: { realm: `oidc1` },
});
@ -425,22 +240,203 @@ describe('OIDCAuthenticationProvider', () => {
expect(authenticationResult.state).toEqual({
state: 'statevalue',
nonce: 'noncevalue',
nextURL: `/s/foo/some-path`,
nextURL: '/base-path/s/foo/some-path',
});
});
it('fails if OpenID Connect authentication request preparation fails.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/some-path' });
const failureReason = new Error('Realm is misconfigured!');
mockOptions.client.callAsInternalUser
.withArgs('shield.oidcPrepare')
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.authenticate(request, null);
sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', {
body: { realm: `oidc1` },
});
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('succeeds if state contains a valid token.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
};
const authorization = `Bearer ${tokenPair.accessToken}`;
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.authHeaders).toEqual({ authorization });
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBeUndefined();
});
it('does not handle `authorization` header with unsupported schema even if state contains a valid token.', async () => {
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Basic some:credentials' },
});
const authenticationResult = await provider.authenticate(request, {
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
});
sinon.assert.notCalled(mockOptions.client.asScoped);
expect(request.headers.authorization).toBe('Basic some:credentials');
expect(authenticationResult.notHandled()).toBe(true);
});
it('fails if token from the state is rejected because of unknown reason.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = {
accessToken: 'some-invalid-token',
refreshToken: 'some-invalid-refresh-token',
};
const authorization = `Bearer ${tokenPair.accessToken}`;
const failureReason = new Error('Token is not valid!');
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(failureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('succeeds if token from the state is expired, but has been successfully refreshed.', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'valid-refresh-token' };
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer new-access-token' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
mockOptions.tokens.refresh
.withArgs(tokenPair.refreshToken)
.resolves({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' });
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.authHeaders).toEqual({
authorization: 'Bearer new-access-token',
});
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toEqual({
accessToken: 'new-access-token',
refreshToken: 'new-refresh-token',
});
});
it('fails if token from the state is expired and refresh attempt failed too.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'expired-token', refreshToken: 'invalid-refresh-token' };
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
const refreshFailureReason = {
statusCode: 500,
message: 'Something is wrong with refresh token.',
};
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshFailureReason);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(refreshFailureReason);
});
it('redirects to OpenID Connect Provider for non-AJAX requests if refresh token is expired or already refreshed.', async () => {
const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
mockOptions.client.callAsInternalUser.withArgs('shield.oidcPrepare').resolves({
state: 'statevalue',
nonce: 'noncevalue',
redirect:
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc',
});
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcPrepare', {
body: { realm: `oidc1` },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'https://op-host/path/login?response_type=code' +
'&scope=openid%20profile%20email' +
'&client_id=s6BhdRkqt3' +
'&state=statevalue' +
'&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc'
);
expect(authenticationResult.state).toEqual({
state: 'statevalue',
nonce: 'noncevalue',
nextURL: '/base-path/s/foo/some-path',
});
});
it('fails for AJAX requests with user friendly message if refresh token is expired.', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } });
const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } });
const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
@ -452,26 +448,31 @@ describe('OIDCAuthenticationProvider', () => {
});
it('succeeds if `authorization` contains a valid token.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'Bearer some-valid-token' } });
const user = mockAuthenticatedUser();
const authorization = 'Bearer some-valid-token';
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(request.headers.authorization).toBe('Bearer some-valid-token');
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.user).toBe(user);
expect(authenticationResult.state).toBe(undefined);
expect(authenticationResult.state).toBeUndefined();
});
it('fails if token from `authorization` header is rejected.', async () => {
const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } });
const authorization = 'Bearer some-invalid-token';
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
const failureReason = new Error('Token is not valid!');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(failureReason));
const failureReason = { statusCode: 401 };
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(failureReason);
const authenticationResult = await provider.authenticate(request);
@ -480,16 +481,20 @@ describe('OIDCAuthenticationProvider', () => {
});
it('fails if token from `authorization` header is rejected even if state contains a valid one.', async () => {
const user = { username: 'user' };
const request = requestFixture({ headers: { authorization: 'Bearer some-invalid-token' } });
const user = mockAuthenticatedUser();
const authorization = 'Bearer some-invalid-token';
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
const failureReason = new Error('Token is not valid!');
callWithRequest
.withArgs(request, 'shield.authenticate')
.returns(Promise.reject(failureReason));
const failureReason = { statusCode: 401 };
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(failureReason);
callWithRequest
.withArgs(sinon.match({ headers: { authorization: 'Bearer some-valid-token' } }))
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer some-valid-token' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, {
@ -502,37 +507,39 @@ describe('OIDCAuthenticationProvider', () => {
});
});
describe('`deauthenticate` method', () => {
describe('`logout` method', () => {
it('returns `notHandled` if state is not presented or does not include access token.', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
let deauthenticateResult = await provider.deauthenticate(request, {});
let deauthenticateResult = await provider.logout(request, {});
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, {});
deauthenticateResult = await provider.logout(request, {});
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, { nonce: 'x' });
deauthenticateResult = await provider.logout(request, { nonce: 'x' });
expect(deauthenticateResult.notHandled()).toBe(true);
sinon.assert.notCalled(callWithInternalUser);
sinon.assert.notCalled(mockOptions.client.callAsInternalUser);
});
it('fails if OpenID Connect logout call fails.', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const accessToken = 'x-oidc-token';
const refreshToken = 'x-oidc-refresh-token';
const failureReason = new Error('Realm is misconfigured!');
callWithInternalUser.withArgs('shield.oidcLogout').returns(Promise.reject(failureReason));
mockOptions.client.callAsInternalUser
.withArgs('shield.oidcLogout')
.returns(Promise.reject(failureReason));
const authenticationResult = await provider.deauthenticate(request, {
const authenticationResult = await provider.logout(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcLogout', {
sinon.assert.calledOnce(mockOptions.client.callAsInternalUser);
sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
@ -541,41 +548,43 @@ describe('OIDCAuthenticationProvider', () => {
});
it('redirects to /logged_out if `redirect` field in OpenID Connect logout response is null.', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const accessToken = 'x-oidc-token';
const refreshToken = 'x-oidc-refresh-token';
callWithInternalUser.withArgs('shield.oidcLogout').resolves({ redirect: null });
mockOptions.client.callAsInternalUser
.withArgs('shield.oidcLogout')
.resolves({ redirect: null });
const authenticationResult = await provider.deauthenticate(request, {
const authenticationResult = await provider.logout(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledWithExactly(callWithInternalUser, 'shield.oidcLogout', {
sinon.assert.calledOnce(mockOptions.client.callAsInternalUser);
sinon.assert.calledWithExactly(mockOptions.client.callAsInternalUser, 'shield.oidcLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/test-base-path/logged_out');
expect(authenticationResult.redirectURL).toBe('/base-path/logged_out');
});
it('redirects user to the OpenID Connect Provider if RP initiated SLO is supported.', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const accessToken = 'x-oidc-token';
const refreshToken = 'x-oidc-refresh-token';
callWithInternalUser
mockOptions.client.callAsInternalUser
.withArgs('shield.oidcLogout')
.resolves({ redirect: 'http://fake-idp/logout&id_token_hint=thehint' });
const authenticationResult = await provider.deauthenticate(request, {
const authenticationResult = await provider.logout(request, {
accessToken,
refreshToken,
});
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledOnce(mockOptions.client.callAsInternalUser);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('http://fake-idp/logout&id_token_hint=thehint');
});

View file

@ -6,8 +6,8 @@
import Boom from 'boom';
import type from 'type-detect';
import { Legacy } from 'kibana';
import { canRedirectRequest } from '../../can_redirect_request';
import { canRedirectRequest } from '../';
import { KibanaRequest } from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { Tokens, TokenPair } from '../tokens';
@ -15,9 +15,17 @@ import {
AuthenticationProviderOptions,
BaseAuthenticationProvider,
AuthenticationProviderSpecificOptions,
RequestWithLoginAttempt,
} from './base';
/**
* Describes the parameters that are required by the provider to process the initial login request.
*/
interface ProviderLoginAttempt {
code?: string;
iss?: string;
loginHint?: string;
}
/**
* The state supported by the provider (for the OpenID Connect handshake or established session).
*/
@ -40,49 +48,13 @@ interface ProviderState extends Partial<TokenPair> {
nextURL?: string;
}
/**
* Defines the shape of an incoming OpenID Connect Request
*/
type OIDCIncomingRequest = RequestWithLoginAttempt & {
payload: {
iss?: string;
login_hint?: string;
};
query: {
iss?: string;
code?: string;
state?: string;
login_hint?: string;
error?: string;
error_description?: string;
};
};
/**
* Checks if the Request object represents an HTTP request regarding authentication with OpenID
* Connect. This can be
* - An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication
* - An HTTP POST request with a parameter named `iss` as part of a 3rd party initiated authentication
* - An HTTP GET request with a query parameter named `code` as the response to a successful authentication from
* an OpenID Connect Provider
* - An HTTP GET request with a query parameter named `error` as the response to a failed authentication from
* an OpenID Connect Provider
* @param request Request instance.
*/
function isOIDCIncomingRequest(request: RequestWithLoginAttempt): request is OIDCIncomingRequest {
return (
(request.payload != null && !!(request.payload as Record<string, unknown>).iss) ||
(request.query != null &&
(!!(request.query as any).iss ||
!!(request.query as any).code ||
!!(request.query as any).error))
);
}
/**
* Provider that supports authentication using an OpenID Connect realm in Elasticsearch.
*/
export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Specifies Elasticsearch OIDC realm name that Kibana should use.
*/
private readonly realm: string;
constructor(
@ -104,10 +76,28 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Performs OpenID Connect request authentication.
* @param request Request instance.
* @param attempt Login attempt description.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
public async login(
request: KibanaRequest,
attempt: ProviderLoginAttempt,
state?: ProviderState | null
) {
this.logger.debug('Trying to perform a login.');
// This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or
// a third party initiating an authentication
return await this.loginWithOIDCPayload(request, attempt, state);
}
/**
* Performs OpenID Connect request authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`);
// We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore.
let {
@ -118,11 +108,6 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
return authenticationResult;
}
if (request.loginAttempt().getCredentials() != null) {
this.debug('Login attempt is detected, but it is not supported by the provider');
return AuthenticationResult.notHandled();
}
if (state && authenticationResult.notHandled()) {
authenticationResult = await this.authenticateViaState(request, state);
if (
@ -133,12 +118,6 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
}
}
if (isOIDCIncomingRequest(request) && authenticationResult.notHandled()) {
// This might be the OpenID Connect Provider redirecting the user to `redirect_uri` after authentication or
// a third party initiating an authentication
authenticationResult = await this.authenticateViaResponseUrl(request, state);
}
// If we couldn't authenticate by means of all methods above, let's try to
// initiate an OpenID Connect based authentication, otherwise just return the authentication result we have.
// We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in
@ -161,30 +140,31 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* to the URL that was requested before authentication flow started or to default Kibana location in case of a third
* party initiated login
* @param request Request instance.
* @param attempt Login attempt description.
* @param [sessionState] Optional state object associated with the provider.
*/
private async authenticateViaResponseUrl(
request: OIDCIncomingRequest,
private async loginWithOIDCPayload(
request: KibanaRequest,
{ iss, loginHint, code }: ProviderLoginAttempt,
sessionState?: ProviderState | null
) {
this.debug('Trying to authenticate via OpenID Connect response query.');
// First check to see if this is a Third Party initiated authentication (which can happen via POST or GET)
const iss = (request.query && request.query.iss) || (request.payload && request.payload.iss);
const loginHint =
(request.query && request.query.login_hint) ||
(request.payload && request.payload.login_hint);
this.logger.debug('Trying to authenticate via OpenID Connect response query.');
// First check to see if this is a Third Party initiated authentication.
if (iss) {
this.debug('Authentication has been initiated by a Third Party.');
this.logger.debug('Authentication has been initiated by a Third Party.');
// We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in
// another tab)
const oidcPrepareParams = loginHint ? { iss, login_hint: loginHint } : { iss };
return this.initiateOIDCAuthentication(request, oidcPrepareParams);
}
if (!request.query || !request.query.code) {
this.debug('OpenID Connect Authentication response is not found.');
if (!code) {
this.logger.debug('OpenID Connect Authentication response is not found.');
return AuthenticationResult.notHandled();
}
// If it is an authentication response and the users' session state doesn't contain all the necessary information,
// then something unexpected happened and we should fail because Elasticsearch won't be able to validate the
// response.
@ -193,7 +173,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
if (!stateNonce || !stateOIDCState || !stateRedirectURL) {
const message =
'Response session state does not have corresponding state or nonce parameters or redirect URL.';
this.debug(message);
this.logger.debug(message);
return AuthenticationResult.failed(Boom.badRequest(message));
}
@ -204,7 +184,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callWithInternalUser('shield.oidcAuthenticate', {
} = await this.options.client.callAsInternalUser('shield.oidcAuthenticate', {
body: {
state: stateOIDCState,
nonce: stateNonce,
@ -215,14 +195,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
},
});
this.debug('Request has been authenticated via OpenID Connect.');
this.logger.debug('Request has been authenticated via OpenID Connect.');
return AuthenticationResult.redirectTo(stateRedirectURL, {
accessToken,
refreshToken,
});
} catch (err) {
this.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`);
this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
@ -235,15 +215,15 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* @param [sessionState] Optional state object associated with the provider.
*/
private async initiateOIDCAuthentication(
request: RequestWithLoginAttempt,
request: KibanaRequest,
params: { realm: string } | { iss: string; login_hint?: string },
sessionState?: ProviderState | null
) {
this.debug('Trying to initiate OpenID Connect authentication.');
this.logger.debug('Trying to initiate OpenID Connect authentication.');
// If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication.
if (!canRedirectRequest(request)) {
this.debug('OpenID Connect authentication can not be initiated by AJAX requests.');
this.logger.debug('OpenID Connect authentication can not be initiated by AJAX requests.');
return AuthenticationResult.notHandled();
}
@ -259,16 +239,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
: params;
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`.
const { state, nonce, redirect } = await this.options.client.callWithInternalUser(
const { state, nonce, redirect } = await this.options.client.callAsInternalUser(
'shield.oidcPrepare',
{
body: oidcPrepareParams,
}
{ body: oidcPrepareParams }
);
this.debug('Redirecting to OpenID Connect Provider with authentication request.');
this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.');
// If this is a third party initiated login, redirect to the base path
const redirectAfterLogin = `${request.getBasePath()}${
const redirectAfterLogin = `${this.options.basePath.get(request)}${
'iss' in params ? '/' : request.url.path
}`;
return AuthenticationResult.redirectTo(
@ -277,7 +255,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
{ state, nonce, nextURL: redirectAfterLogin }
);
} catch (err) {
this.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`);
this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
@ -287,12 +265,12 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via header.');
private async authenticateViaHeader(request: KibanaRequest) {
this.logger.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization) {
this.debug('Authorization header is not presented.');
if (!authorization || typeof authorization !== 'string') {
this.logger.debug('Authorization header is not presented.');
return {
authenticationResult: AuthenticationResult.notHandled(),
};
@ -300,7 +278,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'bearer') {
this.debug(`Unsupported authentication schema: ${authenticationSchema}`);
this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`);
return {
authenticationResult: AuthenticationResult.notHandled(),
headerNotRecognized: true,
@ -308,15 +286,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
}
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via header.');
const user = await this.getUser(request);
this.logger.debug('Request has been authenticated via header.');
return {
authenticationResult: AuthenticationResult.succeeded(user),
};
} catch (err) {
this.debug(`Failed to authenticate request via header: ${err.message}`);
this.logger.debug(`Failed to authenticate request via header: ${err.message}`);
return {
authenticationResult: AuthenticationResult.failed(err),
};
@ -329,35 +306,22 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ accessToken }: ProviderState
) {
this.debug('Trying to authenticate via state.');
private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) {
this.logger.debug('Trying to authenticate via state.');
if (!accessToken) {
this.debug('Elasticsearch access token is not found in state.');
this.logger.debug('Elasticsearch access token is not found in state.');
return AuthenticationResult.notHandled();
}
request.headers.authorization = `Bearer ${accessToken}`;
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
const authHeaders = { authorization: `Bearer ${accessToken}` };
const user = await this.getUser(request, authHeaders);
this.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user);
this.logger.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user, { authHeaders });
} catch (err) {
this.debug(`Failed to authenticate request via state: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
this.logger.debug(`Failed to authenticate request via state: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
@ -370,13 +334,13 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: RequestWithLoginAttempt,
request: KibanaRequest,
{ refreshToken }: ProviderState
) {
this.debug('Trying to refresh elasticsearch access token.');
this.logger.debug('Trying to refresh elasticsearch access token.');
if (!refreshToken) {
this.debug('Refresh token is not found in state.');
this.logger.debug('Refresh token is not found in state.');
return AuthenticationResult.notHandled();
}
@ -395,7 +359,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
// supported.
if (refreshedTokenPair === null) {
if (canRedirectRequest(request)) {
this.debug(
this.logger.debug(
'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.'
);
return this.initiateOIDCAuthentication(request, { realm: this.realm });
@ -407,22 +371,13 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
}
try {
request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`;
const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` };
const user = await this.getUser(request, authHeaders);
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, refreshedTokenPair);
this.logger.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair });
} catch (err) {
this.debug(`Failed to authenticate user using newly refreshed access token: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
this.logger.debug(`Failed to refresh elasticsearch access token: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
@ -433,11 +388,11 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
public async deauthenticate(request: Legacy.Request, state: ProviderState) {
this.debug(`Trying to deauthenticate user via ${request.url.path}.`);
public async logout(request: KibanaRequest, state: ProviderState) {
this.logger.debug(`Trying to log user out via ${request.url.path}.`);
if (!state || !state.accessToken) {
this.debug('There is no elasticsearch access token to invalidate.');
this.logger.debug('There is no elasticsearch access token to invalidate.');
return DeauthenticationResult.notHandled();
}
@ -450,33 +405,25 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider {
};
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/oidc/logout`.
const { redirect } = await this.options.client.callWithInternalUser(
const { redirect } = await this.options.client.callAsInternalUser(
'shield.oidcLogout',
logoutBody
);
this.debug('User session has been successfully invalidated.');
this.logger.debug('User session has been successfully invalidated.');
// Having non-null `redirect` field within logout response means that the OpenID Connect realm configuration
// supports RP initiated Single Logout and we should redirect user to the specified location in the OpenID Connect
// Provider to properly complete logout.
if (redirect != null) {
this.debug('Redirecting user to the OpenID Connect Provider to complete logout.');
this.logger.debug('Redirecting user to the OpenID Connect Provider to complete logout.');
return DeauthenticationResult.redirectTo(redirect);
}
return DeauthenticationResult.redirectTo(`${this.options.basePath}/logged_out`);
return DeauthenticationResult.redirectTo(`${this.options.basePath.get(request)}/logged_out`);
} catch (err) {
this.debug(`Failed to deauthenticate user: ${err.message}`);
this.logger.debug(`Failed to deauthenticate user: ${err.message}`);
return DeauthenticationResult.failed(err);
}
}
/**
* Logs message with `debug` level and oidc/security related tags.
* @param message Message to log.
*/
private debug(message: string) {
this.options.log(['debug', 'security', 'oidc'], message);
}
}

File diff suppressed because it is too large Load diff

View file

@ -5,17 +5,13 @@
*/
import Boom from 'boom';
import { Legacy } from 'kibana';
import { canRedirectRequest } from '../../can_redirect_request';
import { AuthenticatedUser } from '../../../../common/model';
import { KibanaRequest } from '../../../../../../src/core/server';
import { AuthenticatedUser } from '../../../common/model';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { AuthenticationProviderOptions, BaseAuthenticationProvider } from './base';
import { Tokens, TokenPair } from '../tokens';
import {
AuthenticationProviderOptions,
BaseAuthenticationProvider,
RequestWithLoginAttempt,
} from './base';
import { canRedirectRequest } from '..';
/**
* The state supported by the provider (for the SAML handshake or established session).
@ -33,34 +29,17 @@ interface ProviderState extends Partial<TokenPair> {
}
/**
* Defines the shape of the request query containing SAML request.
* Describes the parameters that are required by the provider to process the initial login request.
*/
interface SAMLRequestQuery {
SAMLRequest: string;
}
/**
* Defines the shape of the request with a body containing SAML response.
*/
type RequestWithSAMLPayload = RequestWithLoginAttempt & {
payload: { SAMLResponse: string; RelayState?: string };
};
/**
* Checks whether request payload contains SAML response from IdP.
* @param request Request instance.
*/
function isRequestWithSAMLResponsePayload(
request: RequestWithLoginAttempt
): request is RequestWithSAMLPayload {
return request.payload != null && !!(request.payload as any).SAMLResponse;
interface ProviderLoginAttempt {
samlResponse: string;
}
/**
* Checks whether request query includes SAML request from IdP.
* @param query Parsed HTTP request query.
*/
function isSAMLRequestQuery(query: any): query is SAMLRequestQuery {
export function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } {
return query && query.SAMLRequest;
}
@ -82,13 +61,59 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
this.realm = samlOptions && samlOptions.realm;
}
/**
* Performs initial login request using SAMLResponse payload.
* @param request Request instance.
* @param attempt Login attempt description.
* @param [state] Optional state object associated with the provider.
*/
public async login(
request: KibanaRequest,
{ samlResponse }: ProviderLoginAttempt,
state?: ProviderState | null
) {
this.logger.debug('Trying to perform a login.');
const authenticationResult = state
? await this.authenticateViaState(request, state)
: AuthenticationResult.notHandled();
// Let's check if user is redirected to Kibana from IdP with valid SAMLResponse.
if (authenticationResult.notHandled()) {
return await this.loginWithSAMLResponse(request, samlResponse, state);
}
if (authenticationResult.succeeded()) {
// If user has been authenticated via session, but request also includes SAML payload
// we should check whether this payload is for the exactly same user and if not
// we'll re-authenticate user and forward to a page with the respective warning.
return await this.loginWithNewSAMLResponse(
request,
samlResponse,
(authenticationResult.state || state) as ProviderState,
authenticationResult.user as AuthenticatedUser
);
}
if (authenticationResult.redirected()) {
this.logger.debug('Login has been successfully performed.');
} else {
this.logger.debug(
`Failed to perform a login: ${authenticationResult.error &&
authenticationResult.error.message}`
);
}
return authenticationResult;
}
/**
* Performs SAML request authentication.
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: RequestWithLoginAttempt, state?: ProviderState | null) {
this.debug(`Trying to authenticate user request to ${request.url.path}.`);
public async authenticate(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`);
// We should get rid of `Bearer` scheme support as soon as Reporting doesn't need it anymore.
let {
@ -100,11 +125,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
return authenticationResult;
}
if (request.loginAttempt().getCredentials() != null) {
this.debug('Login attempt is detected, but it is not supported by the provider');
return AuthenticationResult.notHandled();
}
if (state && authenticationResult.notHandled()) {
authenticationResult = await this.authenticateViaState(request, state);
if (
@ -115,22 +135,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
}
// Let's check if user is redirected to Kibana from IdP with valid SAMLResponse.
if (isRequestWithSAMLResponsePayload(request)) {
if (authenticationResult.notHandled()) {
authenticationResult = await this.authenticateViaPayload(request, state);
} else if (authenticationResult.succeeded()) {
// If user has been authenticated via session, but request also includes SAML payload
// we should check whether this payload is for the exactly same user and if not
// we'll re-authenticate user and forward to a page with the respective warning.
authenticationResult = await this.authenticateViaNewPayload(
request,
(authenticationResult.state || state) as ProviderState,
authenticationResult.user as AuthenticatedUser
);
}
}
// If we couldn't authenticate by means of all methods above, let's try to
// initiate SAML handshake, otherwise just return authentication result we have.
return authenticationResult.notHandled()
@ -143,11 +147,11 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
public async deauthenticate(request: Legacy.Request, state?: ProviderState) {
this.debug(`Trying to deauthenticate user via ${request.url.path}.`);
public async logout(request: KibanaRequest, state?: ProviderState) {
this.logger.debug(`Trying to log user out via ${request.url.path}.`);
if ((!state || !state.accessToken) && !isSAMLRequestQuery(request.query)) {
this.debug('There is neither access token nor SAML session to invalidate.');
this.logger.debug('There is neither access token nor SAML session to invalidate.');
return DeauthenticationResult.notHandled();
}
@ -160,13 +164,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// supports SAML Single Logout and we should redirect user to the specified
// location to properly complete logout.
if (redirect != null) {
this.debug('Redirecting user to Identity Provider to complete logout.');
this.logger.debug('Redirecting user to Identity Provider to complete logout.');
return DeauthenticationResult.redirectTo(redirect);
}
return DeauthenticationResult.redirectTo('/logged_out');
} catch (err) {
this.debug(`Failed to deauthenticate user: ${err.message}`);
this.logger.debug(`Failed to deauthenticate user: ${err.message}`);
return DeauthenticationResult.failed(err);
}
}
@ -176,18 +180,18 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: RequestWithLoginAttempt) {
this.debug('Trying to authenticate via header.');
private async authenticateViaHeader(request: KibanaRequest) {
this.logger.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization) {
this.debug('Authorization header is not presented.');
if (!authorization || typeof authorization !== 'string') {
this.logger.debug('Authorization header is not presented.');
return { authenticationResult: AuthenticationResult.notHandled() };
}
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'bearer') {
this.debug(`Unsupported authentication schema: ${authenticationSchema}`);
this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`);
return {
authenticationResult: AuthenticationResult.notHandled(),
headerNotRecognized: true,
@ -195,12 +199,12 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
const user = await this.getUser(request);
this.debug('Request has been authenticated via header.');
this.logger.debug('Request has been authenticated via header.');
return { authenticationResult: AuthenticationResult.succeeded(user) };
} catch (err) {
this.debug(`Failed to authenticate request via header: ${err.message}`);
this.logger.debug(`Failed to authenticate request via header: ${err.message}`);
return { authenticationResult: AuthenticationResult.failed(err) };
}
}
@ -218,13 +222,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* that was requested before SAML handshake or to default Kibana location in case of IdP
* initiated login.
* @param request Request instance.
* @param samlResponse SAMLResponse payload string.
* @param [state] Optional state object associated with the provider.
*/
private async authenticateViaPayload(
request: RequestWithSAMLPayload,
private async loginWithSAMLResponse(
request: KibanaRequest,
samlResponse: string,
state?: ProviderState | null
) {
this.debug('Trying to authenticate via SAML response payload.');
this.logger.debug('Trying to log in with SAML response payload.');
// If we have a `SAMLResponse` and state, but state doesn't contain all the necessary information,
// then something unexpected happened and we should fail.
@ -234,16 +240,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
};
if (state && (!stateRequestId || !stateRedirectURL)) {
const message = 'SAML response state does not have corresponding request id or redirect URL.';
this.debug(message);
this.logger.debug(message);
return AuthenticationResult.failed(Boom.badRequest(message));
}
// When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login.
this.debug(
this.logger.debug(
stateRequestId
? 'Authentication has been previously initiated by Kibana.'
: 'Authentication has been initiated by Identity Provider.'
? 'Login has been previously initiated by Kibana.'
: 'Login has been initiated by Identity Provider.'
);
try {
@ -252,20 +257,20 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callWithInternalUser('shield.samlAuthenticate', {
} = await this.options.client.callAsInternalUser('shield.samlAuthenticate', {
body: {
ids: stateRequestId ? [stateRequestId] : [],
content: request.payload.SAMLResponse,
content: samlResponse,
},
});
this.debug('Request has been authenticated via SAML response.');
return AuthenticationResult.redirectTo(stateRedirectURL || `${this.options.basePath}/`, {
accessToken,
refreshToken,
});
this.logger.debug('Login has been performed with SAML response.');
return AuthenticationResult.redirectTo(
stateRedirectURL || `${this.options.basePath.get(request)}/`,
{ accessToken, refreshToken }
);
} catch (err) {
this.debug(`Failed to authenticate request via SAML response: ${err.message}`);
this.logger.debug(`Failed to log in with SAML response: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
@ -279,24 +284,28 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* we detect that user from existing session isn't the same as defined in SAML payload. In this case
* we'll forward user to a page with the respective warning.
* @param request Request instance.
* @param samlResponse SAMLResponse payload string.
* @param existingState State existing user session is based on.
* @param user User returned for the existing session.
*/
private async authenticateViaNewPayload(
request: RequestWithSAMLPayload,
private async loginWithNewSAMLResponse(
request: KibanaRequest,
samlResponse: string,
existingState: ProviderState,
user: AuthenticatedUser
) {
this.debug('Trying to authenticate via SAML response payload with existing valid session.');
this.logger.debug('Trying to log in with SAML response payload and existing valid session.');
// First let's try to authenticate via SAML Response payload.
const payloadAuthenticationResult = await this.authenticateViaPayload(request);
const payloadAuthenticationResult = await this.loginWithSAMLResponse(request, samlResponse);
if (payloadAuthenticationResult.failed()) {
return payloadAuthenticationResult;
} else if (!payloadAuthenticationResult.shouldUpdateState()) {
}
if (!payloadAuthenticationResult.shouldUpdateState()) {
// Should never happen, but if it does - it's a bug.
return AuthenticationResult.failed(
new Error('Authentication via SAML payload did not produce access and refresh tokens.')
new Error('Login with SAML payload did not produce access and refresh tokens.')
);
}
@ -307,7 +316,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
const newUserAuthenticationResult = await this.authenticateViaState(request, newState);
if (newUserAuthenticationResult.failed()) {
return newUserAuthenticationResult;
} else if (newUserAuthenticationResult.user === undefined) {
}
if (newUserAuthenticationResult.user === undefined) {
// Should never happen, but if it does - it's a bug.
return AuthenticationResult.failed(
new Error('Could not retrieve user information using tokens produced for the SAML payload.')
@ -316,13 +327,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// Now let's invalidate tokens from the existing session.
try {
this.debug('Perform IdP initiated local logout.');
this.logger.debug('Perform IdP initiated local logout.');
await this.options.tokens.invalidate({
accessToken: existingState.accessToken!,
refreshToken: existingState.refreshToken!,
});
} catch (err) {
this.debug(`Failed to perform IdP initiated local logout: ${err.message}`);
this.logger.debug(`Failed to perform IdP initiated local logout: ${err.message}`);
return AuthenticationResult.failed(err);
}
@ -330,19 +341,16 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
newUserAuthenticationResult.user.username !== user.username ||
newUserAuthenticationResult.user.authentication_realm.name !== user.authentication_realm.name
) {
this.debug(
'Authentication initiated by Identity Provider is for a different user than currently authenticated.'
this.logger.debug(
'Login initiated by Identity Provider is for a different user than currently authenticated.'
);
return AuthenticationResult.redirectTo(
`${this.options.basePath}/overwritten_session`,
`${this.options.basePath.get(request)}/overwritten_session`,
newState
);
}
this.debug(
'Authentication initiated by Identity Provider is for currently authenticated user.'
);
this.logger.debug('Login initiated by Identity Provider is for currently authenticated user.');
return payloadAuthenticationResult;
}
@ -352,34 +360,22 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(
request: RequestWithLoginAttempt,
{ accessToken }: ProviderState
) {
this.debug('Trying to authenticate via state.');
private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) {
this.logger.debug('Trying to authenticate via state.');
if (!accessToken) {
this.debug('Access token is not found in state.');
this.logger.debug('Access token is not found in state.');
return AuthenticationResult.notHandled();
}
request.headers.authorization = `Bearer ${accessToken}`;
try {
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
const authHeaders = { authorization: `Bearer ${accessToken}` };
const user = await this.getUser(request, authHeaders);
this.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user);
this.logger.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user, { authHeaders });
} catch (err) {
this.debug(`Failed to authenticate request via state: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
this.logger.debug(`Failed to authenticate request via state: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
@ -392,13 +388,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: RequestWithLoginAttempt,
request: KibanaRequest,
{ refreshToken }: ProviderState
) {
this.debug('Trying to refresh access token.');
this.logger.debug('Trying to refresh access token.');
if (!refreshToken) {
this.debug('Refresh token is not found in state.');
this.logger.debug('Refresh token is not found in state.');
return AuthenticationResult.notHandled();
}
@ -416,7 +412,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported.
if (refreshedTokenPair === null) {
if (canRedirectRequest(request)) {
this.debug('Both access and refresh tokens are expired. Re-initiating SAML handshake.');
this.logger.debug(
'Both access and refresh tokens are expired. Re-initiating SAML handshake.'
);
return this.authenticateViaHandshake(request);
}
@ -426,22 +424,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
try {
request.headers.authorization = `Bearer ${refreshedTokenPair.accessToken}`;
const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` };
const user = await this.getUser(request, authHeaders);
const user = await this.options.client.callWithRequest(request, 'shield.authenticate');
this.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, refreshedTokenPair);
this.logger.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair });
} catch (err) {
this.debug(`Failed to authenticate user using newly refreshed access token: ${err.message}`);
// Reset `Authorization` header we've just set. We know for sure that it hasn't been defined before,
// otherwise it would have been used or completely rejected by the `authenticateViaHeader`.
// We can't just set `authorization` to `undefined` or `null`, we should remove this property
// entirely, otherwise `authorization` header without value will cause `callWithRequest` to fail if
// it's called with this request once again down the line (e.g. in the next authentication provider).
delete request.headers.authorization;
this.logger.debug(
`Failed to authenticate user using newly refreshed access token: ${err.message}`
);
return AuthenticationResult.failed(err);
}
}
@ -450,35 +441,34 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* Tries to start SAML handshake and eventually receive a token.
* @param request Request instance.
*/
private async authenticateViaHandshake(request: RequestWithLoginAttempt) {
this.debug('Trying to initiate SAML handshake.');
private async authenticateViaHandshake(request: KibanaRequest) {
this.logger.debug('Trying to initiate SAML handshake.');
// If client can't handle redirect response, we shouldn't initiate SAML handshake.
if (!canRedirectRequest(request)) {
this.debug('SAML handshake can not be initiated by AJAX requests.');
this.logger.debug('SAML handshake can not be initiated by AJAX requests.');
return AuthenticationResult.notHandled();
}
try {
// Prefer realm name if it's specified, otherwise fallback to ACS.
const preparePayload = this.realm ? { realm: this.realm } : { acs: this.getACS() };
const preparePayload = this.realm ? { realm: this.realm } : { acs: this.getACS(request) };
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/prepare`.
const { id: requestId, redirect } = await this.options.client.callWithInternalUser(
const { id: requestId, redirect } = await this.options.client.callAsInternalUser(
'shield.samlPrepare',
{ body: preparePayload }
);
this.debug('Redirecting to Identity Provider with SAML request.');
this.logger.debug('Redirecting to Identity Provider with SAML request.');
return AuthenticationResult.redirectTo(
redirect,
// Store request id in the state so that we can reuse it once we receive `SAMLResponse`.
{ requestId, nextURL: `${request.getBasePath()}${request.url.path}` }
{ requestId, nextURL: `${this.options.basePath.get(request)}${request.url.path}` }
);
} catch (err) {
this.debug(`Failed to initiate SAML handshake: ${err.message}`);
this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
@ -489,15 +479,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* @param refreshToken Refresh token to invalidate.
*/
private async performUserInitiatedSingleLogout(accessToken: string, refreshToken: string) {
this.debug('Single logout has been initiated by the user.');
this.logger.debug('Single logout has been initiated by the user.');
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/logout`.
const { redirect } = await this.options.client.callWithInternalUser('shield.samlLogout', {
const { redirect } = await this.options.client.callAsInternalUser('shield.samlLogout', {
body: { token: accessToken, refresh_token: refreshToken },
});
this.debug('User session has been successfully invalidated.');
this.logger.debug('User session has been successfully invalidated.');
return redirect;
}
@ -507,15 +497,15 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* Provider and redirects user back to the Identity Provider if needed.
* @param request Request instance.
*/
private async performIdPInitiatedSingleLogout(request: Legacy.Request) {
this.debug('Single logout has been initiated by the Identity Provider.');
private async performIdPInitiatedSingleLogout(request: KibanaRequest) {
this.logger.debug('Single logout has been initiated by the Identity Provider.');
// Prefer realm name if it's specified, otherwise fallback to ACS.
const invalidatePayload = this.realm ? { realm: this.realm } : { acs: this.getACS() };
const invalidatePayload = this.realm ? { realm: this.realm } : { acs: this.getACS(request) };
// This operation should be performed on behalf of the user with a privilege that normal
// user usually doesn't have `cluster:admin/xpack/security/saml/invalidate`.
const { redirect } = await this.options.client.callWithInternalUser('shield.samlInvalidate', {
const { redirect } = await this.options.client.callAsInternalUser('shield.samlInvalidate', {
// Elasticsearch expects `queryString` without leading `?`, so we should strip it with `slice`.
body: {
queryString: request.url.search ? request.url.search.slice(1) : '',
@ -523,7 +513,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
},
});
this.debug('User session has been successfully invalidated.');
this.logger.debug('User session has been successfully invalidated.');
return redirect;
}
@ -531,18 +521,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Constructs and returns Kibana's Assertion consumer service URL.
*/
private getACS() {
return (
`${this.options.protocol}://${this.options.hostname}:${this.options.port}` +
`${this.options.basePath}/api/security/v1/saml`
);
}
/**
* Logs message with `debug` level and saml/security related tags.
* @param message Message to log.
*/
private debug(message: string) {
this.options.log(['debug', 'security', 'saml'], message);
private getACS(request: KibanaRequest) {
return `${this.options.getServerBaseURL()}${this.options.basePath.get(
request
)}/api/security/v1/saml`;
}
}

View file

@ -7,207 +7,66 @@
import Boom from 'boom';
import { errors } from 'elasticsearch';
import sinon from 'sinon';
import { requestFixture } from '../../__tests__/__fixtures__/request';
import { LoginAttempt } from '../login_attempt';
import { mockAuthenticationProviderOptions } from './base.mock';
import { httpServerMock } from '../../../../../../src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import {
MockAuthenticationProviderOptions,
mockAuthenticationProviderOptions,
mockScopedClusterClient,
} from './base.mock';
import { TokenAuthenticationProvider } from './token';
describe('TokenAuthenticationProvider', () => {
let provider: TokenAuthenticationProvider;
let callWithRequest: sinon.SinonStub;
let callWithInternalUser: sinon.SinonStub;
let tokens: ReturnType<typeof mockAuthenticationProviderOptions>['tokens'];
let mockOptions: MockAuthenticationProviderOptions;
beforeEach(() => {
const providerOptions = mockAuthenticationProviderOptions();
callWithRequest = providerOptions.client.callWithRequest;
callWithInternalUser = providerOptions.client.callWithInternalUser;
tokens = providerOptions.tokens;
provider = new TokenAuthenticationProvider(providerOptions);
mockOptions = mockAuthenticationProviderOptions();
provider = new TokenAuthenticationProvider(mockOptions);
});
describe('`authenticate` method', () => {
it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => {
// Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and
// avoid triggering of redirect logic.
const authenticationResult = await provider.authenticate(
requestFixture({ headers: { 'kbn-xsrf': 'xsrf' } }),
null
);
describe('`login` method', () => {
it('succeeds with valid login attempt, creates session and authHeaders', async () => {
const request = httpServerMock.createKibanaRequest();
const user = mockAuthenticatedUser();
expect(authenticationResult.notHandled()).toBe(true);
});
const credentials = { username: 'user', password: 'password' };
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const authorization = `Bearer ${tokenPair.accessToken}`;
it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ path: '/some-path # that needs to be encoded', basePath: '/s/foo' }),
null
);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'/base-path/login?next=%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded'
);
});
it('succeeds with valid login attempt and stores in session', async () => {
const user = { username: 'user' };
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
callWithInternalUser
mockOptions.client.callAsInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'password', username: 'user', password: 'password' },
body: { grant_type: 'password', ...credentials },
})
.resolves({ access_token: 'foo', refresh_token: 'bar' });
.resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken });
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ accessToken: 'foo', refreshToken: 'bar' });
expect(request.headers.authorization).toEqual(`Bearer foo`);
sinon.assert.calledOnce(callWithRequest);
});
it('succeeds if only `authorization` header is available.', async () => {
const authorization = 'Bearer foo';
const request = requestFixture({ headers: { authorization } });
const user = { username: 'user' };
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request);
const authenticationResult = await provider.login(request, credentials);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
sinon.assert.calledOnce(callWithRequest);
});
it('does not return session state for header-based auth', async () => {
const authorization = 'Bearer foo';
const request = requestFixture({ headers: { authorization } });
const user = { username: 'user' };
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.state).toBeUndefined();
});
it('succeeds if only state is available.', async () => {
const request = requestFixture();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const user = { username: 'user' };
const authorization = `Bearer ${tokenPair.accessToken}`;
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toBeUndefined();
sinon.assert.calledOnce(callWithRequest);
});
it('succeeds with valid session even if requiring a token refresh', async () => {
const user = { username: 'user' };
const request = requestFixture();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
.rejects({ statusCode: 401 });
tokens.refresh
.withArgs(tokenPair.refreshToken)
.resolves({ accessToken: 'newfoo', refreshToken: 'newbar' });
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer newfoo' } }),
'shield.authenticate'
)
.returns(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledTwice(callWithRequest);
sinon.assert.calledOnce(tokens.refresh);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' });
expect(request.headers.authorization).toEqual('Bearer newfoo');
});
it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => {
const request = requestFixture({ headers: { authorization: 'Basic ***' } });
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const user = { username: 'user' };
const authorization = `Bearer ${tokenPair.accessToken}`;
callWithRequest
.withArgs(sinon.match({ headers: { authorization } }), 'shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.notCalled(callWithRequest);
expect(request.headers.authorization).toBe('Basic ***');
expect(authenticationResult.notHandled()).toBe(true);
});
it('authenticates only via `authorization` header even if state is available.', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const authorization = `Bearer foo-from-header`;
const request = requestFixture({ headers: { authorization } });
const user = { username: 'user' };
// GetUser will be called with request's `authorization` header.
callWithRequest.withArgs(request, 'shield.authenticate').resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toBeUndefined();
sinon.assert.calledOnce(callWithRequest);
expect(request.headers.authorization).toEqual('Bearer foo-from-header');
expect(authenticationResult.state).toEqual(tokenPair);
expect(authenticationResult.authHeaders).toEqual({ authorization });
});
it('fails if token cannot be generated during login attempt', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const request = httpServerMock.createKibanaRequest();
const credentials = { username: 'user', password: 'password' };
const authenticationError = new Error('Invalid credentials');
callWithInternalUser
mockOptions.client.callAsInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'password', username: 'user', password: 'password' },
body: { grant_type: 'password', ...credentials },
})
.rejects(authenticationError);
const authenticationResult = await provider.authenticate(request);
const authenticationResult = await provider.login(request, credentials);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.notCalled(callWithRequest);
sinon.assert.notCalled(mockOptions.client.asScoped);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
@ -217,24 +76,25 @@ describe('TokenAuthenticationProvider', () => {
});
it('fails if user cannot be retrieved during login attempt', async () => {
const request = requestFixture();
const loginAttempt = new LoginAttempt();
loginAttempt.setCredentials('user', 'password');
(request.loginAttempt as sinon.SinonStub).returns(loginAttempt);
const request = httpServerMock.createKibanaRequest();
const credentials = { username: 'user', password: 'password' };
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithInternalUser
mockOptions.client.callAsInternalUser
.withArgs('shield.getAccessToken', {
body: { grant_type: 'password', username: 'user', password: 'password' },
body: { grant_type: 'password', ...credentials },
})
.resolves({ access_token: 'foo', refresh_token: 'bar' });
.resolves({ access_token: tokenPair.accessToken, refresh_token: tokenPair.refreshToken });
const authenticationError = new Error('Some error');
callWithRequest.withArgs(request, 'shield.authenticate').rejects(authenticationError);
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(authenticationError);
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledOnce(callWithInternalUser);
sinon.assert.calledOnce(callWithRequest);
const authenticationResult = await provider.login(request, credentials);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
@ -242,17 +102,151 @@ describe('TokenAuthenticationProvider', () => {
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.error).toEqual(authenticationError);
});
});
it('fails if authentication with token from header fails with unknown error', async () => {
const authorization = `Bearer foo`;
const request = requestFixture({ headers: { authorization } });
describe('`authenticate` method', () => {
it('does not redirect AJAX requests that can not be authenticated to the login page.', async () => {
// Add `kbn-xsrf` header to make `can_redirect_request` think that it's AJAX request and
// avoid triggering of redirect logic.
const authenticationResult = await provider.authenticate(
httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }),
null
);
const authenticationError = new errors.InternalServerError('something went wrong');
callWithRequest.withArgs(request, 'shield.authenticate').rejects(authenticationError);
expect(authenticationResult.notHandled()).toBe(true);
});
it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => {
const authenticationResult = await provider.authenticate(
httpServerMock.createKibanaRequest({ path: '/s/foo/some-path # that needs to be encoded' }),
null
);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe(
'/base-path/login?next=%2Fbase-path%2Fs%2Ffoo%2Fsome-path%20%23%20that%20needs%20to%20be%20encoded'
);
});
it('succeeds if only `authorization` header is available and returns neither state nor authHeaders.', async () => {
const authorization = 'Bearer foo';
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
const user = mockAuthenticatedUser();
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request);
sinon.assert.calledOnce(callWithRequest);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.authHeaders).toBeUndefined();
expect(authenticationResult.state).toBeUndefined();
});
it('succeeds if only state is available.', async () => {
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const user = mockAuthenticatedUser();
const authorization = `Bearer ${tokenPair.accessToken}`;
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toEqual({ authorization });
expect(request.headers).not.toHaveProperty('authorization');
});
it('succeeds with valid session even if requiring a token refresh', async () => {
const user = mockAuthenticatedUser();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
mockOptions.tokens.refresh
.withArgs(tokenPair.refreshToken)
.resolves({ accessToken: 'newfoo', refreshToken: 'newbar' });
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer newfoo' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledOnce(mockOptions.tokens.refresh);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toEqual({ accessToken: 'newfoo', refreshToken: 'newbar' });
expect(authenticationResult.authHeaders).toEqual({ authorization: 'Bearer newfoo' });
expect(request.headers).not.toHaveProperty('authorization');
});
it('does not handle `authorization` header with unsupported schema even if state contains valid credentials.', async () => {
const request = httpServerMock.createKibanaRequest({
headers: { authorization: 'Basic ***' },
});
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const user = mockAuthenticatedUser();
const authorization = `Bearer ${tokenPair.accessToken}`;
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.notCalled(mockOptions.client.asScoped);
expect(request.headers.authorization).toBe('Basic ***');
expect(authenticationResult.notHandled()).toBe(true);
});
it('authenticates only via `authorization` header even if state is available.', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const authorization = `Bearer foo-from-header`;
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
const user = mockAuthenticatedUser();
// GetUser will be called with request's `authorization` header.
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.resolves(user);
const authenticationResult = await provider.authenticate(request, tokenPair);
expect(authenticationResult.succeeded()).toBe(true);
expect(authenticationResult.user).toEqual(user);
expect(authenticationResult.state).toBeUndefined();
expect(authenticationResult.authHeaders).toBeUndefined();
expect(request.headers.authorization).toEqual('Bearer foo-from-header');
});
it('fails if authentication with token from header fails with unknown error', async () => {
const authorization = `Bearer foo`;
const request = httpServerMock.createKibanaRequest({ headers: { authorization } });
const authenticationError = new errors.InternalServerError('something went wrong');
mockScopedClusterClient(mockOptions.client, sinon.match({ headers: { authorization } }))
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(authenticationError);
const authenticationResult = await provider.authenticate(request);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
@ -262,20 +256,18 @@ describe('TokenAuthenticationProvider', () => {
it('fails if authentication with token from state fails with unknown error.', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const authenticationError = new errors.InternalServerError('something went wrong');
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(authenticationError);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledOnce(callWithRequest);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.user).toBeUndefined();
@ -284,23 +276,22 @@ describe('TokenAuthenticationProvider', () => {
});
it('fails if token refresh is rejected with unknown error', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
const refreshError = new errors.InternalServerError('failed to refresh token');
tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshError);
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).rejects(refreshError);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(tokens.refresh);
sinon.assert.calledOnce(mockOptions.tokens.refresh);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
@ -310,77 +301,81 @@ describe('TokenAuthenticationProvider', () => {
});
it('redirects non-AJAX requests to /login and clears session if token document is missing', async () => {
const request = requestFixture({ path: '/some-path' });
const request = httpServerMock.createKibanaRequest({ path: '/some-path' });
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({
statusCode: 500,
body: { error: { reason: 'token document is missing and must be present' } },
});
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(tokens.refresh);
sinon.assert.calledOnce(mockOptions.tokens.refresh);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/login?next=%2Fsome-path');
expect(authenticationResult.redirectURL).toBe(
'/base-path/login?next=%2Fbase-path%2Fsome-path'
);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toEqual(null);
expect(authenticationResult.error).toBeUndefined();
});
it('redirects non-AJAX requests to /login and clears session if token cannot be refreshed', async () => {
const request = requestFixture({ path: '/some-path' });
const request = httpServerMock.createKibanaRequest({ path: '/some-path' });
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(tokens.refresh);
sinon.assert.calledOnce(mockOptions.tokens.refresh);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/login?next=%2Fsome-path');
expect(authenticationResult.redirectURL).toBe(
'/base-path/login?next=%2Fbase-path%2Fsome-path'
);
expect(authenticationResult.user).toBeUndefined();
expect(authenticationResult.state).toEqual(null);
expect(authenticationResult.error).toBeUndefined();
});
it('does not redirect AJAX requests if token token cannot be refreshed', async () => {
const request = requestFixture({ headers: { 'kbn-xsrf': 'xsrf' }, path: '/some-path' });
const request = httpServerMock.createKibanaRequest({
headers: { 'kbn-xsrf': 'xsrf' },
path: '/some-path',
});
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
mockOptions.tokens.refresh.withArgs(tokenPair.refreshToken).resolves(null);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledOnce(callWithRequest);
sinon.assert.calledOnce(tokens.refresh);
sinon.assert.calledOnce(mockOptions.tokens.refresh);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
@ -392,32 +387,31 @@ describe('TokenAuthenticationProvider', () => {
});
it('fails if new access token is rejected after successful refresh', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } }),
'shield.authenticate'
)
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: `Bearer ${tokenPair.accessToken}` } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects({ statusCode: 401 });
tokens.refresh
mockOptions.tokens.refresh
.withArgs(tokenPair.refreshToken)
.resolves({ accessToken: 'newfoo', refreshToken: 'newbar' });
const authenticationError = new errors.AuthenticationException('Some error');
callWithRequest
.withArgs(
sinon.match({ headers: { authorization: 'Bearer newfoo' } }),
'shield.authenticate'
)
mockScopedClusterClient(
mockOptions.client,
sinon.match({ headers: { authorization: 'Bearer newfoo' } })
)
.callAsCurrentUser.withArgs('shield.authenticate')
.rejects(authenticationError);
const authenticationResult = await provider.authenticate(request, tokenPair);
sinon.assert.calledTwice(callWithRequest);
sinon.assert.calledOnce(tokens.refresh);
sinon.assert.calledOnce(mockOptions.tokens.refresh);
expect(request.headers).not.toHaveProperty('authorization');
expect(authenticationResult.failed()).toBe(true);
@ -427,67 +421,67 @@ describe('TokenAuthenticationProvider', () => {
});
});
describe('`deauthenticate` method', () => {
describe('`logout` method', () => {
it('returns `notHandled` if state is not presented.', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
let deauthenticateResult = await provider.deauthenticate(request);
let deauthenticateResult = await provider.logout(request);
expect(deauthenticateResult.notHandled()).toBe(true);
deauthenticateResult = await provider.deauthenticate(request, null);
deauthenticateResult = await provider.logout(request, null);
expect(deauthenticateResult.notHandled()).toBe(true);
sinon.assert.notCalled(tokens.invalidate);
sinon.assert.notCalled(mockOptions.tokens.invalidate);
deauthenticateResult = await provider.deauthenticate(request, tokenPair);
deauthenticateResult = await provider.logout(request, tokenPair);
expect(deauthenticateResult.notHandled()).toBe(false);
});
it('fails if `tokens.invalidate` fails', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const failureReason = new Error('failed to delete token');
tokens.invalidate.withArgs(tokenPair).rejects(failureReason);
mockOptions.tokens.invalidate.withArgs(tokenPair).rejects(failureReason);
const authenticationResult = await provider.deauthenticate(request, tokenPair);
const authenticationResult = await provider.logout(request, tokenPair);
sinon.assert.calledOnce(tokens.invalidate);
sinon.assert.calledWithExactly(tokens.invalidate, tokenPair);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair);
expect(authenticationResult.failed()).toBe(true);
expect(authenticationResult.error).toBe(failureReason);
});
it('redirects to /login if tokens are invalidated successfully', async () => {
const request = requestFixture();
const request = httpServerMock.createKibanaRequest();
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
tokens.invalidate.withArgs(tokenPair).resolves();
mockOptions.tokens.invalidate.withArgs(tokenPair).resolves();
const authenticationResult = await provider.deauthenticate(request, tokenPair);
const authenticationResult = await provider.logout(request, tokenPair);
sinon.assert.calledOnce(tokens.invalidate);
sinon.assert.calledWithExactly(tokens.invalidate, tokenPair);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/login?msg=LOGGED_OUT');
});
it('redirects to /login with optional search parameters if tokens are invalidated successfully', async () => {
const request = requestFixture({ search: '?yep' });
const request = httpServerMock.createKibanaRequest({ query: { yep: 'nope' } });
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
tokens.invalidate.withArgs(tokenPair).resolves();
mockOptions.tokens.invalidate.withArgs(tokenPair).resolves();
const authenticationResult = await provider.deauthenticate(request, tokenPair);
const authenticationResult = await provider.logout(request, tokenPair);
sinon.assert.calledOnce(tokens.invalidate);
sinon.assert.calledWithExactly(tokens.invalidate, tokenPair);
sinon.assert.calledOnce(mockOptions.tokens.invalidate);
sinon.assert.calledWithExactly(mockOptions.tokens.invalidate, tokenPair);
expect(authenticationResult.redirected()).toBe(true);
expect(authenticationResult.redirectURL).toBe('/base-path/login?yep');
expect(authenticationResult.redirectURL).toBe('/base-path/login?yep=nope');
});
});
});

View file

@ -0,0 +1,255 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { KibanaRequest } from '../../../../../../src/core/server';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
import { BaseAuthenticationProvider } from './base';
import { Tokens, TokenPair } from '../tokens';
import { canRedirectRequest } from '..';
/**
* Describes the parameters that are required by the provider to process the initial login request.
*/
interface ProviderLoginAttempt {
username: string;
password: string;
}
/**
* The state supported by the provider.
*/
type ProviderState = TokenPair;
/**
* Provider that supports token-based request authentication.
*/
export class TokenAuthenticationProvider extends BaseAuthenticationProvider {
/**
* Performs initial login request using username and password.
* @param request Request instance.
* @param loginAttempt Login attempt description.
* @param [state] Optional state object associated with the provider.
*/
/**
* Performs initial login request using username and password.
* @param request Request instance.
* @param attempt User credentials.
* @param [state] Optional state object associated with the provider.
*/
public async login(
request: KibanaRequest,
{ username, password }: ProviderLoginAttempt,
state?: ProviderState | null
) {
this.logger.debug('Trying to perform a login.');
try {
// First attempt to exchange login credentials for an access token
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callAsInternalUser('shield.getAccessToken', {
body: { grant_type: 'password', username, password },
});
this.logger.debug('Get token API request to Elasticsearch successful');
// Then attempt to query for the user details using the new token
const authHeaders = { authorization: `Bearer ${accessToken}` };
const user = await this.getUser(request, authHeaders);
this.logger.debug('Login has been successfully performed.');
return AuthenticationResult.succeeded(user, {
authHeaders,
state: { accessToken, refreshToken },
});
} catch (err) {
this.logger.debug(`Failed to perform a login: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
/**
* Performs token-based request authentication
* @param request Request instance.
* @param [state] Optional state object associated with the provider.
*/
public async authenticate(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to authenticate user request to ${request.url.path}.`);
// if there isn't a payload, try header-based token auth
const {
authenticationResult: headerAuthResult,
headerNotRecognized,
} = await this.authenticateViaHeader(request);
if (headerNotRecognized) {
return headerAuthResult;
}
let authenticationResult = headerAuthResult;
// if we still can't attempt auth, try authenticating via state (session token)
if (authenticationResult.notHandled() && state) {
authenticationResult = await this.authenticateViaState(request, state);
if (
authenticationResult.failed() &&
Tokens.isAccessTokenExpiredError(authenticationResult.error)
) {
authenticationResult = await this.authenticateViaRefreshToken(request, state);
}
}
// finally, if authentication still can not be handled for this
// request/state combination, redirect to the login page if appropriate
if (authenticationResult.notHandled() && canRedirectRequest(request)) {
authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request));
}
return authenticationResult;
}
/**
* Redirects user to the login page preserving query string parameters.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
public async logout(request: KibanaRequest, state?: ProviderState | null) {
this.logger.debug(`Trying to log user out via ${request.url.path}.`);
if (!state) {
this.logger.debug('There are no access and refresh tokens to invalidate.');
return DeauthenticationResult.notHandled();
}
this.logger.debug('Token-based logout has been initiated by the user.');
try {
await this.options.tokens.invalidate(state);
} catch (err) {
this.logger.debug(`Failed invalidating user's access token: ${err.message}`);
return DeauthenticationResult.failed(err);
}
const queryString = request.url.search || `?msg=LOGGED_OUT`;
return DeauthenticationResult.redirectTo(
`${this.options.basePath.get(request)}/login${queryString}`
);
}
/**
* Validates whether request contains `Bearer ***` Authorization header and just passes it
* forward to Elasticsearch backend.
* @param request Request instance.
*/
private async authenticateViaHeader(request: KibanaRequest) {
this.logger.debug('Trying to authenticate via header.');
const authorization = request.headers.authorization;
if (!authorization || typeof authorization !== 'string') {
this.logger.debug('Authorization header is not presented.');
return { authenticationResult: AuthenticationResult.notHandled() };
}
const authenticationSchema = authorization.split(/\s+/)[0];
if (authenticationSchema.toLowerCase() !== 'bearer') {
this.logger.debug(`Unsupported authentication schema: ${authenticationSchema}`);
return { authenticationResult: AuthenticationResult.notHandled(), headerNotRecognized: true };
}
try {
const user = await this.getUser(request);
this.logger.debug('Request has been authenticated via header.');
// We intentionally do not store anything in session state because token
// header auth can only be used on a request by request basis.
return { authenticationResult: AuthenticationResult.succeeded(user) };
} catch (err) {
this.logger.debug(`Failed to authenticate request via header: ${err.message}`);
return { authenticationResult: AuthenticationResult.failed(err) };
}
}
/**
* Tries to extract authorization header from the state and adds it to the request before
* it's forwarded to Elasticsearch backend.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) {
this.logger.debug('Trying to authenticate via state.');
try {
const authHeaders = { authorization: `Bearer ${accessToken}` };
const user = await this.getUser(request, authHeaders);
this.logger.debug('Request has been authenticated via state.');
return AuthenticationResult.succeeded(user, { authHeaders });
} catch (err) {
this.logger.debug(`Failed to authenticate request via state: ${err.message}`);
return AuthenticationResult.failed(err);
}
}
/**
* This method is only called when authentication via access token stored in the state failed because of expired
* token. So we should use refresh token, that is also stored in the state, to extend expired access token and
* authenticate user with it.
* @param request Request instance.
* @param state State value previously stored by the provider.
*/
private async authenticateViaRefreshToken(
request: KibanaRequest,
{ refreshToken }: ProviderState
) {
this.logger.debug('Trying to refresh access token.');
let refreshedTokenPair: TokenPair | null;
try {
refreshedTokenPair = await this.options.tokens.refresh(refreshToken);
} catch (err) {
return AuthenticationResult.failed(err);
}
// If refresh token is no longer valid, then we should clear session and redirect user to the
// login page to re-authenticate, or fail if redirect isn't possible.
if (refreshedTokenPair === null) {
if (canRedirectRequest(request)) {
this.logger.debug('Clearing session since both access and refresh tokens are expired.');
// Set state to `null` to let `Authenticator` know that we want to clear current session.
return AuthenticationResult.redirectTo(this.getLoginPageURL(request), null);
}
return AuthenticationResult.failed(
Boom.badRequest('Both access and refresh tokens are expired.')
);
}
try {
const authHeaders = { authorization: `Bearer ${refreshedTokenPair.accessToken}` };
const user = await this.getUser(request, authHeaders);
this.logger.debug('Request has been authenticated via refreshed token.');
return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair });
} catch (err) {
this.logger.debug(
`Failed to authenticate user using newly refreshed access token: ${err.message}`
);
return AuthenticationResult.failed(err);
}
}
/**
* Constructs login page URL using current url path as `next` query string parameter.
* @param request Request instance.
*/
private getLoginPageURL(request: KibanaRequest) {
const nextURL = encodeURIComponent(`${this.options.basePath.get(request)}${request.url.path}`);
return `${this.options.basePath.get(request)}/login?next=${nextURL}`;
}
}

View file

@ -0,0 +1,230 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { errors } from 'elasticsearch';
import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
import { ClusterClient, ElasticsearchErrorHelpers } from '../../../../../src/core/server';
import { Tokens } from './tokens';
describe('Tokens', () => {
let tokens: Tokens;
let mockClusterClient: jest.Mocked<PublicMethodsOf<ClusterClient>>;
beforeEach(() => {
mockClusterClient = elasticsearchServiceMock.createClusterClient();
const tokensOptions = {
client: mockClusterClient,
logger: loggingServiceMock.create().get(),
};
tokens = new Tokens(tokensOptions);
});
it('isAccessTokenExpiredError() returns `true` only if token expired or its document is missing', () => {
const nonExpirationErrors = [
{},
new Error(),
new errors.InternalServerError(),
new errors.Forbidden(),
{ statusCode: 500, body: { error: { reason: 'some unknown reason' } } },
];
for (const error of nonExpirationErrors) {
expect(Tokens.isAccessTokenExpiredError(error)).toBe(false);
}
const expirationErrors = [
{ statusCode: 401 },
ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()),
new errors.AuthenticationException(),
{
statusCode: 500,
body: { error: { reason: 'token document is missing and must be present' } },
},
];
for (const error of expirationErrors) {
expect(Tokens.isAccessTokenExpiredError(error)).toBe(true);
}
});
describe('refresh()', () => {
const refreshToken = 'some-refresh-token';
it('throws if API call fails with unknown reason', async () => {
const refreshFailureReason = new errors.ServiceUnavailable('Server is not available');
mockClusterClient.callAsInternalUser.mockRejectedValue(refreshFailureReason);
await expect(tokens.refresh(refreshToken)).rejects.toBe(refreshFailureReason);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: refreshToken },
});
});
it('returns `null` if refresh token is not valid', async () => {
const refreshFailureReason = new errors.BadRequest();
mockClusterClient.callAsInternalUser.mockRejectedValue(refreshFailureReason);
await expect(tokens.refresh(refreshToken)).resolves.toBe(null);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: refreshToken },
});
});
it('returns token pair if refresh API call succeeds', async () => {
const tokenPair = { accessToken: 'access-token', refreshToken: 'refresh-token' };
mockClusterClient.callAsInternalUser.mockResolvedValue({
access_token: tokenPair.accessToken,
refresh_token: tokenPair.refreshToken,
});
await expect(tokens.refresh(refreshToken)).resolves.toEqual(tokenPair);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: refreshToken },
});
});
});
describe('invalidate()', () => {
it('throws if call to delete access token responds with an error', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const failureReason = new Error('failed to delete token');
mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => {
if (args && args.body && args.body.token) {
return Promise.reject(failureReason);
}
return Promise.resolve({ invalidated_tokens: 1 });
});
await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { token: tokenPair.accessToken } }
);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { refresh_token: tokenPair.refreshToken } }
);
});
it('throws if call to delete refresh token responds with an error', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
const failureReason = new Error('failed to delete token');
mockClusterClient.callAsInternalUser.mockImplementation((methodName, args: any) => {
if (args && args.body && args.body.refresh_token) {
return Promise.reject(failureReason);
}
return Promise.resolve({ invalidated_tokens: 1 });
});
await expect(tokens.invalidate(tokenPair)).rejects.toBe(failureReason);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { token: tokenPair.accessToken } }
);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { refresh_token: tokenPair.refreshToken } }
);
});
it('invalidates all provided tokens', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { token: tokenPair.accessToken } }
);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { refresh_token: tokenPair.refreshToken } }
);
});
it('invalidates only access token if only access token is provided', async () => {
const tokenPair = { accessToken: 'foo' };
mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { token: tokenPair.accessToken } }
);
});
it('invalidates only refresh token if only refresh token is provided', async () => {
const tokenPair = { refreshToken: 'foo' };
mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 1 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { refresh_token: tokenPair.refreshToken } }
);
});
it('does not fail if none of the tokens were invalidated', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 0 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { token: tokenPair.accessToken } }
);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { refresh_token: tokenPair.refreshToken } }
);
});
it('does not fail if more than one token per access or refresh token were invalidated', async () => {
const tokenPair = { accessToken: 'foo', refreshToken: 'bar' };
mockClusterClient.callAsInternalUser.mockResolvedValue({ invalidated_tokens: 5 });
await expect(tokens.invalidate(tokenPair)).resolves.toBe(undefined);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(2);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { token: tokenPair.accessToken } }
);
expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith(
'shield.deleteAccessToken',
{ body: { refresh_token: tokenPair.refreshToken } }
);
});
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Legacy } from 'kibana';
import { ClusterClient, Logger } from '../../../../../src/core/server';
import { getErrorStatusCode } from '../errors';
/**
@ -29,12 +29,16 @@ export interface TokenPair {
* various authentication providers.
*/
export class Tokens {
/**
* Logger instance bound to `tokens` context.
*/
private readonly logger: Logger;
constructor(
private readonly options: Readonly<{
client: Legacy.Plugins.elasticsearch.Cluster;
log: (tags: string[], message: string) => void;
}>
) {}
private readonly options: Readonly<{ client: PublicMethodsOf<ClusterClient>; logger: Logger }>
) {
this.logger = options.logger;
}
/**
* Tries to exchange provided refresh token to a new pair of access and refresh tokens.
@ -46,15 +50,15 @@ export class Tokens {
const {
access_token: accessToken,
refresh_token: refreshToken,
} = await this.options.client.callWithInternalUser('shield.getAccessToken', {
} = await this.options.client.callAsInternalUser('shield.getAccessToken', {
body: { grant_type: 'refresh_token', refresh_token: existingRefreshToken },
});
this.debug('Access token has been successfully refreshed.');
this.logger.debug('Access token has been successfully refreshed.');
return { accessToken, refreshToken };
} catch (err) {
this.debug(`Failed to refresh access token: ${err.message}`);
this.logger.debug(`Failed to refresh access token: ${err.message}`);
// There are at least two common cases when refresh token request can fail:
// 1. Refresh token is valid only for 24 hours and if it hasn't been used it expires.
@ -73,7 +77,7 @@ export class Tokens {
// same refresh token multiple times yielding the same refreshed access/refresh token pair it's still possible
// to hit the case when refresh token is no longer valid.
if (getErrorStatusCode(err) === 400) {
this.debug('Refresh token is either expired or already used.');
this.logger.debug('Refresh token is either expired or already used.');
return null;
}
@ -88,28 +92,28 @@ export class Tokens {
* @param [refreshToken] Optional refresh token to invalidate.
*/
public async invalidate({ accessToken, refreshToken }: Partial<TokenPair>) {
this.debug('Invalidating access/refresh token pair.');
this.logger.debug('Invalidating access/refresh token pair.');
let invalidationError;
if (refreshToken) {
let invalidatedTokensCount;
try {
invalidatedTokensCount = (await this.options.client.callWithInternalUser(
invalidatedTokensCount = (await this.options.client.callAsInternalUser(
'shield.deleteAccessToken',
{ body: { refresh_token: refreshToken } }
)).invalidated_tokens;
} catch (err) {
this.debug(`Failed to invalidate refresh token: ${err.message}`);
this.logger.debug(`Failed to invalidate refresh token: ${err.message}`);
// We don't re-throw the error here to have a chance to invalidate access token if it's provided.
invalidationError = err;
}
if (invalidatedTokensCount === 0) {
this.debug('Refresh token was already invalidated.');
this.logger.debug('Refresh token was already invalidated.');
} else if (invalidatedTokensCount === 1) {
this.debug('Refresh token has been successfully invalidated.');
this.logger.debug('Refresh token has been successfully invalidated.');
} else if (invalidatedTokensCount > 1) {
this.debug(
this.logger.debug(
`${invalidatedTokensCount} refresh tokens were invalidated, this is unexpected.`
);
}
@ -118,21 +122,23 @@ export class Tokens {
if (accessToken) {
let invalidatedTokensCount;
try {
invalidatedTokensCount = (await this.options.client.callWithInternalUser(
invalidatedTokensCount = (await this.options.client.callAsInternalUser(
'shield.deleteAccessToken',
{ body: { token: accessToken } }
)).invalidated_tokens;
} catch (err) {
this.debug(`Failed to invalidate access token: ${err.message}`);
this.logger.debug(`Failed to invalidate access token: ${err.message}`);
invalidationError = err;
}
if (invalidatedTokensCount === 0) {
this.debug('Access token was already invalidated.');
this.logger.debug('Access token was already invalidated.');
} else if (invalidatedTokensCount === 1) {
this.debug('Access token has been successfully invalidated.');
this.logger.debug('Access token has been successfully invalidated.');
} else if (invalidatedTokensCount > 1) {
this.debug(`${invalidatedTokensCount} access tokens were invalidated, this is unexpected.`);
this.logger.debug(
`${invalidatedTokensCount} access tokens were invalidated, this is unexpected.`
);
}
}
@ -161,12 +167,4 @@ export class Tokens {
))
);
}
/**
* Logs message with `debug` level and tokens/security related tags.
* @param message Message to log.
*/
private debug(message: string) {
this.options.log(['debug', 'security', 'tokens'], message);
}
}

View file

@ -0,0 +1,432 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('crypto', () => ({ randomBytes: jest.fn() }));
import { first } from 'rxjs/operators';
import { loggingServiceMock, coreMock } from '../../../../src/core/server/mocks';
import { createConfig$, ConfigSchema } from './config';
describe('config schema', () => {
it('generates proper defaults', () => {
expect(ConfigSchema.validate({})).toMatchInlineSnapshot(`
Object {
"authc": Object {
"providers": Array [
"basic",
],
},
"cookieName": "sid",
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"public": Object {},
"secureCookies": false,
"sessionTimeout": null,
}
`);
expect(ConfigSchema.validate({}, { dist: false })).toMatchInlineSnapshot(`
Object {
"authc": Object {
"providers": Array [
"basic",
],
},
"cookieName": "sid",
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"public": Object {},
"secureCookies": false,
"sessionTimeout": null,
}
`);
expect(ConfigSchema.validate({}, { dist: true })).toMatchInlineSnapshot(`
Object {
"authc": Object {
"providers": Array [
"basic",
],
},
"cookieName": "sid",
"public": Object {},
"secureCookies": false,
"sessionTimeout": null,
}
`);
});
it('should throw error if xpack.security.encryptionKey is less than 32 characters', () => {
expect(() =>
ConfigSchema.validate({ encryptionKey: 'foo' })
).toThrowErrorMatchingInlineSnapshot(
`"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."`
);
expect(() =>
ConfigSchema.validate({ encryptionKey: 'foo' }, { dist: true })
).toThrowErrorMatchingInlineSnapshot(
`"[encryptionKey]: value is [foo] but it must have a minimum length of [32]."`
);
});
describe('public', () => {
it('properly validates `protocol`', async () => {
expect(ConfigSchema.validate({ public: { protocol: 'http' } }).public).toMatchInlineSnapshot(`
Object {
"protocol": "http",
}
`);
expect(ConfigSchema.validate({ public: { protocol: 'https' } }).public)
.toMatchInlineSnapshot(`
Object {
"protocol": "https",
}
`);
expect(() => ConfigSchema.validate({ public: { protocol: 'ftp' } }))
.toThrowErrorMatchingInlineSnapshot(`
"[public.protocol]: types that failed validation:
- [public.protocol.0]: expected value to equal [http] but got [ftp]
- [public.protocol.1]: expected value to equal [https] but got [ftp]"
`);
expect(() => ConfigSchema.validate({ public: { protocol: 'some-protocol' } }))
.toThrowErrorMatchingInlineSnapshot(`
"[public.protocol]: types that failed validation:
- [public.protocol.0]: expected value to equal [http] but got [some-protocol]
- [public.protocol.1]: expected value to equal [https] but got [some-protocol]"
`);
});
it('properly validates `hostname`', async () => {
expect(ConfigSchema.validate({ public: { hostname: 'elastic.co' } }).public)
.toMatchInlineSnapshot(`
Object {
"hostname": "elastic.co",
}
`);
expect(ConfigSchema.validate({ public: { hostname: '192.168.1.1' } }).public)
.toMatchInlineSnapshot(`
Object {
"hostname": "192.168.1.1",
}
`);
expect(ConfigSchema.validate({ public: { hostname: '::1' } }).public).toMatchInlineSnapshot(`
Object {
"hostname": "::1",
}
`);
expect(() =>
ConfigSchema.validate({ public: { hostname: 'http://elastic.co' } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.hostname]: value is [http://elastic.co] but it must be a valid hostname (see RFC 1123)."`
);
expect(() =>
ConfigSchema.validate({ public: { hostname: 'localhost:5601' } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.hostname]: value is [localhost:5601] but it must be a valid hostname (see RFC 1123)."`
);
});
it('properly validates `port`', async () => {
expect(ConfigSchema.validate({ public: { port: 1234 } }).public).toMatchInlineSnapshot(`
Object {
"port": 1234,
}
`);
expect(ConfigSchema.validate({ public: { port: 0 } }).public).toMatchInlineSnapshot(`
Object {
"port": 0,
}
`);
expect(ConfigSchema.validate({ public: { port: 65535 } }).public).toMatchInlineSnapshot(`
Object {
"port": 65535,
}
`);
expect(() =>
ConfigSchema.validate({ public: { port: -1 } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.port]: Value is [-1] but it must be equal to or greater than [0]."`
);
expect(() =>
ConfigSchema.validate({ public: { port: 65536 } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.port]: Value is [65536] but it must be equal to or lower than [65535]."`
);
expect(() =>
ConfigSchema.validate({ public: { port: '56x1' } })
).toThrowErrorMatchingInlineSnapshot(
`"[public.port]: expected value of type [number] but got [string]"`
);
});
});
describe('authc.oidc', () => {
it(`returns a validation error when authc.providers is "['oidc']" and realm is unspecified`, async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['oidc'] } })
).toThrowErrorMatchingInlineSnapshot(
`"[authc.oidc.realm]: expected value of type [string] but got [undefined]"`
);
expect(() =>
ConfigSchema.validate({ authc: { providers: ['oidc'], oidc: {} } })
).toThrowErrorMatchingInlineSnapshot(
`"[authc.oidc.realm]: expected value of type [string] but got [undefined]"`
);
});
it(`is valid when authc.providers is "['oidc']" and realm is specified`, async () => {
expect(
ConfigSchema.validate({
authc: { providers: ['oidc'], oidc: { realm: 'realm-1' } },
}).authc
).toMatchInlineSnapshot(`
Object {
"oidc": Object {
"realm": "realm-1",
},
"providers": Array [
"oidc",
],
}
`);
});
it(`returns a validation error when authc.providers is "['oidc', 'basic']" and realm is unspecified`, async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['oidc', 'basic'] } })
).toThrowErrorMatchingInlineSnapshot(
`"[authc.oidc.realm]: expected value of type [string] but got [undefined]"`
);
});
it(`is valid when authc.providers is "['oidc', 'basic']" and realm is specified`, async () => {
expect(
ConfigSchema.validate({
authc: { providers: ['oidc', 'basic'], oidc: { realm: 'realm-1' } },
}).authc
).toMatchInlineSnapshot(`
Object {
"oidc": Object {
"realm": "realm-1",
},
"providers": Array [
"oidc",
"basic",
],
}
`);
});
it(`realm is not allowed when authc.providers is "['basic']"`, async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['basic'], oidc: { realm: 'realm-1' } } })
).toThrowErrorMatchingInlineSnapshot(`"[authc.oidc]: a value wasn't expected to be present"`);
});
});
describe('authc.saml', () => {
it('does not fail if authc.providers includes `saml`, but `saml.realm` is not specified', async () => {
expect(ConfigSchema.validate({ authc: { providers: ['saml'] } }).authc)
.toMatchInlineSnapshot(`
Object {
"providers": Array [
"saml",
],
"saml": Object {},
}
`);
expect(ConfigSchema.validate({ authc: { providers: ['saml'], saml: {} } }).authc)
.toMatchInlineSnapshot(`
Object {
"providers": Array [
"saml",
],
"saml": Object {},
}
`);
expect(
ConfigSchema.validate({
authc: { providers: ['saml'], saml: { realm: 'realm-1' } },
}).authc
).toMatchInlineSnapshot(`
Object {
"providers": Array [
"saml",
],
"saml": Object {
"realm": "realm-1",
},
}
`);
});
it('`realm` is not allowed if saml provider is not enabled', async () => {
expect(() =>
ConfigSchema.validate({ authc: { providers: ['basic'], saml: { realm: 'realm-1' } } })
).toThrowErrorMatchingInlineSnapshot(`"[authc.saml]: a value wasn't expected to be present"`);
});
});
});
describe('createConfig$()', () => {
it('should log a warning and set xpack.security.encryptionKey if not set', async () => {
const mockRandomBytes = jest.requireMock('crypto').randomBytes;
mockRandomBytes.mockReturnValue('ab'.repeat(16));
const contextMock = coreMock.createPluginInitializerContext({
authc: { providers: ['basic'] },
});
const config = await createConfig$(contextMock, true)
.pipe(first())
.toPromise();
expect(config).toMatchInlineSnapshot(`
Object {
"authc": Object {
"providers": Array [
"basic",
],
},
"encryptionKey": "abababababababababababababababab",
"secureCookies": true,
}
`);
expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.security.encryptionKey in kibana.yml",
],
]
`);
});
it('should log a warning if SSL is not configured', async () => {
const contextMock = coreMock.createPluginInitializerContext({
encryptionKey: 'a'.repeat(32),
secureCookies: false,
authc: { providers: ['basic'] },
});
const config = await createConfig$(contextMock, false)
.pipe(first())
.toPromise();
expect(config).toMatchInlineSnapshot(`
Object {
"authc": Object {
"providers": Array [
"basic",
],
},
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"secureCookies": false,
}
`);
expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Session cookies will be transmitted over insecure connections. This is not recommended.",
],
]
`);
});
it('should log a warning if SSL is not configured yet secure cookies are being used', async () => {
const contextMock = coreMock.createPluginInitializerContext({
encryptionKey: 'a'.repeat(32),
secureCookies: true,
authc: { providers: ['basic'] },
});
const config = await createConfig$(contextMock, false)
.pipe(first())
.toPromise();
expect(config).toMatchInlineSnapshot(`
Object {
"authc": Object {
"providers": Array [
"basic",
],
},
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"secureCookies": true,
}
`);
expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(`
Array [
Array [
"Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to function properly.",
],
]
`);
});
it('should set xpack.security.secureCookies if SSL is configured', async () => {
const contextMock = coreMock.createPluginInitializerContext({
encryptionKey: 'a'.repeat(32),
secureCookies: false,
authc: { providers: ['basic'] },
});
const config = await createConfig$(contextMock, true)
.pipe(first())
.toPromise();
expect(config).toMatchInlineSnapshot(`
Object {
"authc": Object {
"providers": Array [
"basic",
],
},
"encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"secureCookies": true,
}
`);
expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]);
});
it('should set authProviders to authc.providers', async () => {
const contextMock = coreMock.createPluginInitializerContext({
authProviders: ['saml', 'basic'],
});
const config = await createConfig$(contextMock, true)
.pipe(first())
.toPromise();
expect(config).toMatchInlineSnapshot(`
Object {
"authProviders": Array [
"saml",
"basic",
],
"authc": Object {
"providers": Array [
"saml",
"basic",
],
},
"encryptionKey": "abababababababababababababababab",
"secureCookies": true,
}
`);
});
});

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import crypto from 'crypto';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { schema, Type, TypeOf } from '@kbn/config-schema';
import { PluginInitializerContext } from '../../../../src/core/server';
export type ConfigType = ReturnType<typeof createConfig$> extends Observable<infer P>
? P
: ReturnType<typeof createConfig$>;
const providerOptionsSchema = (providerType: string, optionsSchema: Type<any>) =>
schema.conditional(
schema.siblingRef('providers'),
schema.arrayOf(schema.string(), {
validate: providers => (!providers.includes(providerType) ? 'error' : undefined),
}),
optionsSchema,
schema.never()
);
export const ConfigSchema = schema.object(
{
cookieName: schema.string({ defaultValue: 'sid' }),
encryptionKey: schema.conditional(
schema.contextRef('dist'),
true,
schema.maybe(schema.string({ minLength: 32 })),
schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) })
),
sessionTimeout: schema.oneOf([schema.number(), schema.literal(null)], { defaultValue: null }),
secureCookies: schema.boolean({ defaultValue: false }),
public: schema.object({
protocol: schema.maybe(schema.oneOf([schema.literal('http'), schema.literal('https')])),
hostname: schema.maybe(schema.string({ hostname: true })),
port: schema.maybe(schema.number({ min: 0, max: 65535 })),
}),
// This property is deprecated, but we include it here since new platform doesn't support config deprecation
// transformations yet (e.g. `rename`), so we handle it manually for now.
authProviders: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })),
authc: schema.object({
providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }),
oidc: providerOptionsSchema('oidc', schema.maybe(schema.object({ realm: schema.string() }))),
saml: providerOptionsSchema(
'saml',
schema.maybe(schema.object({ realm: schema.maybe(schema.string()) }))
),
}),
},
// This option should be removed as soon as we entirely migrate config from legacy Security plugin.
{ allowUnknowns: true }
);
export function createConfig$(context: PluginInitializerContext, isTLSEnabled: boolean) {
return context.config.create<TypeOf<typeof ConfigSchema>>().pipe(
map(config => {
const logger = context.logger.get('config');
let encryptionKey = config.encryptionKey;
if (encryptionKey === undefined) {
logger.warn(
'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' +
'restart, please set xpack.security.encryptionKey in kibana.yml'
);
encryptionKey = crypto.randomBytes(16).toString('hex');
}
let secureCookies = config.secureCookies;
if (!isTLSEnabled) {
if (secureCookies) {
logger.warn(
'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' +
'function properly.'
);
} else {
logger.warn(
'Session cookies will be transmitted over insecure connections. This is not recommended.'
);
}
} else if (!secureCookies) {
secureCookies = true;
}
return {
...config,
encryptionKey,
secureCookies,
authc: {
...config.authc,
// If deprecated `authProviders` is specified that most likely means that `authc.providers` isn't, but if it's
// specified then this case should be caught by the config deprecation subsystem in the legacy platform.
providers: config.authProviders || config.authc.providers,
},
};
})
);
}

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from '../../../../src/core/server';
import { ConfigSchema } from './config';
import { Plugin } from './plugin';
// These exports are part of public Security plugin contract, any change in signature of exported
// functions or removal of exports should be considered as a breaking change. Ideally we should
// reduce number of such exports to zero and provide everything we want to expose via Setup/Start
// run-time contracts.
export { wrapError } from './errors';
export {
canRedirectRequest,
AuthenticationResult,
BasicCredentials,
DeauthenticationResult,
} from './authentication';
export { PluginSetupContract } from './plugin';
export const config = { schema: ConfigSchema };
export const plugin = (initializerContext: PluginInitializerContext) =>
new Plugin(initializerContext);

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks';
import { Plugin } from './plugin';
import { ClusterClient, CoreSetup } from '../../../../src/core/server';
describe('Security Plugin', () => {
let plugin: Plugin;
let mockCoreSetup: MockedKeys<CoreSetup>;
let mockClusterClient: jest.Mocked<PublicMethodsOf<ClusterClient>>;
beforeEach(() => {
plugin = new Plugin(
coreMock.createPluginInitializerContext({
cookieName: 'sid',
sessionTimeout: 1500,
authc: { providers: ['saml', 'token'], saml: { realm: 'saml1' } },
})
);
mockCoreSetup = coreMock.createSetup();
mockCoreSetup.http.isTlsEnabled = true;
mockClusterClient = elasticsearchServiceMock.createClusterClient();
mockCoreSetup.elasticsearch.createClient.mockReturnValue(
(mockClusterClient as unknown) as jest.Mocked<ClusterClient>
);
});
describe('setup()', () => {
it('exposes proper contract', async () => {
await expect(plugin.setup(mockCoreSetup)).resolves.toMatchInlineSnapshot(`
Object {
"authc": Object {
"getCurrentUser": [Function],
"isAuthenticated": [Function],
"login": [Function],
"logout": [Function],
},
"config": Object {
"authc": Object {
"providers": Array [
"saml",
"token",
],
},
"cookieName": "sid",
"secureCookies": true,
"sessionTimeout": 1500,
},
"registerLegacyAPI": [Function],
}
`);
});
it('properly creates cluster client instance', async () => {
await plugin.setup(mockCoreSetup);
expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledTimes(1);
expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledWith('security', {
plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')],
});
});
});
describe('stop()', () => {
beforeEach(async () => await plugin.setup(mockCoreSetup));
it('properly closes cluster client instance', async () => {
expect(mockClusterClient.close).not.toHaveBeenCalled();
await plugin.stop();
expect(mockClusterClient.close).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { first } from 'rxjs/operators';
import {
ClusterClient,
CoreSetup,
KibanaRequest,
Logger,
PluginInitializerContext,
RecursiveReadonly,
} from '../../../../src/core/server';
import { deepFreeze } from '../../../../src/core/utils';
import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info';
import { AuthenticatedUser } from '../common/model';
import { Authenticator, setupAuthentication } from './authentication';
import { createConfig$ } from './config';
/**
* Describes a set of APIs that is available in the legacy platform only and required by this plugin
* to function properly.
*/
export interface LegacyAPI {
xpackInfo: Pick<XPackInfo, 'isAvailable' | 'feature'>;
serverConfig: { protocol: string; hostname: string; port: number };
isSystemAPIRequest: (request: KibanaRequest) => boolean;
}
/**
* Describes public Security plugin contract returned at the `setup` stage.
*/
export interface PluginSetupContract {
authc: {
login: Authenticator['login'];
logout: Authenticator['logout'];
getCurrentUser: (request: KibanaRequest) => Promise<AuthenticatedUser | null>;
isAuthenticated: (request: KibanaRequest) => Promise<boolean>;
};
config: RecursiveReadonly<{
sessionTimeout: number | null;
secureCookies: boolean;
authc: { providers: string[] };
}>;
registerLegacyAPI: (legacyAPI: LegacyAPI) => void;
}
/**
* Represents Security Plugin instance that will be managed by the Kibana plugin system.
*/
export class Plugin {
private readonly logger: Logger;
private clusterClient?: ClusterClient;
private legacyAPI?: LegacyAPI;
private readonly getLegacyAPI = () => {
if (!this.legacyAPI) {
throw new Error('Legacy API is not registered!');
}
return this.legacyAPI;
};
constructor(private readonly initializerContext: PluginInitializerContext) {
this.logger = this.initializerContext.logger.get();
}
public async setup(core: CoreSetup): Promise<RecursiveReadonly<PluginSetupContract>> {
const config = await createConfig$(this.initializerContext, core.http.isTlsEnabled)
.pipe(first())
.toPromise();
this.clusterClient = core.elasticsearch.createClient('security', {
plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')],
});
return deepFreeze({
registerLegacyAPI: (legacyAPI: LegacyAPI) => (this.legacyAPI = legacyAPI),
authc: await setupAuthentication({
core,
config,
clusterClient: this.clusterClient,
loggers: this.initializerContext.logger,
getLegacyAPI: this.getLegacyAPI,
}),
// We should stop exposing this config as soon as only new platform plugin consumes it. The only
// exception may be `sessionTimeout` as other parts of the app may want to know it.
config: {
sessionTimeout: config.sessionTimeout,
secureCookies: config.secureCookies,
cookieName: config.cookieName,
authc: { providers: config.authc.providers },
},
});
}
public start() {
this.logger.debug('Starting plugin');
}
public stop() {
this.logger.debug('Stopping plugin');
if (this.clusterClient) {
this.clusterClient.close();
this.clusterClient = undefined;
}
}
}