Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/kbn-mock-idp-plugin/public/login_page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const LoginPage = () => {
full_name: values.full_name,
email: sanitizeEmail(values.full_name),
roles: [values.role],
url: window.location.href,
});
},
});
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-mock-idp-plugin/public/role_switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const RoleSwitcher = () => {
full_name: currentUserState.value!.full_name,
email: currentUserState.value!.email,
roles: [role],
url: window.location.href,
});
setIsOpen(false);
},
Expand Down
44 changes: 28 additions & 16 deletions packages/kbn-mock-idp-plugin/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
STATEFUL_ROLES_ROOT_PATH,
} from '@kbn/es';
import { createSAMLResponse, MOCK_IDP_LOGIN_PATH, MOCK_IDP_LOGOUT_PATH } from '@kbn/mock-idp-utils';
import { getSAMLRequestId } from '@kbn/mock-idp-utils/src/utils';

export interface PluginSetupDependencies {
cloud: CloudSetup;
Expand All @@ -29,6 +30,7 @@ const createSAMLResponseSchema = schema.object({
full_name: schema.maybe(schema.nullable(schema.string())),
email: schema.maybe(schema.nullable(schema.string())),
roles: schema.arrayOf(schema.string()),
url: schema.string(),
});

const projectToAlias = new Map<string, string>([
Expand All @@ -54,12 +56,11 @@ const readStatefulRoles = () => {

export type CreateSAMLResponseParams = TypeOf<typeof createSAMLResponseSchema>;

export const plugin: PluginInitializer<
void,
void,
PluginSetupDependencies
> = async (): Promise<Plugin> => ({
export const plugin: PluginInitializer<void, void, PluginSetupDependencies> = async (
initializerContext
): Promise<Plugin> => ({
setup(core, plugins: PluginSetupDependencies) {
const logger = initializerContext.logger.get();
const router = core.http.createRouter();

core.http.resources.register(
Expand Down Expand Up @@ -111,17 +112,28 @@ export const plugin: PluginInitializer<
const { protocol, hostname, port } = core.http.getServerInfo();
const pathname = core.http.basePath.prepend('/api/security/saml/callback');

return response.ok({
body: {
SAMLResponse: await createSAMLResponse({
kibanaUrl: `${protocol}://${hostname}:${port}${pathname}`,
username: request.body.username,
full_name: request.body.full_name ?? undefined,
email: request.body.email ?? undefined,
roles: request.body.roles,
}),
},
});
try {
const requestId = await getSAMLRequestId(request.body.url);
if (requestId) {
logger.info(`Sending SAML response for request ID: ${requestId}`);
}

return response.ok({
body: {
SAMLResponse: await createSAMLResponse({
kibanaUrl: `${protocol}://${hostname}:${port}${pathname}`,
username: request.body.username,
full_name: request.body.full_name ?? undefined,
email: request.body.email ?? undefined,
roles: request.body.roles,
...(requestId ? { authnRequestId: requestId } : {}),
}),
},
});
} catch (err) {
logger.error(`Failed to create SAMLResponse: ${err}`, err);
throw err;
}
}
);

Expand Down
14 changes: 14 additions & 0 deletions src/platform/packages/private/kbn-mock-idp-utils/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../../..',
roots: ['<rootDir>/src/platform/packages/private/kbn-mock-idp-utils'],
};
3 changes: 3 additions & 0 deletions src/platform/packages/private/kbn-mock-idp-utils/moon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ tags:
- dev
- group-platform
- private
- jest-unit-tests
fileGroups:
src:
- '**/*.ts'
- '**/*.tsx'
- '!target/**/*'
jest-config:
- jest.config.js
tasks: {}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ export {
MOCK_IDP_ATTRIBUTE_NAME,
} from './constants';

export { createMockIdpMetadata, createSAMLResponse, ensureSAMLRoleMapping } from './utils';
export {
createMockIdpMetadata,
createSAMLResponse,
ensureSAMLRoleMapping,
getSAMLRequestId,
} from './utils';
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { getSAMLRequestId } from './utils';

describe('mock-idp-utils', () => {
describe('getSAMLReuestId', () => {
it('should extract SAMLRequest ID from URL', async () => {
const url =
'http://localhost:5601/mock_idp/login?SAMLRequest=fZJvT8IwEMa%2FSnPvYVsnExqGQYmRxD8Epi98Q0q5SWPXzl6H8u2dggYT4tvePc9z97sOLz4qw7boSTubQ9KNgaFVbq3tSw6PxXWnDxejIcnK8FqMm7Cxc3xrkAJrhZbEvpJD461wkjQJKyskEZRYjO9uBe%2FGovYuOOUMsDER%2BtBGXTlLTYV%2BgX6rFT7Ob3PYhFCLKDJOSbNxFEQvi5NI1joiVI3XYRd9pUVt2aykegU2aefQVobv2U%2FLK6del3pdt%2B8v2gKbTnJYxhivB6XkPDtL0wT755hgT2a9lA9kkpWDslTIy7TfthM1OLUUpA058JhnnTjpJL0iyQTvi5R3z1P%2BDGx2WPFS2z26%2F3is9k0kbopi1pk9LApgTz8naBvgAFx8p%2Ftj0v8byx%2B8MDpJYxgd%2B%2F6e9b41mk5mzmi1Y2Nj3PuVRxkwh1IaQohGB%2BHfHzD6BA%3D%3D';
const requestId = await getSAMLRequestId(url);
expect(requestId).toEqual('_0e0d9fa2264331e87e1e5a65329a16f9ffce2f38');
});

it('should return undefined if SAMLRequest parameter is missing', async () => {
const noParamUrl = 'http://localhost:5601/mock_idp/login';
const requestId = await getSAMLRequestId(noParamUrl);
expect(requestId).toBeUndefined();
});

it('should return undefined if SAMLRequest parameter is invalid', async () => {
const invalidUrl = 'http://localhost:5601/mock_idp/login?SAMLRequest=YmxhaCBibGFoIGJsYWg=';
const requestId = await getSAMLRequestId(invalidUrl);
expect(requestId).toBeUndefined();
});
});
});
28 changes: 28 additions & 0 deletions src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
import type { Client } from '@elastic/elasticsearch';
import { X509Certificate } from 'crypto';
import { readFile } from 'fs/promises';
import Url from 'url';
import { promisify } from 'util';
import { SignedXml } from 'xml-crypto';
import { parseString } from 'xml2js';
import zlib from 'zlib';

import { KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils';

Expand Down Expand Up @@ -205,3 +209,27 @@ export async function ensureSAMLRoleMapping(client: Client) {
},
});
}

const inflateRawAsync = promisify(zlib.inflateRaw);
const parseStringAsync = promisify(parseString);

export async function getSAMLRequestId(requestUrl: string): Promise<string | undefined> {
const samlRequest = Url.parse(requestUrl, true /* parseQueryString */).query.SAMLRequest;

let requestId: string | undefined;

if (samlRequest) {
try {
const inflatedSAMLRequest = (await inflateRawAsync(
Buffer.from(samlRequest as string, 'base64')
)) as Buffer;

const parsedSAMLRequest = (await parseStringAsync(inflatedSAMLRequest.toString())) as any;
requestId = parsedSAMLRequest['saml2p:AuthnRequest'].$.ID as string;
} catch (e) {
return undefined;
}
}

return requestId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe('SAMLAuthenticationProvider', () => {
refresh_token: 'some-refresh-token',
realm: 'test-realm',
authentication: mockUser,
in_response_to: mockSAMLSet1.requestId,
});

await expect(
Expand Down Expand Up @@ -104,6 +105,7 @@ describe('SAMLAuthenticationProvider', () => {
refresh_token: 'some-refresh-token',
realm: 'test-realm',
authentication: mockUser,
in_response_to: mockSAMLSet1.requestId,
});

provider = new SAMLAuthenticationProvider(mockOptions, {
Expand Down Expand Up @@ -203,6 +205,7 @@ describe('SAMLAuthenticationProvider', () => {
refresh_token: 'user-initiated-login-refresh-token',
realm: 'test-realm',
authentication: mockUser,
in_response_to: mockSAMLSet1.requestId,
});

await expect(
Expand Down Expand Up @@ -248,6 +251,7 @@ describe('SAMLAuthenticationProvider', () => {
refresh_token: 'user-initiated-login-refresh-token',
realm: 'test-realm',
authentication: mockUser,
in_response_to: mockSAMLSet1.requestId,
});

provider = new SAMLAuthenticationProvider(mockOptions, {
Expand Down Expand Up @@ -367,6 +371,7 @@ describe('SAMLAuthenticationProvider', () => {
refresh_token: 'some-refresh-token',
realm: 'test-realm',
authentication: mockUser,
in_response_to: mockSamlResponses.set25.requestId,
});

const requestIdMap: Record<string, { redirectURL: string }> = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* 2.0.
*/

import { errors } from '@elastic/elasticsearch';
import Boom from '@hapi/boom';

import type { KibanaRequest } from '@kbn/core/server';
Expand Down Expand Up @@ -361,6 +362,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
refresh_token: string;
realm: string;
authentication: AuthenticationInfo;
in_response_to?: string;
};
try {
// This operation should be performed on behalf of the user with a privilege that normal
Expand All @@ -377,10 +379,24 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
},
})) as any;
} catch (err) {
let inResponseToRequestId;
if (err instanceof errors.ResponseError) {
const body = (err as errors.ResponseError).meta.body as
| { error: Record<string, string> }
| undefined;
inResponseToRequestId =
body?.error?.['security.saml.unsolicited_in_response_to'] ?? undefined;
}

this.logger.error(
`Failed to log in with SAML response, ${
!isIdPInitiatedLogin ? `current requestIds: ${stateRequestIds}, ` : ''
} error: ${getDetailedErrorMessage(err)}`
[
'Failed to log in with SAML response',
inResponseToRequestId
? `SP-initiated, unsolicited InResponseTo: ${inResponseToRequestId}`
: 'IDP-initiated',
state ? `current requestIds: [${stateRequestIds}]` : 'no state',
getDetailedErrorMessage(err),
].join(', ')
);

// Since we don't know upfront what realm is targeted by the Identity Provider initiated login
Expand Down Expand Up @@ -418,7 +434,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
let remainingRequestIdMap = stateRequestIdMap;

if (!isIdPInitiatedLogin) {
const inResponseToRequestId = this.parseRequestIdFromSAMLResponse(samlResponse);
const inResponseToRequestId = result.in_response_to;
this.logger.debug(`Login was performed with requestId: ${inResponseToRequestId}`);

if (stateRequestIds.length && inResponseToRequestId) {
Expand Down Expand Up @@ -454,16 +470,8 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
);
}

private parseRequestIdFromSAMLResponse(samlResponse: string): string | null {
const samlResponseBuffer = Buffer.from(samlResponse, 'base64');
const samlResponseString = samlResponseBuffer.toString('utf-8');
const inResponseToRequestIdMatch = samlResponseString.match(/InResponseTo="([a-z0-9_]*)"/);

return inResponseToRequestIdMatch ? inResponseToRequestIdMatch[1] : null;
}

private updateRemainingRequestIds(
requestIdToRemove: string | null,
requestIdToRemove: string | undefined,
remainingRequestIds: Record<RequestId, { redirectURL: string }>
): [boolean, Record<RequestId, { redirectURL: string }>] {
if (requestIdToRemove) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ project:
dependsOn:
- '@kbn/core'
- '@kbn/dev-utils'
- '@kbn/mock-idp-utils'
tags:
- plugin
- prod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@
import crypto from 'crypto';
import fs from 'fs';
import { stringify } from 'query-string';
import url from 'url';
import zlib from 'zlib';
import { promisify } from 'util';
import { parseString } from 'xml2js';
import { SignedXml } from 'xml-crypto';
import { KBN_KEY_PATH } from '@kbn/dev-utils';
import { CLOUD_USER_ID } from '../constants';
Expand All @@ -23,25 +21,13 @@ import { CLOUD_USER_ID } from '../constants';
* http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf.
*/

const inflateRawAsync = promisify(zlib.inflateRaw);
const deflateRawAsync = promisify(zlib.deflateRaw);
const parseStringAsync = promisify(parseString);

const signingKey = fs.readFileSync(KBN_KEY_PATH);
const signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
const canonicalizationAlgorithm = 'http://www.w3.org/2001/10/xml-exc-c14n#';

export async function getSAMLRequestId(urlWithSAMLRequestId: string) {
const inflatedSAMLRequest = (await inflateRawAsync(
Buffer.from(
url.parse(urlWithSAMLRequestId, true /* parseQueryString */).query.SAMLRequest as string,
'base64'
)
)) as Buffer;

const parsedSAMLRequest = (await parseStringAsync(inflatedSAMLRequest.toString())) as any;
return parsedSAMLRequest['saml2p:AuthnRequest'].$.ID;
}
export { getSAMLRequestId } from '@kbn/mock-idp-utils/src/utils';

export async function getSAMLResponse({
destination,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
"kbn_references": [
"@kbn/core",
"@kbn/dev-utils",
"@kbn/mock-idp-utils",
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ project:
sourceRoot: x-pack/platform/test/security_api_integration/packages/helpers
dependsOn:
- '@kbn/dev-utils'
- '@kbn/mock-idp-utils'
tags:
- shared-common
- package
Expand Down
Loading
Loading