mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
# 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:
parent
dbbd9339cd
commit
7983a6ffdf
6 changed files with 706 additions and 70 deletions
|
@ -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',
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"public/**/*",
|
||||
"server/**/*",
|
||||
"__mocks__/**/*",
|
||||
"__fixtures__/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/cloud-plugin",
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue