[kbn-test] improve error handling for saml auth (#182511)

## Summary

If cloud user has no access to the project, we get error message like
`Error: Failed to parse SAML response value`. This error does not
explain the issue and especially because request returns 200 OK (due to
redirects)

This PR disables redirects for API call that creates SAML response, so
if user has no access to the project response code is 303. To better
explain the issue extended message is logged:

```
Error: Failed to parse SAML response value.
 │ Most likely the <user_email> user has no access to the cloud deployment.
 │ Login to <cloud hostname> with the user from '.ftr/role_users.json' file and try to load
 | <project_kibana_url> in the same window.
```

The PR also adds test coverage for the SAML auth in Cloud, to make sure
we don't break stuff with new changes

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dzmitry Lemechko 2024-05-21 16:26:01 +02:00 committed by GitHub
parent afc3c78630
commit ba770bdfe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 333 additions and 41 deletions

View file

@ -20,3 +20,33 @@ export const readCloudUsersFromFile = (filePath: string): Array<[Role, User]> =>
return Object.entries(JSON.parse(data)) as Array<[Role, User]>;
};
export const isValidUrl = (value: string) => {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (err) {
return false;
}
};
export const isValidHostname = (value: string) => {
if (value.length === 0) {
return false;
}
const validChars = /^[a-zA-Z0-9-.]{1,253}\.?$/g;
if (!validChars.test(value)) {
return false;
}
if (value.endsWith('.')) {
value = value.slice(0, value.length - 1);
}
if (value.length > 253) {
return false;
}
return true;
};

View file

