diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index e6ceccde95..1e0dd9c424 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -585,3 +585,61 @@ describe('deepLinking saga — handleOAuth dedup guard', () => { }); }); }); + +describe('deepLinking saga — handleSaml', () => { + beforeEach(() => { + jest.mocked(loginOAuthOrSso).mockReset(); + jest.mocked(loginOAuthOrSso).mockResolvedValue(undefined as any); + }); + + it('redeems the SAML credential token through the regular saml login', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST, credentialToken: 'saml-fresh-A' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(1); + expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledWith({ saml: true, credentialToken: 'saml-fresh-A' }); + }); + + it('does not call loginOAuthOrSso when the credentialToken is missing', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(loginOAuthOrSso)).not.toHaveBeenCalled(); + }); + + it('does not redeem the same SAML credentialToken twice', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST, credentialToken: 'saml-dup-B' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + // The credential token is single use on the server, so a replayed deep link must be suppressed. + store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST, credentialToken: 'saml-dup-B' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(1); + }); + + it('redeems a different SAML credentialToken after a previous one was consumed', async () => { + const store = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST, credentialToken: 'saml-first-C' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST, credentialToken: 'saml-second-C' } as any)); + await flushSagaMicrotasks(); + await flushSagaMicrotasks(); + + expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(2); + expect(jest.mocked(loginOAuthOrSso)).toHaveBeenNthCalledWith(2, { saml: true, credentialToken: 'saml-second-C' }); + }); +}); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index fac9e3292d..6da1e0bdcb 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -143,6 +143,21 @@ const handleOAuth = function* handleOAuth({ params }) { } }; +let consumedSamlToken; + +const handleSaml = function* handleSaml({ params }) { + const { credentialToken } = params; + if (!credentialToken || credentialToken === consumedSamlToken) { + return; + } + consumedSamlToken = credentialToken; + try { + yield loginOAuthOrSso({ saml: true, credentialToken }); + } catch (e) { + log(e); + } +}; + const handleShareExtension = function* handleOpen({ params }) { const server = UserPreferences.getString(CURRENT_SERVER); const user = UserPreferences.getString(`${TOKEN_KEY}-${server}`); @@ -175,6 +190,10 @@ const handleOpen = function* handleOpen({ params }) { yield handleOAuth({ params }); return; } + if (params.type === 'saml') { + yield handleSaml({ params }); + return; + } // If there's no host on the deep link params and the app is opened, just call appInit() let { host } = params;