From 8548dbe3db1659ab8a7b3b5809cd565f2f00b631 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 24 Jun 2020 12:12:40 +0200 Subject: [PATCH] [7.x] Support deep links inside of `RelayState` for SAML IdP initiated login. (#69773) # Conflicts: # x-pack/plugins/security/server/authentication/providers/saml.test.ts # x-pack/plugins/security/server/authentication/providers/saml.ts # x-pack/plugins/security/server/routes/authentication/saml.ts * Fix eslint warning. --- x-pack/dev-tools/jest/setup/polyfills.js | 6 +- .../security/common/is_internal_url.test.ts | 88 ++++++ .../security/common/is_internal_url.ts | 38 +++ x-pack/plugins/security/common/parse_next.ts | 20 +- .../authentication/providers/saml.test.ts | 277 ++++++++++++++++++ .../server/authentication/providers/saml.ts | 53 +++- x-pack/plugins/security/server/config.test.ts | 16 + x-pack/plugins/security/server/config.ts | 1 + .../server/routes/authentication/saml.test.ts | 35 ++- .../server/routes/authentication/saml.ts | 9 +- .../apis/login_selector.ts | 90 +++++- .../login_selector_api_integration/config.ts | 7 +- 12 files changed, 606 insertions(+), 34 deletions(-) create mode 100644 x-pack/plugins/security/common/is_internal_url.test.ts create mode 100644 x-pack/plugins/security/common/is_internal_url.ts diff --git a/x-pack/dev-tools/jest/setup/polyfills.js b/x-pack/dev-tools/jest/setup/polyfills.js index 5ecee2e3ad0d35..822802f3dacb79 100644 --- a/x-pack/dev-tools/jest/setup/polyfills.js +++ b/x-pack/dev-tools/jest/setup/polyfills.js @@ -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: () => '' }); +} diff --git a/x-pack/plugins/security/common/is_internal_url.test.ts b/x-pack/plugins/security/common/is_internal_url.test.ts new file mode 100644 index 00000000000000..7e9f63f069fd05 --- /dev/null +++ b/x-pack/plugins/security/common/is_internal_url.test.ts @@ -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); + }); + }); +}); diff --git a/x-pack/plugins/security/common/is_internal_url.ts b/x-pack/plugins/security/common/is_internal_url.ts new file mode 100644 index 00000000000000..e83839bf7d34bc --- /dev/null +++ b/x-pack/plugins/security/common/is_internal_url.ts @@ -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; +} diff --git a/x-pack/plugins/security/common/parse_next.ts b/x-pack/plugins/security/common/parse_next.ts index 7cbe335825a5a7..7ce0de05ad526d 100644 --- a/x-pack/plugins/security/common/parse_next.ts +++ b/x-pack/plugins/security/common/parse_next.ts @@ -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}/`; } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index 12ad15ebb05895..9f3c44b3c89da3 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -96,6 +96,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', + realm: 'test-realm', + }); + + provider = new SAMLAuthenticationProvider(mockOptions, { + 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(); @@ -172,6 +217,47 @@ 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({ + username: 'user', + access_token: 'user-initiated-login-token', + refresh_token: 'user-initiated-login-refresh-token', + realm: 'test-realm', + }); + + provider = new SAMLAuthenticationProvider(mockOptions, { + 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: { + username: 'user', + 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(); @@ -228,6 +314,132 @@ 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', + realm: 'test-realm', + }); + + provider = new SAMLAuthenticationProvider(mockOptions, { + maxRedirectURLSize: new ByteSizeValue(100), + useRelayStateDeepLink: true, + }); + }); + + it('redirects to the home page if `useRelayStateDeepLink` is set to `false`.', async () => { + provider = new SAMLAuthenticationProvider(mockOptions, { + 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', + }, + } + ) + ); + }); + }); + it('uses `realm` name instead of `acs` if it is specified for SAML authenticate request.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -462,6 +674,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', + realm: 'test-realm', + }); + + mockOptions.tokens.invalidate.mockResolvedValue(undefined); + + provider = new SAMLAuthenticationProvider(mockOptions, { + 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' }, + } + ); + + 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 = { diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index 67f9452154aba4..18c2ef933bb428 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -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'; @@ -60,7 +61,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. @@ -100,9 +101,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, - samlOptions?: Readonly<{ realm?: string; maxRedirectURLSize?: ByteSizeValue }> + samlOptions?: Readonly<{ + realm?: string; + maxRedirectURLSize?: ByteSizeValue; + useRelayStateDeepLink?: boolean; + }> ) { super(options); @@ -112,6 +123,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.realm = samlOptions.realm; this.maxRedirectURLSize = samlOptions.maxRedirectURLSize; + this.useRelayStateDeepLink = samlOptions.useRelayStateDeepLink ?? false; } /** @@ -146,14 +158,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, @@ -167,6 +179,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return await this.loginWithNewSAMLResponse( request, samlResponse, + relayState, (authenticationResult.state || state) as ProviderState ); } @@ -288,11 +301,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.'); @@ -340,9 +355,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 } } ); } catch (err) { @@ -367,17 +402,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; } diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 54b1dfd1c8e459..1f5628076a6ac0 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -793,6 +793,7 @@ describe('config schema', () => { saml: { saml1: { order: 0, realm: 'saml1' }, saml2: { order: 1, realm: 'saml2', maxRedirectURLSize: '1kb' }, + saml3: { order: 2, realm: 'saml3', useRelayStateDeepLink: true }, }, }, }, @@ -808,6 +809,7 @@ describe('config schema', () => { "order": 0, "realm": "saml1", "showInSelector": true, + "useRelayStateDeepLink": false, }, "saml2": Object { "enabled": true, @@ -817,6 +819,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, }, }, } @@ -905,6 +918,7 @@ describe('config schema', () => { "order": 3, "realm": "saml3", "showInSelector": true, + "useRelayStateDeepLink": false, }, "saml1": Object { "enabled": true, @@ -914,6 +928,7 @@ describe('config schema', () => { "order": 1, "realm": "saml1", "showInSelector": true, + "useRelayStateDeepLink": false, }, "saml2": Object { "enabled": true, @@ -923,6 +938,7 @@ describe('config schema', () => { "order": 2, "realm": "saml2", "showInSelector": true, + "useRelayStateDeepLink": false, }, }, } diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 11cd73d35d7475..f2b86d20d2cd74 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -97,6 +97,7 @@ const providersConfigSchema = schema.object( ...getCommonProviderSchemaProperties(), realm: schema.string(), maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + useRelayStateDeepLink: schema.boolean({ defaultValue: false }), }) ) ), diff --git a/x-pack/plugins/security/server/routes/authentication/saml.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts index 64263d351941cb..2d1e8ec6039e06 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -71,9 +71,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 () => { @@ -183,5 +183,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' }, + }); + }); }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 688793d0806c4f..b2d2b3c0759ad7 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -92,10 +92,10 @@ export function defineSAMLRoutes({ { path, 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 }, }, @@ -115,6 +115,7 @@ export function defineSAMLRoutes({ value: { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: request.body.SAMLResponse, + relayState: request.body.RelayState, }, }); diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts index 57c91bd49808e8..54b37fe52cc56c 100644 --- a/x-pack/test/login_selector_api_integration/apis/login_selector.ts +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -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 diff --git a/x-pack/test/login_selector_api_integration/config.ts b/x-pack/test/login_selector_api_integration/config.ts index ba7aadb121e825..67bc2e6f17b565 100644 --- a/x-pack/test/login_selector_api_integration/config.ts +++ b/x-pack/test/login_selector_api_integration/config.ts @@ -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, + }, }, })}`, ],