@ -0,0 +1,197 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ToolingLog } from '@kbn/tooling-log';
import axios, { AxiosRequestConfig } from 'axios';
jest.mock('axios');
import {
createCloudSession,
createSAMLRequest,
createSAMLResponse,
finishSAMLHandshake,
} from './saml_auth';
const axiosRequestMock = jest.spyOn(axios, 'request');
const axiosGetMock = jest.spyOn(axios, 'get');
const log = new ToolingLog();
const mockRequestOnce = (mockedPath: string, response: any) => {
axiosRequestMock.mockImplementationOnce((config: AxiosRequestConfig) => {
if (config.url?.endsWith(mockedPath)) {
return Promise.resolve(response);
}
return Promise.reject(new Error(`Unexpected URL: ${config.url}`));
});
};
const mockGetOnce = (mockedUrl: string, response: any) => {
axiosGetMock.mockImplementationOnce((url: string) => {
if (url === mockedUrl) {
return Promise.resolve(response);
}
return Promise.reject(new Error(`Unexpected URL`));
});
};
describe('saml_auth', () => {
describe('createCloudSession', () => {
test('returns token value', async () => {
mockRequestOnce('/api/v1/saas/auth/_login', { data: { token: 'mocked_token' } });
const sessionToken = await createCloudSession({
hostname: 'cloud',
email: 'viewer@elastic.co',
password: 'changeme',
log,
});
expect(sessionToken).toBe('mocked_token');
});
test('throws error when response has no token', async () => {
mockRequestOnce('/api/v1/saas/auth/_login', { data: { message: 'no token' } });
await expect(
createCloudSession({
hostname: 'cloud',
email: 'viewer@elastic.co',
password: 'changeme',
log,
})
).rejects.toThrow('Unable to create Cloud session, token is missing.');
});
});
describe('createSAMLRequest', () => {
test('returns { location, sid }', async () => {
mockRequestOnce('/internal/security/login', {
data: {
location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F',
},
headers: {
'set-cookie': [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`],
},
});
const response = await createSAMLRequest('https://kbn.test.co', '8.12.0', log);
expect(response).toStrictEqual({
location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F',
sid: 'Fe26.2**1234567890',
});
});
test(`throws error when response has no 'set-cookie' header`, async () => {
mockRequestOnce('/internal/security/login', {
data: {
location: 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F',
},
headers: {},
});
expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow(
`Failed to parse 'set-cookie' header`
);
});
test('throws error when location is not a valid url', async () => {
mockRequestOnce('/internal/security/login', {
data: {
location: 'http/.test',
},
headers: {
'set-cookie': [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`],
},
});
expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow(
`Location from Kibana SAML request is not a valid url: http/.test`
);
});
test('throws error when response has no location', async () => {
const data = { error: 'mocked error' };
mockRequestOnce('/internal/security/login', {
data,
headers: {
'set-cookie': [`sid=Fe26.2**1234567890; Secure; HttpOnly; Path=/`],
},
});
expect(createSAMLRequest('https://kbn.test.co', '8.12.0', log)).rejects.toThrow(
`Failed to get location from SAML response data: ${JSON.stringify(data)}`
);
});
});
describe('createSAMLResponse', () => {
const location = 'https://cloud.test/saml?SAMLRequest=fVLLbtswEPwVgXe9K6%2F';
const createSAMLResponseParams = {
location,
ecSession: 'mocked_token',
email: 'viewer@elastic.co',
kbnHost: 'https://kbn.test.co',
log,
};
test('returns valid saml response', async () => {
mockGetOnce(location, {
data: `<!DOCTYPE html><html lang="en"><head><title>Test</title></head><body><input type="hidden" name="SAMLResponse" value="PD94bWluc2U+"></body></html>`,
});
const actualResponse = await createSAMLResponse(createSAMLResponseParams);
expect(actualResponse).toBe('PD94bWluc2U+');
});
test('throws error when failed to parse SAML response value', async () => {
mockGetOnce(location, {
data: `<!DOCTYPE html><html lang="en"><head><title>Test</title></head><body></body></html>`,
});
await expect(createSAMLResponse(createSAMLResponseParams)).rejects
.toThrowError(`Failed to parse SAML response value.\nMost likely the 'viewer@elastic.co' user has no access to the cloud deployment.
Login to ${
new URL(location).hostname
} with the user from '.ftr/role_users.json' file and try to load
https://kbn.test.co in the same window.`);
});
});
describe('finishSAMLHandshake', () => {
const cookieStr = 'mocked_cookie';
test('returns valid cookie', async () => {
mockRequestOnce('/api/security/saml/callback', {
headers: {
'set-cookie': [`sid=${cookieStr}; Secure; HttpOnly; Path=/`],
},
});
const response = await finishSAMLHandshake({
kbnHost: 'https://kbn.test.co',
samlResponse: 'PD94bWluc2U+',
sid: 'Fe26.2**1234567890',
log,
});
expect(response.key).toEqual('sid');
expect(response.value).toEqual(cookieStr);
});
test(`throws error when response has no 'set-cookie' header`, async () => {
mockRequestOnce('/api/security/saml/callback', { headers: {} });
await expect(
finishSAMLHandshake({
kbnHost: 'https://kbn.test.co',
samlResponse: 'PD94bWluc2U+',
sid: 'Fe26.2**1234567890',
log,
})
).rejects.toThrow(`Failed to parse 'set-cookie' header`);
});
});
});

View file

