mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Support deep links inside of RelayState
for SAML IdP initiated login. (#69401)
This commit is contained in:
parent
e2ab94060a
commit
33fb3e832c
12 changed files with 609 additions and 35 deletions
|
@ -17,5 +17,7 @@ const MutationObserver = require('mutation-observer');
|
|||
Object.defineProperty(window, 'MutationObserver', { value: MutationObserver });
|
||||
|
||||
require('whatwg-fetch');
|
||||
const URL = { createObjectURL: () => '' };
|
||||
Object.defineProperty(window, 'URL', { value: URL });
|
||||
|
||||
if (!global.URL.hasOwnProperty('createObjectURL')) {
|
||||
Object.defineProperty(global.URL, 'createObjectURL', { value: () => '' });
|
||||
}
|
||||
|
|
88
x-pack/plugins/security/common/is_internal_url.test.ts
Normal file
88
x-pack/plugins/security/common/is_internal_url.test.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* 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 { isInternalURL } from './is_internal_url';
|
||||
|
||||
describe('isInternalURL', () => {
|
||||
describe('with basePath defined', () => {
|
||||
const basePath = '/iqf';
|
||||
|
||||
it('should return `true `if URL includes hash fragment', () => {
|
||||
const href = `${basePath}/app/kibana#/discover/New-Saved-Search`;
|
||||
expect(isInternalURL(href, basePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return `false` if URL includes a protocol/hostname', () => {
|
||||
const href = `https://example.com${basePath}/app/kibana`;
|
||||
expect(isInternalURL(href, basePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return `false` if URL includes a port', () => {
|
||||
const href = `http://localhost:5601${basePath}/app/kibana`;
|
||||
expect(isInternalURL(href, basePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return `false` if URL does not specify protocol', () => {
|
||||
const hrefWithTwoSlashes = `/${basePath}/app/kibana`;
|
||||
expect(isInternalURL(hrefWithTwoSlashes)).toBe(false);
|
||||
|
||||
const hrefWithThreeSlashes = `//${basePath}/app/kibana`;
|
||||
expect(isInternalURL(hrefWithThreeSlashes)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return `true` if URL starts with a basepath', () => {
|
||||
for (const href of [basePath, `${basePath}/`, `${basePath}/login`, `${basePath}/login/`]) {
|
||||
expect(isInternalURL(href, basePath)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return `false` if URL does not start with basePath', () => {
|
||||
for (const href of [
|
||||
'/notbasepath/app/kibana',
|
||||
`${basePath}_/login`,
|
||||
basePath.slice(1),
|
||||
`${basePath.slice(1)}/app/kibana`,
|
||||
]) {
|
||||
expect(isInternalURL(href, basePath)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return `true` if relative path does not escape base path', () => {
|
||||
const href = `${basePath}/app/kibana/../../management`;
|
||||
expect(isInternalURL(href, basePath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return `false` if relative path escapes base path', () => {
|
||||
const href = `${basePath}/app/kibana/../../../management`;
|
||||
expect(isInternalURL(href, basePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without basePath defined', () => {
|
||||
it('should return `true `if URL includes hash fragment', () => {
|
||||
const href = '/app/kibana#/discover/New-Saved-Search';
|
||||
expect(isInternalURL(href)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return `false` if URL includes a protocol/hostname', () => {
|
||||
const href = 'https://example.com/app/kibana';
|
||||
expect(isInternalURL(href)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return `false` if URL includes a port', () => {
|
||||
const href = 'http://localhost:5601/app/kibana';
|
||||
expect(isInternalURL(href)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return `false` if URL does not specify protocol', () => {
|
||||
const hrefWithTwoSlashes = `//app/kibana`;
|
||||
expect(isInternalURL(hrefWithTwoSlashes)).toBe(false);
|
||||
|
||||
const hrefWithThreeSlashes = `///app/kibana`;
|
||||
expect(isInternalURL(hrefWithThreeSlashes)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
38
x-pack/plugins/security/common/is_internal_url.ts
Normal file
38
x-pack/plugins/security/common/is_internal_url.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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 { parse } from 'url';
|
||||
|
||||
export function isInternalURL(url: string, basePath = '') {
|
||||
const { protocol, hostname, port, pathname } = parse(
|
||||
url,
|
||||
false /* parseQueryString */,
|
||||
true /* slashesDenoteHost */
|
||||
);
|
||||
|
||||
// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
|
||||
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
|
||||
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
|
||||
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
|
||||
// and the first slash that belongs to path.
|
||||
if (protocol !== null || hostname !== null || port !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (basePath) {
|
||||
// Now we need to normalize URL to make sure any relative path segments (`..`) cannot escape expected
|
||||
// base path. We can rely on `URL` with a localhost to automatically "normalize" the URL.
|
||||
const normalizedPathname = new URL(String(pathname), 'https://localhost').pathname;
|
||||
return (
|
||||
// Normalized pathname can add a leading slash, but we should also make sure it's included in
|
||||
// the original URL too
|
||||
pathname?.startsWith('/') &&
|
||||
(normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`))
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { parse } from 'url';
|
||||
import { isInternalURL } from './is_internal_url';
|
||||
|
||||
export function parseNext(href: string, basePath = '') {
|
||||
const { query, hash } = parse(href, true);
|
||||
|
@ -20,23 +21,8 @@ export function parseNext(href: string, basePath = '') {
|
|||
}
|
||||
|
||||
// validate that `next` is not attempting a redirect to somewhere
|
||||
// outside of this Kibana install
|
||||
const { protocol, hostname, port, pathname } = parse(
|
||||
next,
|
||||
false /* parseQueryString */,
|
||||
true /* slashesDenoteHost */
|
||||
);
|
||||
|
||||
// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
|
||||
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
|
||||
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
|
||||
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
|
||||
// and the first slash that belongs to path.
|
||||
if (protocol !== null || hostname !== null || port !== null) {
|
||||
return `${basePath}/`;
|
||||
}
|
||||
|
||||
if (!String(pathname).startsWith(basePath)) {
|
||||
// outside of this Kibana install.
|
||||
if (!isInternalURL(next, basePath)) {
|
||||
return `${basePath}/`;
|
||||
}
|
||||
|
||||
|
|
|
@ -110,6 +110,51 @@ describe('SAMLAuthenticationProvider', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('gets token and redirects user to the requested URL if SAML Response is valid ignoring Relay State.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
mockOptions.client.callAsInternalUser.mockResolvedValue({
|
||||
username: 'user',
|
||||
access_token: 'some-token',
|
||||
refresh_token: 'some-refresh-token',
|
||||
});
|
||||
|
||||
provider = new SAMLAuthenticationProvider(mockOptions, {
|
||||
realm: 'test-realm',
|
||||
maxRedirectURLSize: new ByteSizeValue(100),
|
||||
useRelayStateDeepLink: true,
|
||||
});
|
||||
await expect(
|
||||
provider.login(
|
||||
request,
|
||||
{
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response-xml',
|
||||
relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
|
||||
},
|
||||
{
|
||||
requestId: 'some-request-id',
|
||||
redirectURL: '/test-base-path/some-path#some-app',
|
||||
realm: 'test-realm',
|
||||
}
|
||||
)
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', {
|
||||
state: {
|
||||
username: 'user',
|
||||
accessToken: 'some-token',
|
||||
refreshToken: 'some-refresh-token',
|
||||
realm: 'test-realm',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith(
|
||||
'shield.samlAuthenticate',
|
||||
{ body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('fails if SAML Response payload is presented but state does not contain SAML Request token.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
|
@ -178,6 +223,45 @@ describe('SAMLAuthenticationProvider', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('redirects to the default location if state contains empty redirect URL ignoring Relay State.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
mockOptions.client.callAsInternalUser.mockResolvedValue({
|
||||
access_token: 'user-initiated-login-token',
|
||||
refresh_token: 'user-initiated-login-refresh-token',
|
||||
});
|
||||
|
||||
provider = new SAMLAuthenticationProvider(mockOptions, {
|
||||
realm: 'test-realm',
|
||||
maxRedirectURLSize: new ByteSizeValue(100),
|
||||
useRelayStateDeepLink: true,
|
||||
});
|
||||
await expect(
|
||||
provider.login(
|
||||
request,
|
||||
{
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response-xml',
|
||||
relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
|
||||
},
|
||||
{ requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' }
|
||||
)
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo('/base-path/', {
|
||||
state: {
|
||||
accessToken: 'user-initiated-login-token',
|
||||
refreshToken: 'user-initiated-login-refresh-token',
|
||||
realm: 'test-realm',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith(
|
||||
'shield.samlAuthenticate',
|
||||
{ body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' } }
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects to the default location if state is not presented.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
|
@ -231,6 +315,133 @@ describe('SAMLAuthenticationProvider', () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe('IdP initiated login', () => {
|
||||
beforeEach(() => {
|
||||
mockOptions.basePath.get.mockReturnValue(mockOptions.basePath.serverBasePath);
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockImplementation(() =>
|
||||
Promise.resolve(mockAuthenticatedUser())
|
||||
);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
mockOptions.client.callAsInternalUser.mockResolvedValue({
|
||||
username: 'user',
|
||||
access_token: 'valid-token',
|
||||
refresh_token: 'valid-refresh-token',
|
||||
});
|
||||
|
||||
provider = new SAMLAuthenticationProvider(mockOptions, {
|
||||
realm: 'test-realm',
|
||||
maxRedirectURLSize: new ByteSizeValue(100),
|
||||
useRelayStateDeepLink: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => {
|
||||
provider = new SAMLAuthenticationProvider(mockOptions, {
|
||||
realm: 'test-realm',
|
||||
maxRedirectURLSize: new ByteSizeValue(100),
|
||||
useRelayStateDeepLink: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response-xml',
|
||||
relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
|
||||
})
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, {
|
||||
state: {
|
||||
username: 'user',
|
||||
accessToken: 'valid-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
realm: 'test-realm',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects to the home page if `relayState` is not specified.', async () => {
|
||||
await expect(
|
||||
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response-xml',
|
||||
})
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, {
|
||||
state: {
|
||||
username: 'user',
|
||||
accessToken: 'valid-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
realm: 'test-realm',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects to the home page if `relayState` includes external URL', async () => {
|
||||
await expect(
|
||||
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response-xml',
|
||||
relayState: `https://evil.com${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
|
||||
})
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, {
|
||||
state: {
|
||||
username: 'user',
|
||||
accessToken: 'valid-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
realm: 'test-realm',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects to the home page if `relayState` includes URL that starts with double slashes', async () => {
|
||||
await expect(
|
||||
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response-xml',
|
||||
relayState: `//${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
|
||||
})
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo(`${mockOptions.basePath.serverBasePath}/`, {
|
||||
state: {
|
||||
username: 'user',
|
||||
accessToken: 'valid-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
realm: 'test-realm',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects to the URL from the relay state.', async () => {
|
||||
await expect(
|
||||
provider.login(httpServerMock.createKibanaRequest({ headers: {} }), {
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response-xml',
|
||||
relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
|
||||
})
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo(
|
||||
`${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
|
||||
{
|
||||
state: {
|
||||
username: 'user',
|
||||
accessToken: 'valid-token',
|
||||
refreshToken: 'valid-refresh-token',
|
||||
realm: 'test-realm',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IdP initiated login with existing session', () => {
|
||||
it('returns `notHandled` if new SAML Response is rejected.', async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: {} });
|
||||
|
@ -377,6 +588,71 @@ describe('SAMLAuthenticationProvider', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it(`redirects to the URL from relay state if new SAML Response is for the same user if ${description}.`, async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: {} });
|
||||
const state = {
|
||||
username: 'user',
|
||||
accessToken: 'existing-token',
|
||||
refreshToken: 'existing-refresh-token',
|
||||
realm: 'test-realm',
|
||||
};
|
||||
const authorization = `Bearer ${state.accessToken}`;
|
||||
|
||||
const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient();
|
||||
mockScopedClusterClient.callAsCurrentUser.mockImplementation(() => response);
|
||||
mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient);
|
||||
|
||||
mockOptions.client.callAsInternalUser.mockResolvedValue({
|
||||
username: 'user',
|
||||
access_token: 'new-valid-token',
|
||||
refresh_token: 'new-valid-refresh-token',
|
||||
});
|
||||
|
||||
mockOptions.tokens.invalidate.mockResolvedValue(undefined);
|
||||
|
||||
provider = new SAMLAuthenticationProvider(mockOptions, {
|
||||
realm: 'test-realm',
|
||||
maxRedirectURLSize: new ByteSizeValue(100),
|
||||
useRelayStateDeepLink: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.login(
|
||||
request,
|
||||
{
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response-xml',
|
||||
relayState: '/mock-server-basepath/app/some-app#some-deep-link',
|
||||
},
|
||||
state
|
||||
)
|
||||
).resolves.toEqual(
|
||||
AuthenticationResult.redirectTo('/mock-server-basepath/app/some-app#some-deep-link', {
|
||||
state: {
|
||||
username: 'user',
|
||||
accessToken: 'new-valid-token',
|
||||
refreshToken: 'new-valid-refresh-token',
|
||||
realm: 'test-realm',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expectAuthenticateCall(mockOptions.client, { headers: { authorization } });
|
||||
|
||||
expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith(
|
||||
'shield.samlAuthenticate',
|
||||
{
|
||||
body: { ids: [], content: 'saml-response-xml', realm: 'test-realm' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
it(`redirects to \`overwritten_session\` if new SAML Response is for the another user if ${description}.`, async () => {
|
||||
const request = httpServerMock.createKibanaRequest({ headers: {} });
|
||||
const state = {
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import Boom from 'boom';
|
||||
import { ByteSizeValue } from '@kbn/config-schema';
|
||||
import { KibanaRequest } from '../../../../../../src/core/server';
|
||||
import { isInternalURL } from '../../../common/is_internal_url';
|
||||
import { AuthenticationResult } from '../authentication_result';
|
||||
import { DeauthenticationResult } from '../deauthentication_result';
|
||||
import { canRedirectRequest } from '../can_redirect_request';
|
||||
|
@ -59,7 +60,7 @@ export enum SAMLLogin {
|
|||
*/
|
||||
type ProviderLoginAttempt =
|
||||
| { type: SAMLLogin.LoginInitiatedByUser; redirectURLPath?: string; redirectURLFragment?: string }
|
||||
| { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string };
|
||||
| { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string; relayState?: string };
|
||||
|
||||
/**
|
||||
* Checks whether request query includes SAML request from IdP.
|
||||
|
@ -98,9 +99,19 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
|
|||
*/
|
||||
private readonly maxRedirectURLSize: ByteSizeValue;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private readonly useRelayStateDeepLink: boolean;
|
||||
|
||||
constructor(
|
||||
protected readonly options: Readonly<AuthenticationProviderOptions>,
|
||||
samlOptions?: Readonly<{ realm?: string; maxRedirectURLSize?: ByteSizeValue }>
|
||||
samlOptions?: Readonly<{
|
||||
realm?: string;
|
||||
maxRedirectURLSize?: ByteSizeValue;
|
||||
useRelayStateDeepLink?: boolean;
|
||||
}>
|
||||
) {
|
||||
super(options);
|
||||
|
||||
|
@ -114,6 +125,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
|
|||
|
||||
this.realm = samlOptions.realm;
|
||||
this.maxRedirectURLSize = samlOptions.maxRedirectURLSize;
|
||||
this.useRelayStateDeepLink = samlOptions.useRelayStateDeepLink ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -148,14 +160,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
|
|||
return this.captureRedirectURL(request, redirectURLPath, attempt.redirectURLFragment);
|
||||
}
|
||||
|
||||
const { samlResponse } = attempt;
|
||||
const { samlResponse, relayState } = attempt;
|
||||
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);
|
||||
return await this.loginWithSAMLResponse(request, samlResponse, relayState, state);
|
||||
}
|
||||
|
||||
// If user has been authenticated via session or failed to do so because of expired access token,
|
||||
|
@ -169,6 +181,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
|
|||
return await this.loginWithNewSAMLResponse(
|
||||
request,
|
||||
samlResponse,
|
||||
relayState,
|
||||
(authenticationResult.state || state) as ProviderState
|
||||
);
|
||||
}
|
||||
|
@ -290,11 +303,13 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
|
|||
* initiated login.
|
||||
* @param request Request instance.
|
||||
* @param samlResponse SAMLResponse payload string.
|
||||
* @param relayState RelayState payload string.
|
||||
* @param [state] Optional state object associated with the provider.
|
||||
*/
|
||||
private async loginWithSAMLResponse(
|
||||
request: KibanaRequest,
|
||||
samlResponse: string,
|
||||
relayState?: string,
|
||||
state?: ProviderState | null
|
||||
) {
|
||||
this.logger.debug('Trying to log in with SAML response payload.');
|
||||
|
@ -334,9 +349,29 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
|
|||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
if (isIdPInitiatedLogin && relayState) {
|
||||
if (!this.useRelayStateDeepLink) {
|
||||
this.options.logger.debug(
|
||||
`"RelayState" is provided, but deep links support is not enabled for "${this.type}/${this.options.name}" provider.`
|
||||
);
|
||||
} else if (!isInternalURL(relayState, this.options.basePath.serverBasePath)) {
|
||||
this.options.logger.debug(
|
||||
`"RelayState" is provided, but it is not a valid Kibana internal URL.`
|
||||
);
|
||||
} else {
|
||||
this.options.logger.debug(
|
||||
`User will be redirected to the Kibana internal URL specified in "RelayState".`
|
||||
);
|
||||
redirectURLFromRelayState = relayState;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Login has been performed with SAML response.');
|
||||
return AuthenticationResult.redirectTo(
|
||||
stateRedirectURL || `${this.options.basePath.get(request)}/`,
|
||||
redirectURLFromRelayState || stateRedirectURL || `${this.options.basePath.get(request)}/`,
|
||||
{ state: { username, accessToken, refreshToken, realm: this.realm } }
|
||||
);
|
||||
} catch (err) {
|
||||
|
@ -361,17 +396,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
|
|||
* we'll forward user to a page with the respective warning.
|
||||
* @param request Request instance.
|
||||
* @param samlResponse SAMLResponse payload string.
|
||||
* @param relayState RelayState payload string.
|
||||
* @param existingState State existing user session is based on.
|
||||
*/
|
||||
private async loginWithNewSAMLResponse(
|
||||
request: KibanaRequest,
|
||||
samlResponse: string,
|
||||
relayState: string | undefined,
|
||||
existingState: ProviderState
|
||||
) {
|
||||
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.loginWithSAMLResponse(request, samlResponse);
|
||||
const payloadAuthenticationResult = await this.loginWithSAMLResponse(
|
||||
request,
|
||||
samlResponse,
|
||||
relayState
|
||||
);
|
||||
if (payloadAuthenticationResult.failed() || payloadAuthenticationResult.notHandled()) {
|
||||
return payloadAuthenticationResult;
|
||||
}
|
||||
|
|
|
@ -655,6 +655,7 @@ describe('config schema', () => {
|
|||
saml: {
|
||||
saml1: { order: 0, realm: 'saml1' },
|
||||
saml2: { order: 1, realm: 'saml2', maxRedirectURLSize: '1kb' },
|
||||
saml3: { order: 2, realm: 'saml3', useRelayStateDeepLink: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -670,6 +671,7 @@ describe('config schema', () => {
|
|||
"order": 0,
|
||||
"realm": "saml1",
|
||||
"showInSelector": true,
|
||||
"useRelayStateDeepLink": false,
|
||||
},
|
||||
"saml2": Object {
|
||||
"enabled": true,
|
||||
|
@ -679,6 +681,17 @@ describe('config schema', () => {
|
|||
"order": 1,
|
||||
"realm": "saml2",
|
||||
"showInSelector": true,
|
||||
"useRelayStateDeepLink": false,
|
||||
},
|
||||
"saml3": Object {
|
||||
"enabled": true,
|
||||
"maxRedirectURLSize": ByteSizeValue {
|
||||
"valueInBytes": 2048,
|
||||
},
|
||||
"order": 2,
|
||||
"realm": "saml3",
|
||||
"showInSelector": true,
|
||||
"useRelayStateDeepLink": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -767,6 +780,7 @@ describe('config schema', () => {
|
|||
"order": 3,
|
||||
"realm": "saml3",
|
||||
"showInSelector": true,
|
||||
"useRelayStateDeepLink": false,
|
||||
},
|
||||
"saml1": Object {
|
||||
"enabled": true,
|
||||
|
@ -776,6 +790,7 @@ describe('config schema', () => {
|
|||
"order": 1,
|
||||
"realm": "saml1",
|
||||
"showInSelector": true,
|
||||
"useRelayStateDeepLink": false,
|
||||
},
|
||||
"saml2": Object {
|
||||
"enabled": true,
|
||||
|
@ -785,6 +800,7 @@ describe('config schema', () => {
|
|||
"order": 2,
|
||||
"realm": "saml2",
|
||||
"showInSelector": true,
|
||||
"useRelayStateDeepLink": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -97,6 +97,7 @@ const providersConfigSchema = schema.object(
|
|||
...getCommonProviderSchemaProperties(),
|
||||
realm: schema.string(),
|
||||
maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }),
|
||||
useRelayStateDeepLink: schema.boolean({ defaultValue: false }),
|
||||
})
|
||||
)
|
||||
),
|
||||
|
|
|
@ -62,9 +62,9 @@ describe('SAML authentication routes', () => {
|
|||
`"[SAMLResponse]: expected value of type [string] but got [undefined]"`
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
bodyValidator.validate({ SAMLResponse: 'saml-response', UnknownArg: 'arg' })
|
||||
).toThrowErrorMatchingInlineSnapshot(`"[UnknownArg]: definition for this key is missing"`);
|
||||
expect(bodyValidator.validate({ SAMLResponse: 'saml-response', UnknownArg: 'arg' })).toEqual({
|
||||
SAMLResponse: 'saml-response',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 500 if authentication throws unhandled exception.', async () => {
|
||||
|
@ -174,5 +174,34 @@ describe('SAML authentication routes', () => {
|
|||
headers: { location: 'http://redirect-to/path' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes `RelayState` within login attempt.', async () => {
|
||||
authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path'));
|
||||
|
||||
const redirectResponse = Symbol('error');
|
||||
const responseFactory = httpServerMock.createResponseFactory();
|
||||
responseFactory.redirected.mockReturnValue(redirectResponse as any);
|
||||
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
body: { SAMLResponse: 'saml-response', RelayState: '/app/kibana' },
|
||||
});
|
||||
|
||||
await expect(routeHandler({} as any, request, responseFactory)).resolves.toBe(
|
||||
redirectResponse
|
||||
);
|
||||
|
||||
expect(authc.login).toHaveBeenCalledWith(request, {
|
||||
provider: { type: 'saml' },
|
||||
value: {
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: 'saml-response',
|
||||
relayState: '/app/kibana',
|
||||
},
|
||||
});
|
||||
|
||||
expect(responseFactory.redirected).toHaveBeenCalledWith({
|
||||
headers: { location: 'http://redirect-to/path' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -89,10 +89,10 @@ export function defineSAMLRoutes({
|
|||
{
|
||||
path: '/api/security/saml/callback',
|
||||
validate: {
|
||||
body: schema.object({
|
||||
SAMLResponse: schema.string(),
|
||||
RelayState: schema.maybe(schema.string()),
|
||||
}),
|
||||
body: schema.object(
|
||||
{ SAMLResponse: schema.string(), RelayState: schema.maybe(schema.string()) },
|
||||
{ unknowns: 'ignore' }
|
||||
),
|
||||
},
|
||||
options: { authRequired: false, xsrfRequired: false },
|
||||
},
|
||||
|
@ -101,7 +101,11 @@ export function defineSAMLRoutes({
|
|||
// When authenticating using SAML we _expect_ to redirect to the Kibana target location.
|
||||
const authenticationResult = await authc.login(request, {
|
||||
provider: { type: SAMLAuthenticationProvider.type },
|
||||
value: { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: request.body.SAMLResponse },
|
||||
value: {
|
||||
type: SAMLLogin.LoginWithSAMLResponse,
|
||||
samlResponse: request.body.SAMLResponse,
|
||||
relayState: request.body.RelayState,
|
||||
},
|
||||
});
|
||||
|
||||
if (authenticationResult.redirected()) {
|
||||
|
|
|
@ -102,7 +102,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
});
|
||||
}
|
||||
|
||||
it('should be able to log in via IdP initiated login for any configured realm', async () => {
|
||||
it('should be able to log in via IdP initiated login for any configured provider', async () => {
|
||||
for (const providerName of ['saml1', 'saml2']) {
|
||||
const authenticationResponse = await supertest
|
||||
.post('/api/security/saml/callback')
|
||||
|
@ -124,6 +124,57 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
}
|
||||
});
|
||||
|
||||
it('should redirect to URL from relay state in case of IdP initiated login only for providers that explicitly enabled that behaviour', async () => {
|
||||
for (const { providerName, redirectURL } of [
|
||||
{ providerName: 'saml1', redirectURL: '/' },
|
||||
{ providerName: 'saml2', redirectURL: '/app/kibana#/dashboards' },
|
||||
]) {
|
||||
const authenticationResponse = await supertest
|
||||
.post('/api/security/saml/callback')
|
||||
.ca(CA_CERT)
|
||||
.type('form')
|
||||
.send({
|
||||
SAMLResponse: await createSAMLResponse({
|
||||
issuer: `http://www.elastic.co/${providerName}`,
|
||||
}),
|
||||
})
|
||||
.send({ RelayState: '/app/kibana#/dashboards' })
|
||||
.expect(302);
|
||||
|
||||
// User should be redirected to the base URL.
|
||||
expect(authenticationResponse.headers.location).to.be(redirectURL);
|
||||
|
||||
const cookies = authenticationResponse.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
|
||||
await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not redirect to URL from relay state in case of IdP initiated login if URL is not internal', async () => {
|
||||
for (const providerName of ['saml1', 'saml2']) {
|
||||
const authenticationResponse = await supertest
|
||||
.post('/api/security/saml/callback')
|
||||
.ca(CA_CERT)
|
||||
.type('form')
|
||||
.send({
|
||||
SAMLResponse: await createSAMLResponse({
|
||||
issuer: `http://www.elastic.co/${providerName}`,
|
||||
}),
|
||||
})
|
||||
.send({ RelayState: 'http://www.elastic.co/app/kibana#/dashboards' })
|
||||
.expect(302);
|
||||
|
||||
// User should be redirected to the base URL.
|
||||
expect(authenticationResponse.headers.location).to.be('/');
|
||||
|
||||
const cookies = authenticationResponse.headers['set-cookie'];
|
||||
expect(cookies).to.have.length(1);
|
||||
|
||||
await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be able to log in via IdP initiated login even if session with other provider type exists', async () => {
|
||||
const basicAuthenticationResponse = await supertest
|
||||
.post('/internal/security/login')
|
||||
|
@ -193,6 +244,43 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2');
|
||||
});
|
||||
|
||||
it('should redirect to URL from relay state in case of IdP initiated login even if session with other SAML provider exists', async () => {
|
||||
// First login with `saml1`.
|
||||
const saml1AuthenticationResponse = await supertest
|
||||
.post('/api/security/saml/callback')
|
||||
.ca(CA_CERT)
|
||||
.send({
|
||||
SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml1` }),
|
||||
})
|
||||
.expect(302);
|
||||
|
||||
const saml1SessionCookie = request.cookie(
|
||||
saml1AuthenticationResponse.headers['set-cookie'][0]
|
||||
)!;
|
||||
await checkSessionCookie(saml1SessionCookie, 'a@b.c', 'saml1');
|
||||
|
||||
// And now try to login with `saml2`.
|
||||
const saml2AuthenticationResponse = await supertest
|
||||
.post('/api/security/saml/callback')
|
||||
.ca(CA_CERT)
|
||||
.set('Cookie', saml1SessionCookie.cookieString())
|
||||
.type('form')
|
||||
.send({
|
||||
SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }),
|
||||
})
|
||||
.send({ RelayState: '/app/kibana#/dashboards' })
|
||||
.expect(302);
|
||||
|
||||
// It should be `/overwritten_session` with `?next='/app/kibana#/dashboards'` instead of just
|
||||
// `'/app/kibana#/dashboards'` once it's generalized.
|
||||
expect(saml2AuthenticationResponse.headers.location).to.be('/app/kibana#/dashboards');
|
||||
|
||||
const saml2SessionCookie = request.cookie(
|
||||
saml2AuthenticationResponse.headers['set-cookie'][0]
|
||||
)!;
|
||||
await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2');
|
||||
});
|
||||
|
||||
// Ideally we should be able to abandon intermediate session and let user log in, but for the
|
||||
// time being we cannot distinguish errors coming from Elasticsearch for the case when SAML
|
||||
// response just doesn't correspond to request ID we have in intermediate cookie and the case
|
||||
|
|
|
@ -127,7 +127,12 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
|
|||
oidc: { oidc1: { order: 3, realm: 'oidc1' } },
|
||||
saml: {
|
||||
saml1: { order: 1, realm: 'saml1' },
|
||||
saml2: { order: 5, realm: 'saml2', maxRedirectURLSize: '100b' },
|
||||
saml2: {
|
||||
order: 5,
|
||||
realm: 'saml2',
|
||||
maxRedirectURLSize: '100b',
|
||||
useRelayStateDeepLink: true,
|
||||
},
|
||||
},
|
||||
})}`,
|
||||
],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue