diff --git a/packages/kbn-mock-idp-plugin/public/login_page.tsx b/packages/kbn-mock-idp-plugin/public/login_page.tsx index 17127f9159c78..7deb255b19b69 100644 --- a/packages/kbn-mock-idp-plugin/public/login_page.tsx +++ b/packages/kbn-mock-idp-plugin/public/login_page.tsx @@ -50,6 +50,7 @@ export const LoginPage = ({ config }: { config: ConfigType }) => { full_name: values.full_name, email: sanitizeEmail(values.full_name), roles: [values.role], + url: window.location.href, }); }, }); diff --git a/packages/kbn-mock-idp-plugin/public/role_switcher.tsx b/packages/kbn-mock-idp-plugin/public/role_switcher.tsx index 7a3845b0cc54a..4bcd2f4d4b465 100644 --- a/packages/kbn-mock-idp-plugin/public/role_switcher.tsx +++ b/packages/kbn-mock-idp-plugin/public/role_switcher.tsx @@ -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); }, diff --git a/packages/kbn-mock-idp-plugin/server/plugin.ts b/packages/kbn-mock-idp-plugin/server/plugin.ts index eef02bc525944..a6c438a60f82f 100644 --- a/packages/kbn-mock-idp-plugin/server/plugin.ts +++ b/packages/kbn-mock-idp-plugin/server/plugin.ts @@ -23,6 +23,7 @@ import { } from '@kbn/es'; import type { ServerlessProductTier } from '@kbn/es/src/utils'; import { createSAMLResponse, MOCK_IDP_LOGIN_PATH, MOCK_IDP_LOGOUT_PATH } from '@kbn/mock-idp-utils'; +import { getSAMLRequestId } from '@kbn/mock-idp-utils/src/utils'; import type { ConfigType } from './config'; @@ -35,6 +36,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(), }); // BOOKMARK - List of Kibana project types @@ -154,6 +156,11 @@ export const plugin: PluginInitializer = as : {}; 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({ @@ -162,6 +169,7 @@ export const plugin: PluginInitializer = as full_name: request.body.full_name ?? undefined, email: request.body.email ?? undefined, roles: request.body.roles, + ...(requestId ? { authnRequestId: requestId } : {}), ...serverlessOptions, }), }, diff --git a/src/platform/packages/private/kbn-mock-idp-utils/src/index.ts b/src/platform/packages/private/kbn-mock-idp-utils/src/index.ts index 09716bd5a2116..7d343b51ce5e6 100644 --- a/src/platform/packages/private/kbn-mock-idp-utils/src/index.ts +++ b/src/platform/packages/private/kbn-mock-idp-utils/src/index.ts @@ -42,4 +42,5 @@ export { createSAMLResponse, ensureSAMLRoleMapping, generateCosmosDBApiRequestHeaders, + getSAMLRequestId, } from './utils'; diff --git a/src/platform/packages/private/kbn-mock-idp-utils/src/utils.test.ts b/src/platform/packages/private/kbn-mock-idp-utils/src/utils.test.ts index 8e038159a0f71..52369b98f70e8 100644 --- a/src/platform/packages/private/kbn-mock-idp-utils/src/utils.test.ts +++ b/src/platform/packages/private/kbn-mock-idp-utils/src/utils.test.ts @@ -27,7 +27,12 @@ import { } from './constants'; import { decodeWithChecksum } from './jwt-codecs/encoder-checksum'; import { removePrefixEssuDev } from './jwt-codecs/encoder-prefix'; -import { createMockIdpMetadata, createSAMLResponse, ensureSAMLRoleMapping } from './utils'; +import { + createMockIdpMetadata, + createSAMLResponse, + ensureSAMLRoleMapping, + getSAMLRequestId, +} from './utils'; describe('mock-idp-utils', () => { describe('createMockIdpMetadata', () => { @@ -411,4 +416,25 @@ describe('mock-idp-utils', () => { await expect(ensureSAMLRoleMapping(mockClient)).rejects.toThrow('Elasticsearch error'); }); }); + + 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(); + }); + }); }); diff --git a/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts b/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts index 543d130b3ad6c..a437eaa15d59b 100644 --- a/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts +++ b/src/platform/packages/private/kbn-mock-idp-utils/src/utils.ts @@ -10,7 +10,11 @@ import type { Client } from '@elastic/elasticsearch'; import { createHmac, randomBytes, 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'; @@ -406,3 +410,27 @@ function wrapSignedJwt(signedJwt: string): string { const accessTokenEncodedWithChecksum = encodeWithChecksum(signedJwt); return prefixWithEssuDev(accessTokenEncodedWithChecksum); } + +const inflateRawAsync = promisify(zlib.inflateRaw); +const parseStringAsync = promisify(parseString); + +export async function getSAMLRequestId(requestUrl: string): Promise { + 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; +} diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts index 86f93369dc784..b22007c34c287 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.test.ts @@ -56,6 +56,7 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'some-refresh-token', realm: 'test-realm', authentication: mockUser, + in_response_to: mockSAMLSet1.requestId, }); await expect( @@ -105,6 +106,7 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'some-refresh-token', realm: 'test-realm', authentication: mockUser, + in_response_to: mockSAMLSet1.requestId, }); provider = new SAMLAuthenticationProvider(mockOptions, { @@ -204,6 +206,7 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'user-initiated-login-refresh-token', realm: 'test-realm', authentication: mockUser, + in_response_to: mockSAMLSet1.requestId, }); await expect( @@ -249,6 +252,7 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'user-initiated-login-refresh-token', realm: 'test-realm', authentication: mockUser, + in_response_to: mockSAMLSet1.requestId, }); provider = new SAMLAuthenticationProvider(mockOptions, { @@ -368,6 +372,7 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'some-refresh-token', realm: 'test-realm', authentication: mockUser, + in_response_to: mockSamlResponses.set25.requestId, }); const requestIdMap: Record = {}; @@ -1746,6 +1751,7 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'essu_dev_some-refresh-token', realm: ELASTIC_CLOUD_SSO_REALM_NAME, authentication: mockUser, + in_response_to: mockSAMLSet1.requestId, }); mockOptions.uiam?.getUserProfileGrant.mockReturnValue({ type: 'uiamAccessToken', @@ -1984,6 +1990,7 @@ describe('SAMLAuthenticationProvider', () => { refresh_token: 'x_essu_dev_some-refresh-token', realm: ELASTIC_CLOUD_SSO_REALM_NAME, authentication: mockUser, + in_response_to: mockSAMLSet1.requestId, }); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); diff --git a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts index 05d8c70b42447..3df1fa822b2e9 100644 --- a/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts +++ b/x-pack/platform/plugins/shared/security/server/authentication/providers/saml.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { errors } from '@elastic/elasticsearch'; import Boom from '@hapi/boom'; import type { KibanaRequest } from '@kbn/core/server'; @@ -412,6 +413,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 @@ -428,10 +430,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 } + | 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 @@ -469,7 +485,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) { @@ -507,16 +523,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 ): [boolean, Record] { if (requestIdToRemove) { diff --git a/x-pack/platform/test/cloud_integration/plugins/saml_provider/moon.yml b/x-pack/platform/test/cloud_integration/plugins/saml_provider/moon.yml index d7a4f91c1fc4a..12137d5cd095b 100644 --- a/x-pack/platform/test/cloud_integration/plugins/saml_provider/moon.yml +++ b/x-pack/platform/test/cloud_integration/plugins/saml_provider/moon.yml @@ -20,6 +20,7 @@ project: dependsOn: - '@kbn/core' - '@kbn/dev-utils' + - '@kbn/mock-idp-utils' tags: - plugin - prod diff --git a/x-pack/platform/test/cloud_integration/plugins/saml_provider/server/saml_tools.ts b/x-pack/platform/test/cloud_integration/plugins/saml_provider/server/saml_tools.ts index 7d1a6cbfa4255..d2415c025d01f 100644 --- a/x-pack/platform/test/cloud_integration/plugins/saml_provider/server/saml_tools.ts +++ b/x-pack/platform/test/cloud_integration/plugins/saml_provider/server/saml_tools.ts @@ -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'; @@ -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, diff --git a/x-pack/platform/test/cloud_integration/plugins/saml_provider/tsconfig.json b/x-pack/platform/test/cloud_integration/plugins/saml_provider/tsconfig.json index d619448106405..65a4f44dcfc53 100644 --- a/x-pack/platform/test/cloud_integration/plugins/saml_provider/tsconfig.json +++ b/x-pack/platform/test/cloud_integration/plugins/saml_provider/tsconfig.json @@ -13,5 +13,6 @@ "kbn_references": [ "@kbn/core", "@kbn/dev-utils", + "@kbn/mock-idp-utils", ] } diff --git a/x-pack/platform/test/security_api_integration/packages/helpers/moon.yml b/x-pack/platform/test/security_api_integration/packages/helpers/moon.yml index 96f8f59521599..5a9b828fc4f06 100644 --- a/x-pack/platform/test/security_api_integration/packages/helpers/moon.yml +++ b/x-pack/platform/test/security_api_integration/packages/helpers/moon.yml @@ -19,6 +19,7 @@ project: sourceRoot: x-pack/platform/test/security_api_integration/packages/helpers dependsOn: - '@kbn/dev-utils' + - '@kbn/mock-idp-utils' tags: - shared-common - package diff --git a/x-pack/platform/test/security_api_integration/packages/helpers/saml/saml_tools.ts b/x-pack/platform/test/security_api_integration/packages/helpers/saml/saml_tools.ts index b1dd8f851caf0..6ffb14f872662 100644 --- a/x-pack/platform/test/security_api_integration/packages/helpers/saml/saml_tools.ts +++ b/x-pack/platform/test/security_api_integration/packages/helpers/saml/saml_tools.ts @@ -8,10 +8,8 @@ import crypto from 'crypto'; import fs from 'fs'; import { stringify } from 'query-string'; -import url from 'url'; import { promisify } from 'util'; import { SignedXml } from 'xml-crypto'; -import { parseString } from 'xml2js'; import zlib from 'zlib'; import { KBN_KEY_PATH } from '@kbn/dev-utils'; @@ -23,25 +21,13 @@ import { KBN_KEY_PATH } from '@kbn/dev-utils'; * 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, diff --git a/x-pack/platform/test/security_api_integration/packages/helpers/tsconfig.json b/x-pack/platform/test/security_api_integration/packages/helpers/tsconfig.json index f4c287cbeebef..2de864fbd1cc6 100644 --- a/x-pack/platform/test/security_api_integration/packages/helpers/tsconfig.json +++ b/x-pack/platform/test/security_api_integration/packages/helpers/tsconfig.json @@ -15,5 +15,6 @@ ], "kbn_references": [ "@kbn/dev-utils", + "@kbn/mock-idp-utils", ] } diff --git a/x-pack/platform/test/security_api_integration/tests/saml/saml_login.ts b/x-pack/platform/test/security_api_integration/tests/saml/saml_login.ts index 7278b7a503b5c..8ad5d51d4885f 100644 --- a/x-pack/platform/test/security_api_integration/tests/saml/saml_login.ts +++ b/x-pack/platform/test/security_api_integration/tests/saml/saml_login.ts @@ -177,7 +177,7 @@ export default function ({ getService }: FtrProviderContext) { describe('finishing handshake', () => { let handshakeCookie: Cookie; - let samlRequestId: string; + let samlRequestId: string | undefined; beforeEach(async () => { const handshakeResponse = await supertest