Support deep links inside of RelayState for SAML IdP initiated login. (#69401)

This commit is contained in:
Aleh Zasypkin 2020-06-24 08:05:02 +02:00 committed by GitHub
parent e2ab94060a
commit 33fb3e832c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 609 additions and 35 deletions

View file

@ -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: () => '' });
}

View 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);
});
});
});

View 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;
}

View file

@ -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}/`;
}

View file

@ -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 = {

View file

@ -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;
}

View file

@ -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,
},
},
}

View file

@ -97,6 +97,7 @@ const providersConfigSchema = schema.object(
...getCommonProviderSchemaProperties(),
realm: schema.string(),
maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }),
useRelayStateDeepLink: schema.boolean({ defaultValue: false }),
})
)
),

View file

@ -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' },
});
});
});
});

View file

@ -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()) {

View file

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

View file

@ -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,
},
},
})}`,
],