Skip to content

Commit

Permalink
Support deep links inside of RelayState for SAML IdP initiated login.
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin committed Jun 23, 2020
1 parent 0d7471f commit 1815c2f
Show file tree
Hide file tree
Showing 8 changed files with 539 additions and 24 deletions.
17 changes: 17 additions & 0 deletions x-pack/plugins/security/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { resolve } from 'path';
import { 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';
Expand Down Expand Up @@ -45,6 +46,12 @@ export const security = (kibana) => new kibana.Plugin({
hostname: Joi.string().hostname(),
port: Joi.number().integer().min(0).max(65535)
}).default(),
authc: Joi.object({})
.when('authProviders', {
is: Joi.array().items(Joi.string().valid('saml').required(), Joi.string()),
then: Joi.object({ saml: Joi.object({ useRelayStateDeepLink: Joi.boolean().default(false) }) }).default(),
otherwise: Joi.any().forbidden(),
}),
authorization: Joi.object({
legacyFallback: Joi.object({
enabled: Joi.boolean().default(true)
Expand All @@ -56,6 +63,16 @@ export const security = (kibana) => new kibana.Plugin({
}).default();
},

deprecations() {
return [
(settings, log) => {
if (has(settings, 'authc.saml.useRelayStateDeepLink')) {
log('Config key "authc.saml.useRelayStateDeepLink" is deprecated and will be removed in the next major version.');
}
}
];
},

uiExports: {
chromeNavControls: ['plugins/security/views/nav_control'],
managementSections: ['plugins/security/views/management'],
Expand Down
26 changes: 23 additions & 3 deletions x-pack/plugins/security/server/lib/authentication/authenticator.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ function getProviderOptions(server) {
};
}

/**
* Prepares options object that is specific only to an authentication provider.
* @param {Hapi.Server} server HapiJS Server instance.
* @param {string} providerType the type of the provider to get the options for.
* @returns {Object | undefined}
*/
function getProviderSpecificOptions(
server,
providerType
) {
// We can't use `config.has` here as it doesn't currently work with Joi's "alternatives" syntax
// which is used for the `authc` schema.
const authc = server.config().get(`xpack.security.authc`);
return authc && authc.hasOwnProperty(providerType) ? authc[providerType] : undefined;
}

/**
* Authenticator is responsible for authentication of the request using chain of
* authentication providers. The chain is essentially a prioritized list of configured
Expand Down Expand Up @@ -117,7 +133,10 @@ class Authenticator {

this._providers = new Map(
authProviders.map(
(providerType) => [providerType, this._instantiateProvider(providerType, providerOptions)]
(providerType) => {
const providerSpecificOptions = getProviderSpecificOptions(server, providerType);
return [providerType, this._instantiateProvider(providerType, providerOptions, providerSpecificOptions)];
}
)
);
}
Expand Down Expand Up @@ -219,16 +238,17 @@ class Authenticator {
* Instantiates authentication provider based on the provider key from config.
* @param {string} providerType Provider type key.
* @param {Object} options Options to pass to provider's constructor.
* @params {Object} providerSpecificOptions Optional provider specific options.
* @returns {Object} Authentication provider instance.
* @private
*/
_instantiateProvider(providerType, options) {
_instantiateProvider(providerType, options, providerSpecificOptions) {
const ProviderClassName = providerMap.get(providerType);
if (!ProviderClassName) {
throw new Error(`Unsupported authentication provider name: ${providerType}.`);
}

return new ProviderClassName(options);
return new ProviderClassName(options, providerSpecificOptions);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,38 @@ describe('SAMLAuthenticationProvider', () => {
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});

it('gets token and redirects user to the requested URL if SAML Response is valid ignoring Relay State.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });

callWithInternalUser
.withArgs('shield.samlAuthenticate')
.returns(Promise.resolve({ access_token: 'some-token', refresh_token: 'some-refresh-token' }));

provider = new SAMLAuthenticationProvider({
client: { callWithRequest, callWithInternalUser },
log() {},
protocol: 'test-protocol',
hostname: 'test-hostname',
port: 1234,
basePath: '/test-base-path'
}, { useRelayStateDeepLink: true });

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()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/some-path');
expect(authenticationResult.state).to.eql({ 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' } });

Expand Down Expand Up @@ -164,6 +196,112 @@ describe('SAMLAuthenticationProvider', () => {
});
});

describe('IdP initiated login', () => {
beforeEach(() => {
provider = new SAMLAuthenticationProvider({
client: { callWithRequest, callWithInternalUser },
log() {},
protocol: 'test-protocol',
hostname: 'test-hostname',
port: 1234,
basePath: '/test-base-path'
}, { useRelayStateDeepLink: true });

callWithInternalUser
.withArgs('shield.samlAuthenticate')
.returns(Promise.resolve({ access_token: 'some-token', refresh_token: 'some-refresh-token' }));
});

it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => {
provider = new SAMLAuthenticationProvider({
client: { callWithRequest, callWithInternalUser },
log() {},
protocol: 'test-protocol',
hostname: 'test-hostname',
port: 1234,
basePath: '/test-base-path'
}, { useRelayStateDeepLink: false });

const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml', RelayState: '/test-base-path/app/some-app#some-deep-link' } })
);

sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);

expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});

it('redirects to the home page if `RelayState` is not specified.', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } })
);

sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);

expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});

it('redirects to the home page if `RelayState` includes external URL', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml', RelayState: 'https://evil.com/test-base-path/app/some-app#some-deep-link' } })
);

sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);

expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});

it('redirects to the home page if `RelayState` includes URL that starts with double slashes', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml', RelayState: '//test-base-path/app/some-app#some-deep-link' } })
);

sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);

expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});

it('redirects to the URL from the relay state.', async () => {
const authenticationResult = await provider.authenticate(
requestFixture({ payload: { SAMLResponse: 'saml-response-xml', RelayState: '/test-base-path/app/some-app#some-deep-link' } })
);

sinon.assert.calledWithExactly(
callWithInternalUser,
'shield.samlAuthenticate',
{ body: { ids: [], content: 'saml-response-xml' } }
);

expect(authenticationResult.redirected()).to.be(true);
expect(authenticationResult.redirectURL).to.be('/test-base-path/app/some-app#some-deep-link');
expect(authenticationResult.state).to.eql({ accessToken: 'some-token', refreshToken: 'some-refresh-token' });
});
});

it('fails if SAML Response is rejected.', async () => {
const request = requestFixture({ payload: { SAMLResponse: 'saml-response-xml' } });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Boom from 'boom';
import { canRedirectRequest } from '../../can_redirect_request';
import { getErrorStatusCode } from '../../errors';
import { isInternalURL } from '../../is_internal_url';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';

Expand Down Expand Up @@ -60,12 +61,22 @@ export class SAMLAuthenticationProvider {
*/
_options = null;

/**
* Indicates if we should treat non-empty `RelayState` as a deep link in Kibana we should redirect
* user to after successful IdP initiated login. `RelayState` is ignored for SP initiated login.
* @type {boolean}
* @private
*/
_useRelayStateDeepLink;

/**
* Instantiates SAMLAuthenticationProvider.
* @param {ProviderOptions} options Provider options object.
* @param {Object} samlOptions SAML provider specific options.
*/
constructor(options) {
constructor(options, samlOptions) {
this._options = options;
this._useRelayStateDeepLink = !!(samlOptions && samlOptions.useRelayStateDeepLink);
}

/**
Expand Down Expand Up @@ -186,11 +197,11 @@ export class SAMLAuthenticationProvider {
}

// When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login.
if (stateRequestId) {
this._options.log(['debug', 'security', 'saml'], 'Authentication has been previously initiated by Kibana.');
} else {
this._options.log(['debug', 'security', 'saml'], 'Authentication has been initiated by Identity Provider.');
}
const isIdPInitiatedLogin = !stateRequestId;
this._options.log(['debug', 'security', 'saml'], !isIdPInitiatedLogin
? 'Authentication has been previously initiated by Kibana.'
: 'Authentication has been initiated by Identity Provider.'
);

try {
// This operation should be performed on behalf of the user with a privilege that normal
Expand All @@ -205,8 +216,29 @@ export class SAMLAuthenticationProvider {

this._options.log(['debug', 'security', 'saml'], 'Request has been authenticated via SAML response.');

// IdP can pass `RelayState` with the deep link in Kibana during IdP initiated login and
// depending on the configuration we may need to redirect user to this URL.
let redirectURLFromRelayState;
const relayState = request.payload.RelayState;
if (isIdPInitiatedLogin && relayState) {
if (!this._useRelayStateDeepLink) {
this._options.log(['debug', 'security', 'saml'],
`"RelayState" is provided, but deep links support is not enabled.`
);
} else if (!isInternalURL(relayState, this._options.basePath)) {
this._options.log(['debug', 'security', 'saml'],
`"RelayState" is provided, but it is not a valid Kibana internal URL.`
);
} else {
this._options.log(['debug', 'security', 'saml'],
`User will be redirected to the Kibana internal URL specified in "RelayState".`
);
redirectURLFromRelayState = relayState;
}
}

return AuthenticationResult.redirectTo(
stateRedirectURL || `${this._options.basePath}/`,
redirectURLFromRelayState || stateRedirectURL || `${this._options.basePath}/`,
{ accessToken, refreshToken }
);
} catch (err) {
Expand Down
Loading

0 comments on commit 1815c2f

Please sign in to comment.