[8.18] Feature/saml multi tab (#212148) (#218375)

# Backport

This will backport the following commits from `main` to `8.18`:
- [Feature/saml multi tab
(#212148)](https://github.com/elastic/kibana/pull/212148)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT
[{"author":{"name":"Kurt","email":"kc13greiner@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-04-16T03:47:06Z","message":"Feature/saml
multi tab (#212148)\n\n## Summary\n\nCloses
https://github.com/elastic/kibana/issues/199188\n\nAllow multiple SAML
authc calls to succeed.\n\n## Testing \n\nConfigure
logging:\n```yaml\nlogging.loggers:\n - name: plugins.security\n level:
debug\n```\n\n### See the failure\n\nPull `main` and copy the code from
the following files in this PR into\ntheir respective files on that
branch:\n\n- `packages/kbn-mock-idp-plugin/public/login_page.tsx`\n-
`packages/kbn-mock-idp-plugin/server/plugin.ts`\n-
`packages/kbn-mock-idp-utils/src/index.ts`\n-
`packages/kbn-mock-idp-utils/src/utils.ts`\n\nStart KB/ES in serverless
from this modified main branch\n\nOpen 2 tabs to the local serverless
login screen\n\nAs the same user, click login and change tabs and click
login again\n\nThe you will get an error.\n\nShut down KB/ES\n\n### See
the success\n\nStart KB/ES in serverless from this PR\n\nOpen 2 tabs to
the local serverless login screen\n\nAs the same user, click login and
change tabs and click login again\n\nBoth should succeed\n\n## Release
note\nRefreshing multiple tabs where the user has logged out
will\nsimultaneously login successfully\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"d7fd324356c72ca95d05e97eeaf796145806ca1b","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Team:Security","backport:all-open","ci:cloud-deploy","v9.1.0","v9.0.1"],"title":"Feature/saml
multi
tab","number":212148,"url":"https://github.com/elastic/kibana/pull/212148","mergeCommit":{"message":"Feature/saml
multi tab (#212148)\n\n## Summary\n\nCloses
https://github.com/elastic/kibana/issues/199188\n\nAllow multiple SAML
authc calls to succeed.\n\n## Testing \n\nConfigure
logging:\n```yaml\nlogging.loggers:\n - name: plugins.security\n level:
debug\n```\n\n### See the failure\n\nPull `main` and copy the code from
the following files in this PR into\ntheir respective files on that
branch:\n\n- `packages/kbn-mock-idp-plugin/public/login_page.tsx`\n-
`packages/kbn-mock-idp-plugin/server/plugin.ts`\n-
`packages/kbn-mock-idp-utils/src/index.ts`\n-
`packages/kbn-mock-idp-utils/src/utils.ts`\n\nStart KB/ES in serverless
from this modified main branch\n\nOpen 2 tabs to the local serverless
login screen\n\nAs the same user, click login and change tabs and click
login again\n\nThe you will get an error.\n\nShut down KB/ES\n\n### See
the success\n\nStart KB/ES in serverless from this PR\n\nOpen 2 tabs to
the local serverless login screen\n\nAs the same user, click login and
change tabs and click login again\n\nBoth should succeed\n\n## Release
note\nRefreshing multiple tabs where the user has logged out
will\nsimultaneously login successfully\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"d7fd324356c72ca95d05e97eeaf796145806ca1b"}},"sourceBranch":"main","suggestedTargetBranches":["9.0"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/212148","number":212148,"mergeCommit":{"message":"Feature/saml
multi tab (#212148)\n\n## Summary\n\nCloses
https://github.com/elastic/kibana/issues/199188\n\nAllow multiple SAML
authc calls to succeed.\n\n## Testing \n\nConfigure
logging:\n```yaml\nlogging.loggers:\n - name: plugins.security\n level:
debug\n```\n\n### See the failure\n\nPull `main` and copy the code from
the following files in this PR into\ntheir respective files on that
branch:\n\n- `packages/kbn-mock-idp-plugin/public/login_page.tsx`\n-
`packages/kbn-mock-idp-plugin/server/plugin.ts`\n-
`packages/kbn-mock-idp-utils/src/index.ts`\n-
`packages/kbn-mock-idp-utils/src/utils.ts`\n\nStart KB/ES in serverless
from this modified main branch\n\nOpen 2 tabs to the local serverless
login screen\n\nAs the same user, click login and change tabs and click
login again\n\nThe you will get an error.\n\nShut down KB/ES\n\n### See
the success\n\nStart KB/ES in serverless from this PR\n\nOpen 2 tabs to
the local serverless login screen\n\nAs the same user, click login and
change tabs and click login again\n\nBoth should succeed\n\n## Release
note\nRefreshing multiple tabs where the user has logged out
will\nsimultaneously login successfully\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"d7fd324356c72ca95d05e97eeaf796145806ca1b"}},{"branch":"9.0","label":"v9.0.1","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

---------

Co-authored-by: Kurt <kc13greiner@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-04-23 22:56:54 +02:00 committed by GitHub
parent dbbd9339cd
commit 7983a6ffdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 706 additions and 70 deletions

View file

@ -0,0 +1,259 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const mockSamlResponses = {
set1: {
requestId: '_mock_request_id_1',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMSI=',
redirectURL: '/path1',
},
set2: {
requestId: '_mock_request_id_2',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMiI=',
redirectURL: '/path2',
},
set3: {
requestId: '_mock_request_id_3',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMyI=',
redirectURL: '/path3',
},
set4: {
requestId: '_mock_request_id_4',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNCI=',
redirectURL: '/path4',
},
set5: {
requestId: '_mock_request_id_5',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNSI=',
redirectURL: '/path5',
},
set6: {
requestId: '_mock_request_id_6',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNiI=',
redirectURL: '/path6',
},
set7: {
requestId: '_mock_request_id_7',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNyI=',
redirectURL: '/path7',
},
set8: {
requestId: '_mock_request_id_8',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfOCI=',
redirectURL: '/path8',
},
set9: {
requestId: '_mock_request_id_9',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfOSI=',
redirectURL: '/path9',
},
set10: {
requestId: '_mock_request_id_10',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTAi=',
redirectURL: '/path10',
},
set11: {
requestId: '_mock_request_id_11',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTEi=',
redirectURL: '/path11',
},
set12: {
requestId: '_mock_request_id_12',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTIi=',
redirectURL: '/path12',
},
set13: {
requestId: '_mock_request_id_13',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTMi=',
redirectURL: '/path13',
},
set14: {
requestId: '_mock_request_id_14',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTQi=',
redirectURL: '/path14',
},
set15: {
requestId: '_mock_request_id_15',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTUi=',
redirectURL: '/path15',
},
set16: {
requestId: '_mock_request_id_16',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTYi=',
redirectURL: '/path16',
},
set17: {
requestId: '_mock_request_id_17',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTci=',
redirectURL: '/path17',
},
set18: {
requestId: '_mock_request_id_18',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTgi+',
redirectURL: '/path18',
},
set19: {
requestId: '_mock_request_id_19',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMTki=',
redirectURL: '/path19',
},
set20: {
requestId: '_mock_request_id_20',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjAi=',
redirectURL: '/path20',
},
set21: {
requestId: '_mock_request_id_21',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjEi=',
redirectURL: '/path21',
},
set22: {
requestId: '_mock_request_id_22',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjIi=',
redirectURL: '/path22',
},
set23: {
requestId: '_mock_request_id_23',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjMi=',
redirectURL: '/path23',
},
set24: {
requestId: '_mock_request_id_24',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjQi=',
redirectURL: '/path24',
},
set25: {
requestId: '_mock_request_id_25',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjUi=',
redirectURL: '/path25',
},
set26: {
requestId: '_mock_request_id_26',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjYi=',
redirectURL: '/path26',
},
set27: {
requestId: '_mock_request_id_27',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjci=',
redirectURL: '/path27',
},
set28: {
requestId: '_mock_request_id_28',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjgi=',
redirectURL: '/path28',
},
set29: {
requestId: '_mock_request_id_29',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMjki=',
redirectURL: '/path29',
},
set30: {
requestId: '_mock_request_id_30',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzAi=',
redirectURL: '/path30',
},
set31: {
requestId: '_mock_request_id_31',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzEi=',
redirectURL: '/path31',
},
set32: {
requestId: '_mock_request_id_32',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzIi=',
redirectURL: '/path32',
},
set33: {
requestId: '_mock_request_id_33',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzMi=',
redirectURL: '/path33',
},
set34: {
requestId: '_mock_request_id_34',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzQi=',
redirectURL: '/path34',
},
set35: {
requestId: '_mock_request_id_35',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzUi=',
redirectURL: '/path35',
},
set36: {
requestId: '_mock_request_id_36',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzYi=',
redirectURL: '/path36',
},
set37: {
requestId: '_mock_request_id_37',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzci=',
redirectURL: '/path37',
},
set38: {
requestId: '_mock_request_id_38',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzgi=',
redirectURL: '/path38',
},
set39: {
requestId: '_mock_request_id_39',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfMzki=',
redirectURL: '/path39',
},
set40: {
requestId: '_mock_request_id_40',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDAi=',
redirectURL: '/path40',
},
set41: {
requestId: '_mock_request_id_41',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDEi=',
redirectURL: '/path41',
},
set42: {
requestId: '_mock_request_id_42',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDIi=',
redirectURL: '/path42',
},
set43: {
requestId: '_mock_request_id_43',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDMi=',
redirectURL: '/path43',
},
set44: {
requestId: '_mock_request_id_44',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDQi=',
redirectURL: '/path44',
},
set45: {
requestId: '_mock_request_id_45',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDUi=',
redirectURL: '/path45',
},
set46: {
requestId: '_mock_request_id_46',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDYi=',
redirectURL: '/path46',
},
set47: {
requestId: '_mock_request_id_47',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDci=',
redirectURL: '/path47',
},
set48: {
requestId: '_mock_request_id_48',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDgi=',
redirectURL: '/path48',
},
set49: {
requestId: '_mock_request_id_49',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNDki=',
redirectURL: '/path49',
},
set50: {
requestId: '_mock_request_id_50',
samlResponse: 'VGVzdFNBTUxSZXNwb25zZTxJblJlc3BvbnNlVG89Il9tb2NrX3JlcXVlc3RfaWRfNTAi=',
redirectURL: '/path50',
},
};

View file

@ -330,6 +330,14 @@ export class Authenticator {
);
if (!authenticationResult.notHandled()) {
if (!ownsSession && existingSessionValue?.provider.name) {
// 'telemetry' to see how prevalent it is for users
// to be using multiple concurrent providers to authenticate
this.logger.warn(
`A previous provider owned the session, ${existingSessionValue?.provider.name}, but the authenticate request was handled by provider ${providerName}`
);
}
const sessionUpdateResult = await this.updateSessionValue(request, {
provider: { type: provider.type, name: providerName },
authenticationResult,
@ -406,6 +414,14 @@ export class Authenticator {
);
if (!authenticationResult.notHandled()) {
if (!ownsSession && existingSession.value?.provider.name) {
// 'telemetry' to see how prevalent it is for users
// to be using multiple concurrent providers to authenticate
this.logger.warn(
`A previous provider owned the session, ${existingSession.value?.provider.name}, but the authenticate request was handled by provider ${providerName}`
);
}
const sessionUpdateResult = await this.updateSessionValue(request, {
provider: { type: provider.type, name: providerName },
authenticationResult,

View file

@ -20,6 +20,7 @@ import {
} from '../../../common/constants';
import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock';
import { securityMock } from '../../mocks';
import { mockSamlResponses } from '../__fixtures__/mock_saml_responses';
import { AuthenticationResult } from '../authentication_result';
import { DeauthenticationResult } from '../deauthentication_result';
@ -30,6 +31,9 @@ describe('SAMLAuthenticationProvider', () => {
let mockScopedClusterClient: ReturnType<
typeof elasticsearchServiceMock.createScopedClusterClient
>;
const mockSAMLSet1 = mockSamlResponses.set1;
beforeEach(() => {
mockOptions = mockAuthenticationProviderOptions({ name: 'saml' });
@ -55,10 +59,16 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path#some-app',
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: mockSAMLSet1.samlResponse,
},
{
requestIdMap: {
[mockSAMLSet1.requestId]: {
redirectURL: '/test-base-path/some-path#some-app',
},
},
realm: 'test-realm',
}
)
@ -77,7 +87,11 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' },
body: {
ids: [mockSAMLSet1.requestId],
content: mockSAMLSet1.samlResponse,
realm: 'test-realm',
},
});
});
@ -99,12 +113,13 @@ describe('SAMLAuthenticationProvider', () => {
request,
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
samlResponse: mockSAMLSet1.samlResponse,
relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
},
{
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path#some-app',
requestIdMap: {
[mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path#some-app' },
},
realm: 'test-realm',
}
)
@ -123,7 +138,11 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' },
body: {
ids: [mockSAMLSet1.requestId],
content: mockSAMLSet1.samlResponse,
realm: 'test-realm',
},
});
});
@ -133,7 +152,10 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
},
{} as any
)
).resolves.toEqual(
@ -155,7 +177,10 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
},
{ realm: 'other-realm' }
)
).resolves.toEqual(
@ -182,8 +207,14 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{ requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' }
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: mockSAMLSet1.samlResponse,
},
{
requestIdMap: { [mockSAMLSet1.requestId]: { redirectURL: '' } },
realm: 'test-realm',
}
)
).resolves.toEqual(
AuthenticationResult.redirectTo('/mock-server-basepath/', {
@ -200,7 +231,11 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' },
body: {
ids: [mockSAMLSet1.requestId],
content: mockSAMLSet1.samlResponse,
realm: 'test-realm',
},
});
});
@ -222,10 +257,13 @@ describe('SAMLAuthenticationProvider', () => {
request,
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
samlResponse: mockSAMLSet1.samlResponse,
relayState: `${mockOptions.basePath.serverBasePath}/app/some-app#some-deep-link`,
},
{ requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' }
{
requestIdMap: { [mockSAMLSet1.requestId]: { redirectURL: '' } },
realm: 'test-realm',
}
)
).resolves.toEqual(
AuthenticationResult.redirectTo('/mock-server-basepath/', {
@ -242,7 +280,11 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' },
body: {
ids: [mockSAMLSet1.requestId],
content: mockSAMLSet1.samlResponse,
realm: 'test-realm',
},
});
});
@ -291,10 +333,14 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path',
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: mockSAMLSet1.samlResponse,
},
{
requestIdMap: {
[mockSAMLSet1.requestId]: { redirectURL: '/test-base-path/some-path' },
},
realm: 'test-realm',
}
)
@ -303,7 +349,108 @@ describe('SAMLAuthenticationProvider', () => {
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/authenticate',
body: { ids: ['some-request-id'], content: 'saml-response-xml', realm: 'test-realm' },
body: {
ids: [mockSAMLSet1.requestId],
content: mockSAMLSet1.samlResponse,
realm: 'test-realm',
},
});
});
describe('Multiple "concurrent" login requests', () => {
it('should remove the successful requestId from the map, but leave the other requestIds', async () => {
const request = httpServerMock.createKibanaRequest();
mockOptions.client.asInternalUser.transport.request.mockResolvedValue({
access_token: 'some-token',
refresh_token: 'some-refresh-token',
realm: 'test-realm',
authentication: mockUser,
});
const requestIdMap: Record<string, { redirectURL: string }> = {};
Object.values(mockSamlResponses).forEach(
(response) => (requestIdMap[response.requestId] = { redirectURL: response.redirectURL })
);
const requestIdMapResult = { ...requestIdMap };
delete requestIdMapResult[mockSamlResponses.set25.requestId];
await expect(
provider.login(
request,
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: mockSamlResponses.set25.samlResponse,
},
{
requestIdMap,
realm: 'test-realm',
}
)
).resolves.toEqual(
AuthenticationResult.redirectTo('/path25', {
user: mockUser,
userProfileGrant: { type: 'accessToken', accessToken: 'some-token' },
state: {
accessToken: 'some-token',
refreshToken: 'some-refresh-token',
requestIdMap: requestIdMapResult,
realm: 'test-realm',
},
})
);
});
it('should replace the first requestId in the list if a new User-Initiated call is made and there are 50 existing requestIds in the state', async () => {
const request = httpServerMock.createKibanaRequest();
const requestIdMap: Record<string, { redirectURL: string }> = {};
Object.values(mockSamlResponses).forEach(
(response) => (requestIdMap[response.requestId] = { redirectURL: response.redirectURL })
);
const newRequestId = '_mock_request_id_51';
const requestIdMapResult = { ...requestIdMap };
delete requestIdMapResult[mockSamlResponses.set1.requestId];
requestIdMapResult[newRequestId] = { redirectURL: '/path51' };
mockOptions.client.asInternalUser.transport.request.mockResolvedValue({
id: newRequestId,
redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20',
realm: 'test-realm',
});
await expect(
provider.login(
request,
{
type: SAMLLogin.LoginInitiatedByUser,
redirectURL: '/path51',
},
{ requestIdMap }
)
).resolves.toEqual(
AuthenticationResult.redirectTo(
'https://idp-host/path/login?SAMLRequest=some%20request%20',
{
state: {
requestIdMap: requestIdMapResult,
realm: 'test-realm',
},
}
)
);
expect(mockOptions.client.asInternalUser.transport.request).toHaveBeenCalledWith({
method: 'POST',
path: '/_security/saml/prepare',
body: {
acs: 'test-protocol://test-hostname:1234/mock-server-basepath/api/security/v1/saml',
},
});
});
});
@ -397,7 +544,10 @@ describe('SAMLAuthenticationProvider', () => {
const loginResult = await provider.login(
httpServerMock.createKibanaRequest({ headers: {} }),
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
}
);
expect(loginResult.user?.elastic_cloud_user).toBe(isElasticCloudUser);
@ -511,7 +661,10 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
},
{
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
@ -544,7 +697,10 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
},
{
accessToken: 'some-valid-token',
refreshToken: 'some-valid-refresh-token',
@ -583,7 +739,10 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
},
state
)
).resolves.toEqual(AuthenticationResult.failed(failureReason));
@ -645,7 +804,10 @@ describe('SAMLAuthenticationProvider', () => {
await expect(
provider.login(
request,
{ type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' },
{
type: SAMLLogin.LoginWithSAMLResponse,
samlResponse: 'saml-response-xml',
},
state
)
).resolves.toEqual(
@ -776,8 +938,9 @@ describe('SAMLAuthenticationProvider', () => {
'https://idp-host/path/login?SAMLRequest=some%20request%20',
{
state: {
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path#some-fragment',
requestIdMap: {
'some-request-id': { redirectURL: '/test-base-path/some-path#some-fragment' },
},
realm: 'test-realm',
},
}
@ -818,8 +981,9 @@ describe('SAMLAuthenticationProvider', () => {
'https://idp-host/path/login?SAMLRequest=some%20request%20',
{
state: {
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path#some-fragment',
requestIdMap: {
'some-request-id': { redirectURL: '/test-base-path/some-path#some-fragment' },
},
realm: 'test-realm',
},
}
@ -866,8 +1030,9 @@ describe('SAMLAuthenticationProvider', () => {
'https://idp-host/path/login?SAMLRequest=some%20request%20',
{
state: {
requestId: 'some-request-id',
redirectURL: '/test-base-path/some-path#some-fragment',
requestIdMap: {
'some-request-id': { redirectURL: '/test-base-path/some-path#some-fragment' },
},
realm: 'test-realm',
},
}
@ -989,8 +1154,11 @@ describe('SAMLAuthenticationProvider', () => {
'https://idp-host/path/login?SAMLRequest=some%20request%20',
{
state: {
requestId: 'some-request-id',
redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment',
requestIdMap: {
'some-request-id': {
redirectURL: '/mock-server-basepath/s/foo/some-path#some-fragment',
},
},
},
}
)

View file

@ -26,20 +26,16 @@ import { HTTPAuthorizationHeader } from '../http_authentication';
import type { RefreshTokenResult, TokenPair } from '../tokens';
import { Tokens } from '../tokens';
type RequestId = string;
/**
* The state supported by the provider (for the SAML handshake or established session).
*/
interface ProviderState extends Partial<TokenPair> {
/**
* Unique identifier of the SAML request initiated the handshake.
* Map of redirectURLs by requestId.
*/
requestId?: string;
/**
* Stores path component of the URL only or in a combination with URL fragment that was used to
* initiate SAML handshake and where we should redirect user after successful authentication.
*/
redirectURL?: string;
requestIdMap?: Record<RequestId, { redirectURL: string }>;
/**
* The name of the SAML realm that was used to establish session (may not be known during URL
@ -96,6 +92,10 @@ function canStartNewSession(request: KibanaRequest) {
return canRedirectRequest(request) && request.route.options.authRequired === true;
}
/**
* SAML _requestId limit
*/
const samlRequestIdLimit = 50;
/**
* Provider that supports SAML request authentication.
*/
@ -141,7 +141,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug('Trying to perform a login.');
// It may happen that Kibana is re-configured to use different realm for the same provider name,
// we should clear such session an log user out.
// we should clear such session and log user out.
if (state && this.realm && state.realm !== this.realm) {
const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`;
this.logger.warn(message);
@ -154,7 +154,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.warn(message);
return AuthenticationResult.failed(Boom.badRequest(message));
}
return this.authenticateViaHandshake(request, attempt.redirectURL);
return this.authenticateViaHandshake(request, attempt.redirectURL, state);
}
const { samlResponse, relayState } = attempt;
@ -213,7 +213,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
// It may happen that Kibana is re-configured to use different realm for the same provider name,
// we should clear such session an log user out.
// we should clear such session and log user out.
if (state && this.realm && state.realm !== this.realm) {
const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`;
this.logger.warn(message);
@ -234,7 +234,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// If we couldn't authenticate by means of all methods above, let's try to capture user URL and
// initiate SAML handshake, otherwise just return authentication result we have.
return authenticationResult.notHandled() && canStartNewSession(request)
? this.initiateAuthenticationHandshake(request)
? this.initiateAuthenticationHandshake(request, state)
: authenticationResult;
}
@ -270,7 +270,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
try {
// It may _theoretically_ (highly unlikely in practice though) happen that when user receives
// logout response they may already have a new SAML session (isSPInitiatedSLOResponse == true
// and state !== undefined). In this case case it'd be safer to trigger SP initiated logout
// and state !== undefined). In this case it'd be safer to trigger SP initiated logout
// for the new session as well.
const redirect = isIdPInitiatedSLORequest
? await this.performIdPInitiatedSingleLogout(request, this.realm || state?.realm)
@ -330,29 +330,36 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// If we have a `SAMLResponse` and state, but state doesn't contain all the necessary information,
// then something unexpected happened and we should fail.
const {
requestId: stateRequestId,
redirectURL: stateRedirectURL,
realm: stateRealm,
} = state || {
requestId: '',
redirectURL: '',
const { requestIdMap: stateRequestIdMap = {}, realm: stateRealm } = state || {
requestIdMap: {},
realm: '',
};
if (state && !stateRequestId) {
const stateRequestIds = Object.keys(stateRequestIdMap) || [];
this.logger.debug(`Current state: ${JSON.stringify(state)}`);
if (state && stateRequestIds.length === 0) {
const message = 'SAML response state does not have corresponding request id.';
this.logger.warn(message);
return AuthenticationResult.failed(Boom.badRequest(message));
}
// When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login.
const isIdPInitiatedLogin = !stateRequestId;
// When we don't have requestIds we assume that SAMLResponse came from an IdP initiated login.
const isIdPInitiatedLogin = !stateRequestIds.length;
this.logger.debug(
!isIdPInitiatedLogin
? `Login has been previously initiated by Kibana, request id ${stateRequestId}.`
? `Login has been previously initiated by Kibana. Current requestIds: ${stateRequestIds}`
: 'Login has been initiated by Identity Provider.'
);
this.logger.debug(
`SAML RESPONSE: ${samlResponse}:::${JSON.stringify(
!isIdPInitiatedLogin ? [...stateRequestIds] : []
)}`
);
const providerRealm = this.realm || stateRealm;
let result: {
@ -370,7 +377,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
method: 'POST',
path: '/_security/saml/authenticate',
body: {
ids: !isIdPInitiatedLogin ? [stateRequestId] : [],
ids: !isIdPInitiatedLogin ? stateRequestIds : [],
content: samlResponse,
...(providerRealm ? { realm: providerRealm } : {}),
},
@ -378,8 +385,8 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
} catch (err) {
this.logger.error(
`Failed to log in with SAML response, ${
!isIdPInitiatedLogin ? `request id: ${stateRequestId}, ` : ''
}error: ${getDetailedErrorMessage(err)}`
!isIdPInitiatedLogin ? `current requestIds: ${stateRequestIds}, ` : ''
} error: ${getDetailedErrorMessage(err)}`
);
// Since we don't know upfront what realm is targeted by the Identity Provider initiated login
@ -411,8 +418,35 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
this.logger.debug('Login has been performed with SAML response.');
let redirectURLForRequestId;
let areAnyRequestIdsRemaining = false;
let remainingRequestIdMap = stateRequestIdMap;
if (!isIdPInitiatedLogin) {
const inResponseToRequestId = this.parseRequestIdFromSAMLResponse(samlResponse);
this.logger.debug(`Login was performed with requestId: ${inResponseToRequestId}`);
if (stateRequestIds.length && inResponseToRequestId) {
redirectURLForRequestId = stateRequestIdMap[inResponseToRequestId].redirectURL;
} else {
this.logger.info(
'No requestId found in SAML response or state does not contain requestId.'
);
}
// Remove value of inResponseToRequestId from stateRequestIdMap and return a map of any
// requestIds that remain
[areAnyRequestIdsRemaining, remainingRequestIdMap] = this.updateRemainingRequestIds(
inResponseToRequestId,
stateRequestIdMap
);
}
return AuthenticationResult.redirectTo(
redirectURLFromRelayState || stateRedirectURL || `${this.options.basePath.get(request)}/`,
redirectURLFromRelayState ||
redirectURLForRequestId ||
`${this.options.basePath.get(request)}/`,
{
user: this.authenticationInfoToAuthenticatedUser(result.authentication),
userProfileGrant: { type: 'accessToken', accessToken: result.access_token },
@ -420,11 +454,43 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
accessToken: result.access_token,
refreshToken: result.refresh_token,
realm: result.realm,
...(areAnyRequestIdsRemaining && { requestIdMap: remainingRequestIdMap }),
},
}
);
}
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,
remainingRequestIds: Record<RequestId, { redirectURL: string }>
): [boolean, Record<RequestId, { redirectURL: string }>] {
if (requestIdToRemove) {
this.logger.info(`Removing requestId ${requestIdToRemove} from the state.`);
delete remainingRequestIds[requestIdToRemove];
}
const areAnyRequestIdsRemaining =
remainingRequestIds && Object.keys(remainingRequestIds)?.length > 0;
if (areAnyRequestIdsRemaining) {
this.logger.info(
`The remaining requestIds in the state are ${Object.keys(remainingRequestIds)}`
);
} else {
this.logger.info(`There are no remaining requestIds in the state.`);
}
return [areAnyRequestIdsRemaining, remainingRequestIds];
}
/**
* Validates whether user retrieved using session is the same as the user defined in the SAML payload.
* If we can successfully exchange this SAML payload to access and refresh tokens, then we'll
@ -446,12 +512,18 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
) {
this.logger.info('Trying to log in with SAML response payload and existing valid session.');
// If there are requestIds we want to pass the state
const shouldPassState =
existingState?.requestIdMap && Object.keys(existingState.requestIdMap).length > 0;
// First let's try to authenticate via SAML Response payload.
const payloadAuthenticationResult = await this.loginWithSAMLResponse(
request,
samlResponse,
relayState
relayState,
shouldPassState ? existingState : null
);
if (payloadAuthenticationResult.failed() || payloadAuthenticationResult.notHandled()) {
return payloadAuthenticationResult;
}
@ -489,7 +561,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
*/
private async authenticateViaState(request: KibanaRequest, { accessToken }: ProviderState) {
this.logger.debug('Trying to authenticate via state.');
if (!accessToken) {
this.logger.debug('Access token is not found in state.');
return AuthenticationResult.notHandled();
@ -569,15 +640,20 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* Tries to start SAML handshake and eventually receive a token.
* @param request Request instance.
* @param redirectURL URL to redirect user to after successful SAML handshake.
* @param state Optional state object associated with the provider.
*/
private async authenticateViaHandshake(request: KibanaRequest, redirectURL: string) {
private async authenticateViaHandshake(
request: KibanaRequest,
redirectURL: string,
state?: ProviderState | null
) {
this.logger.debug('Trying to initiate SAML handshake.');
try {
// Prefer realm name if it's specified, otherwise fallback to ACS.
const preparePayload = this.realm ? { realm: this.realm } : { acs: this.getACS() };
// This operation should be performed on behalf of the user with a privilege that normal
// This operation should be performed on behalf of the user with a privilege that a normal
// user usually doesn't have `cluster:admin/xpack/security/saml/prepare`.
// We can replace generic `transport.request` with a dedicated API method call once
// https://github.com/elastic/elasticsearch/issues/67189 is resolved.
@ -597,7 +673,10 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
// Store request id in the state so that we can reuse it once we receive `SAMLResponse`.
return AuthenticationResult.redirectTo(redirect, {
state: { requestId, redirectURL, realm },
state: {
requestIdMap: this.updateRequestIdMap(requestId, redirectURL, state?.requestIdMap),
realm,
},
});
} catch (err) {
this.logger.debug(() => `Failed to initiate SAML handshake: ${getDetailedErrorMessage(err)}`);
@ -605,6 +684,38 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
}
}
private updateRequestIdMap(
newRequestID: string,
newRedirectURL: string,
existingRequestIdMap: Record<RequestId, { redirectURL: string }> | undefined
): Record<RequestId, { redirectURL: string }> {
let result: Record<RequestId, { redirectURL: string }> = {};
if (existingRequestIdMap) {
result = existingRequestIdMap;
}
// We do not want to add an infinite number of requestIds to the state, so we limit it to `samlRequestIdLimit`(50)
// We remove the first requestId if we have 50
if (Object.keys(result).length >= samlRequestIdLimit) {
this.logger.debug(
`requestId limit reached, removing the oldest requestId ${result[0]} from the state.`
);
const oldestRequestId = Object.keys(result)[0];
delete result[oldestRequestId];
}
// We add the new requestId to the end of the array
result[newRequestID] = { redirectURL: newRedirectURL };
this.logger.debug(
`Adding new requestId ${newRequestID} to the state. Current state: ${JSON.stringify(result)}`
);
return result;
}
/**
* Calls `saml/logout` with access and refresh tokens and redirects user to the Identity Provider if needed.
* @param accessToken Access token to invalidate.
@ -673,13 +784,16 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
* initiate handshake right away, otherwise we'll redirect user to a dedicated page where we capture URL hash fragment
* first and only then initiate SAML handshake.
* @param request Request instance.
* @param state Optional state object associated with the provider.
*/
private initiateAuthenticationHandshake(request: KibanaRequest) {
private initiateAuthenticationHandshake(request: KibanaRequest, state?: ProviderState | null) {
const originalURLHash = request.url.searchParams.get(AUTH_URL_HASH_QUERY_STRING_PARAMETER);
if (originalURLHash != null) {
return this.authenticateViaHandshake(
request,
`${this.options.getRequestOriginalURL(request)}${originalURLHash}`
`${this.options.getRequestOriginalURL(request)}${originalURLHash}`,
state
);
}
@ -693,7 +807,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider {
)}`,
// Here we indicate that current session, if any, should be invalidated. It is a no-op for the
// initial handshake, but is essential when both access and refresh tokens are expired.
{ state: null }
{ state: state ? state : null }
);
}
}

View file

@ -8,6 +8,7 @@
"public/**/*",
"server/**/*",
"__mocks__/**/*",
"__fixtures__/**/*"
],
"kbn_references": [
"@kbn/cloud-plugin",

View file

@ -6,6 +6,7 @@
*/
import { readFileSync } from 'fs';
import type { Response } from 'supertest';
import type { Cookie } from 'tough-cookie';
import { parse as parseCookie } from 'tough-cookie';
import url from 'url';
@ -543,13 +544,17 @@ export default function ({ getService }: FtrProviderContext) {
).to.be(true);
const saml2HandshakeCookie = parseCookie(saml2HandshakeResponse.headers['set-cookie'][0])!;
const samlRequestId = await getSAMLRequestId(saml2HandshakeResponse.body.location);
const saml2AuthenticationResponse = await supertest
.post('/api/security/saml/callback')
.ca(CA_CERT)
.set('Cookie', saml2HandshakeCookie.cookieString())
.send({
SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }),
SAMLResponse: await createSAMLResponse({
issuer: `http://www.elastic.co/saml2`,
inResponseTo: samlRequestId,
}),
})
.expect(302);
@ -568,6 +573,79 @@ export default function ({ getService }: FtrProviderContext) {
'token'
);
});
it.skip('should be able to have many pending SP initiated logins all successfully succeed', async () => {
const samlResponseMapByRequestId: Record<string, { samlResponse: string; cookie: any }> =
{};
let sharedCookie;
for (let i = 0; i < 10; i++) {
const samlHandshakeResponse: Response = await supertest
.post('/internal/security/login')
.ca(CA_CERT)
.set('kbn-xsrf', 'xxx')
.set('Cookie', sharedCookie ? sharedCookie.cookieString() : '')
.send({
providerType: 'saml',
providerName: 'saml2',
currentURL: `https://kibana.com/login?next=/abc/xyz/${i}`,
})
.expect(200);
if (!sharedCookie) {
sharedCookie = parseCookie(samlHandshakeResponse.headers['set-cookie'][0])!;
}
const cookie = parseCookie(samlHandshakeResponse.headers['set-cookie'][0])!;
const samlRequestId = await getSAMLRequestId(samlHandshakeResponse.body.location);
const samlResponse = await createSAMLResponse({
issuer: `http://www.elastic.co/saml2`,
inResponseTo: samlRequestId,
});
samlResponseMapByRequestId[samlRequestId] = { samlResponse, cookie };
}
const preparedCallbacks = [];
for (const requestId of Object.keys(samlResponseMapByRequestId)) {
const samlValues = samlResponseMapByRequestId[requestId];
const callbackFunc = () => {
return supertest
.post('/api/security/saml/callback')
.ca(CA_CERT)
.set('Cookie', samlValues.cookie.cookieString())
.send({
SAMLResponse: samlValues.samlResponse,
});
};
preparedCallbacks.push(callbackFunc);
}
const responses = await Promise.all(
preparedCallbacks.map((func) => {
return func();
})
);
expect(
responses.map((response: Response) => {
return response.headers.location;
})
).to.eql([
'/abc/xyz/0',
'/abc/xyz/1',
'/abc/xyz/2',
'/abc/xyz/3',
'/abc/xyz/4',
'/abc/xyz/5',
'/abc/xyz/6',
'/abc/xyz/7',
'/abc/xyz/8',
'/abc/xyz/9',
]);
});
});
describe('Kerberos', () => {