mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[7.x] Migrate authentication subsystem to the new platform. (#41593)
This commit is contained in:
parent
945dde0e85
commit
e19a03bb7b
81 changed files with 6224 additions and 5582 deletions
|
@ -75,7 +75,7 @@ export function createRootWithSettings(
|
|||
repl: false,
|
||||
basePath: false,
|
||||
optimize: false,
|
||||
oss: false,
|
||||
oss: true,
|
||||
...cliArgs,
|
||||
},
|
||||
isDevClusterMaster: false,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`;
|
|
@ -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';
|
||||
|
|
4
x-pack/legacy/plugins/security/index.d.ts
vendored
4
x-pack/legacy/plugins/security/index.d.ts
vendored
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
||||
}
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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';
|
8
x-pack/plugins/security/kibana.json
Normal file
8
x-pack/plugins/security/kibana.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"id": "security",
|
||||
"version": "8.0.0",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["xpack", "security"],
|
||||
"server": true,
|
||||
"ui": false
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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).
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
415
x-pack/plugins/security/server/authentication/authenticator.ts
Normal file
415
x-pack/plugins/security/server/authentication/authenticator.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
|
@ -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;
|
350
x-pack/plugins/security/server/authentication/index.test.ts
Normal file
350
x-pack/plugins/security/server/authentication/index.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
157
x-pack/plugins/security/server/authentication/index.ts
Normal file
157
x-pack/plugins/security/server/authentication/index.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
209
x-pack/plugins/security/server/authentication/providers/basic.ts
Normal file
209
x-pack/plugins/security/server/authentication/providers/basic.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
1010
x-pack/plugins/security/server/authentication/providers/saml.test.ts
Normal file
1010
x-pack/plugins/security/server/authentication/providers/saml.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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`;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
255
x-pack/plugins/security/server/authentication/providers/token.ts
Normal file
255
x-pack/plugins/security/server/authentication/providers/token.ts
Normal 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}`;
|
||||
}
|
||||
}
|
230
x-pack/plugins/security/server/authentication/tokens.test.ts
Normal file
230
x-pack/plugins/security/server/authentication/tokens.test.ts
Normal 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 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
432
x-pack/plugins/security/server/config.test.ts
Normal file
432
x-pack/plugins/security/server/config.test.ts
Normal 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,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
103
x-pack/plugins/security/server/config.ts
Normal file
103
x-pack/plugins/security/server/config.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
27
x-pack/plugins/security/server/index.ts
Normal file
27
x-pack/plugins/security/server/index.ts
Normal 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);
|
81
x-pack/plugins/security/server/plugin.test.ts
Normal file
81
x-pack/plugins/security/server/plugin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
114
x-pack/plugins/security/server/plugin.ts
Normal file
114
x-pack/plugins/security/server/plugin.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue