Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[6.8] Support deep links inside of RelayState for SAML IdP initiated login. #69663

Merged
merged 2 commits into from
Jun 26, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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({})
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: even though we drop it in 7.0 I wanted it to look similar to what we have in 7.2+.

.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
Copy link
Member Author

@azasypkin azasypkin Jun 24, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: borrowed from Ioannis's PR where he introduced OIDC.

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