@ -12,10 +12,12 @@ import axios, { AxiosResponse } from 'axios';
import * as cheerio from 'cheerio';
import { Cookie, parse as parseCookie } from 'tough-cookie';
import Url from 'url';
import { isValidHostname, isValidUrl } from './helper';
import {
CloudSamlSessionParams,
CreateSamlSessionParams,
LocalSamlSessionParams,
SAMLResponseValueParams,
UserProfile,
} from './types';
@ -50,13 +52,23 @@ const cleanException = (url: string, ex: any) => {
}
};
const getSessionCookie = (cookieString: string) => {
return parseCookie(cookieString);
const getCookieFromResponseHeaders = (response: AxiosResponse, errorMessage: string) => {
const setCookieHeader = response?.headers['set-cookie'];
if (!setCookieHeader) {
throw new Error(`Failed to parse 'set-cookie' header`);
}
const cookie = parseCookie(setCookieHeader![0]);
if (!cookie) {
throw new Error(errorMessage);
}
return cookie;
};
const getCloudHostName = () => {
const hostname = process.env.TEST_CLOUD_HOST_NAME;
if (!hostname) {
if (!hostname || !isValidHostname(hostname)) {
throw new Error('SAML Authentication requires TEST_CLOUD_HOST_NAME env variable to be set');
}
@ -71,7 +83,7 @@ const getCloudUrl = (hostname: string, pathname: string) => {
});
};
const createCloudSession = async (params: CreateSamlSessionParams) => {
export const createCloudSession = async (params: CreateSamlSessionParams) => {
const { hostname, email, password, log } = params;
const cloudLoginUrl = getCloudUrl(hostname, '/api/v1/saas/auth/_login');
let sessionResponse: AxiosResponse | undefined;
@ -112,7 +124,7 @@ const createCloudSession = async (params: CreateSamlSessionParams) => {
return token;
};
const createSAMLRequest = async (kbnUrl: string, kbnVersion: string, log: ToolingLog) => {
export const createSAMLRequest = async (kbnUrl: string, kbnVersion: string, log: ToolingLog) => {
let samlResponse: AxiosResponse;
const url = kbnUrl + '/internal/security/login';
try {
@ -138,10 +150,10 @@ const createSAMLRequest = async (kbnUrl: string, kbnVersion: string, log: Toolin
throw ex;
}
const cookie = getSessionCookie(samlResponse.headers['set-cookie']![0]);
if (!cookie) {
throw new Error(`Failed to parse cookie from SAML response headers`);
}
const cookie = getCookieFromResponseHeaders(
samlResponse,
'Failed to parse cookie from SAML response headers'
);
const location = samlResponse?.data?.location as string;
if (!location) {
@ -149,24 +161,46 @@ const createSAMLRequest = async (kbnUrl: string, kbnVersion: string, log: Toolin
`Failed to get location from SAML response data: ${JSON.stringify(samlResponse.data)}`
);
}
if (!isValidUrl(location)) {
throw new Error(`Location from Kibana SAML request is not a valid url: ${location}`);
}
return { location, sid: cookie.value };
};
const createSAMLResponse = async (url: string, ecSession: string) => {
const samlResponse = await axios.get(url, {
headers: {
Cookie: `ec_session=${ecSession}`,
},
});
const $ = cheerio.load(samlResponse.data);
const value = $('input').attr('value') ?? '';
if (value.length === 0) {
throw new Error('Failed to parse SAML response value');
export const createSAMLResponse = async (params: SAMLResponseValueParams) => {
const { location, ecSession, email, kbnHost, log } = params;
let samlResponse: AxiosResponse;
let value: string | undefined;
try {
samlResponse = await axios.get(location, {
headers: {
Cookie: `ec_session=${ecSession}`,
},
maxRedirects: 0,
});
const $ = cheerio.load(samlResponse.data);
value = $('input').attr('value');
} catch (err) {
if (err.isAxiosError) {
log.error(
`Create SAML Response failed with status code ${err?.response?.status}: ${err?.response?.data}`
);
}
}
if (!value) {
const hostname = new URL(location).hostname;
throw new Error(
`Failed to parse SAML response value.\nMost likely the '${email}' user has no access to the cloud deployment.
Login to ${hostname} with the user from '.ftr/role_users.json' file and try to load
${kbnHost} in the same window.`
);
}
return value;
};
const finishSAMLHandshake = async ({
export const finishSAMLHandshake = async ({
kbnHost,
samlResponse,
sid,
@ -199,12 +233,10 @@ const finishSAMLHandshake = async ({
throw ex;
}
const cookie = getSessionCookie(authResponse!.headers['set-cookie']![0]);
if (!cookie) {
throw new Error(`Failed to get cookie from SAML callback response headers`);
}
return cookie;
return getCookieFromResponseHeaders(
authResponse,
'Failed to get cookie from SAML callback response headers'
);
};
const getSecurityProfile = async ({
@ -238,9 +270,9 @@ const getSecurityProfile = async ({
export const createCloudSAMLSession = async (params: CloudSamlSessionParams) => {
const { email, password, kbnHost, kbnVersion, log } = params;
const hostname = getCloudHostName();
const token = await createCloudSession({ hostname, email, password, log });
const ecSession = await createCloudSession({ hostname, email, password, log });
const { location, sid } = await createSAMLRequest(kbnHost, kbnVersion, log);
const samlResponse = await createSAMLResponse(location, token);
const samlResponse = await createSAMLResponse({ location, ecSession, email, kbnHost, log });
const cookie = await finishSAMLHandshake({ kbnHost, samlResponse, sid, log });
const userProfile = await getSecurityProfile({ kbnHost, cookie, log });
return new Session(cookie, email, userProfile.full_name);

View file

@ -24,6 +24,7 @@ const roleEditor = 'editor';
const createLocalSAMLSessionMock = jest.spyOn(samlAuth, 'createLocalSAMLSession');
const createCloudSAMLSessionMock = jest.spyOn(samlAuth, 'createCloudSAMLSession');
const readCloudUsersFromFileMock = jest.spyOn(helper, 'readCloudUsersFromFile');
const isValidHostnameMock = jest.spyOn(helper, 'isValidHostname');
jest.mock('../kbn_client/kbn_client', () => {
return {
@ -130,19 +131,6 @@ describe('SamlSessionManager', () => {
});
describe('for cloud session', () => {
beforeEach(() => {
jest.resetAllMocks();
jest
.requireMock('../kbn_client/kbn_client')
.KbnClient.mockImplementation(() => ({ version: { get } }));
get.mockImplementationOnce(() => Promise.resolve('8.12.0'));
createCloudSAMLSessionMock.mockResolvedValue(
new Session(cloudCookieInstance, cloudEmail, cloudFullname)
);
readCloudUsersFromFileMock.mockReturnValue(cloudUsers);
});
const hostOptions = {
protocol: 'https' as 'http' | 'https',
hostname: 'cloud',
@ -165,6 +153,43 @@ describe('SamlSessionManager', () => {
cloudUsers.push(['viewer', { email: 'viewer@elastic.co', password: 'p1234' }]);
cloudUsers.push(['editor', { email: 'editor@elastic.co', password: 'p1234' }]);
describe('handles errors', () => {
beforeEach(() => {
jest.resetAllMocks();
jest
.requireMock('../kbn_client/kbn_client')
.KbnClient.mockImplementation(() => ({ version: { get } }));
get.mockImplementationOnce(() => Promise.resolve('8.12.0'));
readCloudUsersFromFileMock.mockReturnValue(cloudUsers);
});
test('should throw error if TEST_CLOUD_HOST_NAME is not set', async () => {
isValidHostnameMock.mockReturnValueOnce(false);
const samlSessionManager = new SamlSessionManager({
hostOptions,
log,
isCloud,
});
await expect(samlSessionManager.getSessionCookieForRole(roleViewer)).rejects.toThrow(
'SAML Authentication requires TEST_CLOUD_HOST_NAME env variable to be set'
);
});
});
beforeEach(() => {
jest.resetAllMocks();
jest
.requireMock('../kbn_client/kbn_client')
.KbnClient.mockImplementation(() => ({ version: { get } }));
get.mockImplementationOnce(() => Promise.resolve('8.12.0'));
createCloudSAMLSessionMock.mockResolvedValue(
new Session(cloudCookieInstance, cloudEmail, cloudFullname)
);
readCloudUsersFromFileMock.mockReturnValue(cloudUsers);
});
test('should create an instance of SamlSessionManager', () => {
const samlSessionManager = new SamlSessionManager({
hostOptions,

View file

@ -32,6 +32,14 @@ export interface CreateSamlSessionParams {
log: ToolingLog;
}
export interface SAMLResponseValueParams {
location: string;
ecSession: string;
email: string;
kbnHost: string;
log: ToolingLog;
}
export interface User {
readonly email: string;
readonly password: string;