From fa4d85670c50d292ee2b3019ab180b329e9a3ca5 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Fri, 14 Aug 2026 15:40:29 +0530 Subject: [PATCH 01/14] saml deeplink auth --- .../meteor/client/lib/buildAuthDeeplinkURL.ts | 7 ++++ apps/meteor/client/meteor/login/saml.ts | 10 +++++- .../client/views/root/SAMLLoginRoute.tsx | 12 +++++-- apps/meteor/server/lib/saml/lib/SAML.ts | 21 +++++++++--- .../server/lib/saml/lib/ServiceProvider.ts | 8 ++--- apps/meteor/server/lib/saml/lib/Utils.ts | 32 +++++++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/apps/meteor/client/lib/buildAuthDeeplinkURL.ts b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts index e4481fed271d3..2598a073b7eb1 100644 --- a/apps/meteor/client/lib/buildAuthDeeplinkURL.ts +++ b/apps/meteor/client/lib/buildAuthDeeplinkURL.ts @@ -3,3 +3,10 @@ export const buildDeepLinkURL = (resumeToken: string, userId: string) => { const { origin } = url; return `rocketchat://auth?host=${origin}&token=${resumeToken}&userId=${userId}`; }; + +export const buildSamlDeepLinkURL = (credentialToken: string) => { + const { origin } = new URL(window.location.href); + const params = new URLSearchParams({ type: 'saml', host: origin, credentialToken }); + + return `rocketchat://auth?${params.toString()}`; +}; diff --git a/apps/meteor/client/meteor/login/saml.ts b/apps/meteor/client/meteor/login/saml.ts index 1dcb48d91f067..3a7f4dadcbc23 100644 --- a/apps/meteor/client/meteor/login/saml.ts +++ b/apps/meteor/client/meteor/login/saml.ts @@ -91,7 +91,15 @@ Meteor.loginWithSaml = (options) => { const credentialToken = `id-${Random.id()}`; options.credentialToken = credentialToken; - window.location.href = `_saml/authorize/${options.provider}/${options.credentialToken}`; + let url = `_saml/authorize/${options.provider}/${options.credentialToken}`; + + // Forward the loginClient so the session can be deep-linked back to the native client. + const loginClient = new URLSearchParams(window.location.search).get('loginClient'); + if (loginClient === 'desktop' || loginClient === 'mobile') { + url += `?loginClient=${loginClient}`; + } + + window.location.href = url; }; const loginWithSamlToken = (credentialToken: string) => diff --git a/apps/meteor/client/views/root/SAMLLoginRoute.tsx b/apps/meteor/client/views/root/SAMLLoginRoute.tsx index 35e3c26db504b..efabbc93b4794 100644 --- a/apps/meteor/client/views/root/SAMLLoginRoute.tsx +++ b/apps/meteor/client/views/root/SAMLLoginRoute.tsx @@ -1,17 +1,25 @@ -import { useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useRouter, useToastMessageDispatch, useSearchParameter } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { useEffect } from 'react'; +import { buildSamlDeepLinkURL } from '../../lib/buildAuthDeeplinkURL'; import { useSamlInviteToken } from '../invite/hooks/useSamlInviteToken'; const SAMLLoginRoute = () => { const router = useRouter(); const dispatchToastMessage = useToastMessageDispatch(); const [inviteToken] = useSamlInviteToken(); + const loginClient = useSearchParameter('loginClient'); useEffect(() => { const { token } = router.getRouteParameters(); + //SAML token handoff to the native client (mobile/desktop) + if (loginClient === 'desktop' || loginClient === 'mobile') { + window.location.href = buildSamlDeepLinkURL(token); + return; + } + Meteor.loginWithSamlToken(token, (error?: unknown) => { if (error) { dispatchToastMessage({ type: 'error', message: error }); @@ -33,7 +41,7 @@ const SAMLLoginRoute = () => { ); } }); - }, [dispatchToastMessage, inviteToken, router]); + }, [dispatchToastMessage, inviteToken, loginClient, router]); return null; }; diff --git a/apps/meteor/server/lib/saml/lib/SAML.ts b/apps/meteor/server/lib/saml/lib/SAML.ts index a0d859f77a22c..b201640742b3f 100644 --- a/apps/meteor/server/lib/saml/lib/SAML.ts +++ b/apps/meteor/server/lib/saml/lib/SAML.ts @@ -77,7 +77,7 @@ export class SAML { case 'sloRedirect': return this.processSLORedirectAction(req, res, service); case 'authorize': - return this.processAuthorizeAction(res, service, samlObject); + return this.processAuthorizeAction(req, res, service, samlObject); case 'validate': return this.processValidateAction(req, res, service, samlObject); default: @@ -247,7 +247,7 @@ export class SAML { if ((username && username !== user.username) || (nameOverwrite && fullName && fullName !== user.name)) { await saveUserIdentity({ _id: user._id, name: nameOverwrite ? fullName || undefined : user.name, username }); } - + console.log('saml token - ', stampedToken); // sending token along with the userId return { userId: user._id, @@ -457,15 +457,18 @@ export class SAML { } private static async processAuthorizeAction( + req: IIncomingMessage, res: ServerResponse, service: IServiceProviderOptions, samlObject: ISAMLAction, ): Promise { const serviceProvider = new SAMLServiceProvider(service); let url: string | undefined; + const requestedLoginClient = req.query.loginClient; + const loginClient = requestedLoginClient === 'desktop' || requestedLoginClient === 'mobile' ? requestedLoginClient : undefined; try { - url = await serviceProvider.getAuthorizeUrl(samlObject.credentialToken); + url = await serviceProvider.getAuthorizeUrl(samlObject.credentialToken, loginClient); } catch (err: any) { SAMLUtils.error({ err, msg: 'Unable to generate authorize url' }); url = Meteor.absoluteUrl(); @@ -498,7 +501,9 @@ export class SAML { } const serviceProvider = new SAMLServiceProvider(service); - SAMLUtils.relayState = envelope.relayState ?? null; + const { provider, loginClient } = SAMLUtils.decodeAuthorizeRelayState(envelope.relayState); + // Keep exposing the provider as the relay state for downstream profile mapping. + SAMLUtils.relayState = provider ?? null; serviceProvider.validateResponse(envelope, async (err, profile /* , loggedOut*/) => { try { if (err) { @@ -534,7 +539,13 @@ export class SAML { }; await this.storeCredential(credentialToken, loginResult); - const url = Meteor.absoluteUrl(SAMLUtils.getValidationActionRedirectPath(credentialToken)); + + let redirectPath = SAMLUtils.getValidationActionRedirectPath(credentialToken); + if (loginClient) { + redirectPath += `&loginClient=${loginClient}`; + } + + const url = Meteor.absoluteUrl(redirectPath); redirect(url); } catch (err) { SAMLUtils.error({ err }); diff --git a/apps/meteor/server/lib/saml/lib/ServiceProvider.ts b/apps/meteor/server/lib/saml/lib/ServiceProvider.ts index 3157a11366a7c..346a36b674250 100644 --- a/apps/meteor/server/lib/saml/lib/ServiceProvider.ts +++ b/apps/meteor/server/lib/saml/lib/ServiceProvider.ts @@ -119,7 +119,7 @@ export class SAMLServiceProvider { /* This method will generate the request URL with all the query string params and pass it to the callback */ - public async requestToUrl(request: string, operation: string): Promise { + public async requestToUrl(request: string, operation: string, loginClient?: string): Promise { const buffer = await util.promisify(zlib.deflateRaw)(request); try { const base64 = buffer.toString('base64'); @@ -143,7 +143,7 @@ export class SAMLServiceProvider { // in case of logout we want to be redirected back to the Meteor app. relayState = Meteor.absoluteUrl(); } else { - relayState = this.serviceProviderOptions.provider; + relayState = SAMLUtils.encodeAuthorizeRelayState(this.serviceProviderOptions.provider, loginClient); } const samlRequest = this.maybeSignRequest({ @@ -165,11 +165,11 @@ export class SAMLServiceProvider { } } - public async getAuthorizeUrl(credentialToken: string): Promise { + public async getAuthorizeUrl(credentialToken: string, loginClient?: string): Promise { const request = this.generateAuthorizeRequest(credentialToken); SAMLUtils.log({ request, msg: 'getAuthorizeUrl' }); - return this.requestToUrl(request, 'authorize'); + return this.requestToUrl(request, 'authorize', loginClient); } public async validateLogoutRequest( diff --git a/apps/meteor/server/lib/saml/lib/Utils.ts b/apps/meteor/server/lib/saml/lib/Utils.ts index 6a540e56e266d..5d0e905a1173f 100644 --- a/apps/meteor/server/lib/saml/lib/Utils.ts +++ b/apps/meteor/server/lib/saml/lib/Utils.ts @@ -137,6 +137,38 @@ export class SAMLUtils { return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`; } + private static isSupportedLoginClient(value: unknown): value is 'desktop' | 'mobile' { + return value === 'desktop' || value === 'mobile'; + } + + public static encodeAuthorizeRelayState(provider: string, loginClient?: string): string { + if (!this.isSupportedLoginClient(loginClient)) { + return provider; + } + + return new URLSearchParams({ provider, loginClient }).toString(); + } + + public static decodeAuthorizeRelayState(relayState?: string | null): { provider?: string; loginClient?: 'desktop' | 'mobile' } { + console.log('SAMLUtils.decodeAuthorizeRelayState', { relayState }); + if (!relayState) { + return {}; + } + + if (relayState.includes('provider=')) { + const params = new URLSearchParams(relayState); + const provider = params.get('provider') ?? undefined; + const loginClient = params.get('loginClient'); + + return { + provider, + loginClient: this.isSupportedLoginClient(loginClient) ? loginClient : undefined, + }; + } + + return { provider: relayState }; + } + public static log(obj: object | string): void { if (debug && logger) { logger.debug(obj); From e6682f02e3623ade51ba1e9fe2cb3910cc2c8046 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 21:34:42 +0530 Subject: [PATCH 02/14] add unit tests --- .../client/views/root/SAMLLoginRoute.spec.tsx | 48 +++++++++++++++++++ apps/meteor/server/lib/saml/lib/Utils.ts | 1 - .../unit/server/lib/saml/server.tests.ts | 31 ++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx b/apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx index 6a2c5a4c0b221..2049fc97bf745 100644 --- a/apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx +++ b/apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx @@ -89,3 +89,51 @@ it('should call loginWithSamlToken with the token when it is present', async () expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1); expect(Meteor.loginWithSamlToken).toHaveBeenLastCalledWith('testToken', expect.any(Function)); }); + +jest.mock('../../lib/buildAuthDeeplinkURL', () => ({ buildSamlDeepLinkURL: jest.fn() })); + +describe('native client handoff', () => { + const { buildSamlDeepLinkURL } = jest.requireMock('../../lib/buildAuthDeeplinkURL') as { buildSamlDeepLinkURL: jest.Mock }; + + const navigableDeepLink = `${window.location.origin}/#deep-link`; + + beforeEach(() => { + mockUseSamlInviteToken.mockReturnValue([null, () => ({})]); + buildSamlDeepLinkURL.mockReturnValue(navigableDeepLink); + }); + + afterEach(() => { + window.location.hash = ''; + }); + + it.each(['mobile', 'desktop'])('should hand the credential token to the %s client without logging in', async (loginClient) => { + render( + + + + + , + ); + + // The browser must never authenticate: the app redeems the credential on its own connection. + expect(Meteor.loginWithSamlToken).not.toHaveBeenCalled(); + expect(navigateStub).not.toHaveBeenCalled(); + + expect(buildSamlDeepLinkURL).toHaveBeenCalledTimes(1); + expect(buildSamlDeepLinkURL).toHaveBeenLastCalledWith('testToken'); + expect(window.location.href).toBe(navigableDeepLink); + }); + + it('should log in normally for an unrecognized loginClient', async () => { + render( + + + + + , + ); + + expect(buildSamlDeepLinkURL).not.toHaveBeenCalled(); + expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/meteor/server/lib/saml/lib/Utils.ts b/apps/meteor/server/lib/saml/lib/Utils.ts index 5d0e905a1173f..4bc9debc95529 100644 --- a/apps/meteor/server/lib/saml/lib/Utils.ts +++ b/apps/meteor/server/lib/saml/lib/Utils.ts @@ -150,7 +150,6 @@ export class SAMLUtils { } public static decodeAuthorizeRelayState(relayState?: string | null): { provider?: string; loginClient?: 'desktop' | 'mobile' } { - console.log('SAMLUtils.decodeAuthorizeRelayState', { relayState }); if (!relayState) { return {}; } diff --git a/apps/meteor/tests/unit/server/lib/saml/server.tests.ts b/apps/meteor/tests/unit/server/lib/saml/server.tests.ts index cd6d447de6713..89e346a98fefd 100644 --- a/apps/meteor/tests/unit/server/lib/saml/server.tests.ts +++ b/apps/meteor/tests/unit/server/lib/saml/server.tests.ts @@ -1131,6 +1131,36 @@ describe('SAML', () => { `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`, ); }); + + describe('authorize RelayState encoding', () => { + it('should keep the provider when no loginClient is provided', () => { + expect(SAMLUtils.encodeAuthorizeRelayState('test-sp')).to.be.equal('test-sp'); + }); + + it('should ignore unsupported loginClient values and keep the provider', () => { + expect(SAMLUtils.encodeAuthorizeRelayState('test-sp', 'web')).to.be.equal('test-sp'); + expect(SAMLUtils.encodeAuthorizeRelayState('test-sp', '')).to.be.equal('test-sp'); + }); + + it('should round-trip provider and loginClient for supported clients', () => { + for (const loginClient of ['mobile', 'desktop']) { + const encoded = SAMLUtils.encodeAuthorizeRelayState('test-sp', loginClient); + expect(SAMLUtils.decodeAuthorizeRelayState(encoded)).to.be.deep.equal({ provider: 'test-sp', loginClient }); + } + }); + + it('should drop an unexpected loginClient echoed back in a compound RelayState', () => { + expect(SAMLUtils.decodeAuthorizeRelayState('provider=test-sp&loginClient=hacker')).to.be.deep.equal({ + provider: 'test-sp', + loginClient: undefined, + }); + }); + + it('should preserve providers containing URL-special characters', () => { + const encoded = SAMLUtils.encodeAuthorizeRelayState('a b&c=d', 'mobile'); + expect(SAMLUtils.decodeAuthorizeRelayState(encoded)).to.be.deep.equal({ provider: 'a b&c=d', loginClient: 'mobile' }); + }); + }); }); describe('[SAML.processRequest] validate action - assertion replay protection', () => { @@ -1185,6 +1215,7 @@ describe('SAML', () => { warn: sinon.stub(), log: sinon.stub(), getValidationActionRedirectPath: (token: string) => `_saml/validate/${token}`, + decodeAuthorizeRelayState: (provider: string) => ({ provider }), }, }, './getSAMLEnvelope': { getSAMLEnvelope: async () => ({ relayState: null }) }, From 1346ede48a1ef9963b12619438c78109f50024e1 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 21:45:40 +0530 Subject: [PATCH 03/14] remove logs --- apps/meteor/server/lib/saml/lib/SAML.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/meteor/server/lib/saml/lib/SAML.ts b/apps/meteor/server/lib/saml/lib/SAML.ts index b201640742b3f..bcb9cf3cbaecd 100644 --- a/apps/meteor/server/lib/saml/lib/SAML.ts +++ b/apps/meteor/server/lib/saml/lib/SAML.ts @@ -247,7 +247,6 @@ export class SAML { if ((username && username !== user.username) || (nameOverwrite && fullName && fullName !== user.name)) { await saveUserIdentity({ _id: user._id, name: nameOverwrite ? fullName || undefined : user.name, username }); } - console.log('saml token - ', stampedToken); // sending token along with the userId return { userId: user._id, From a040e6c3966ee0ff654bac967c33f56fa7d82ee4 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 21:46:05 +0530 Subject: [PATCH 04/14] client side navigation after token handoff --- apps/meteor/client/views/root/SAMLLoginRoute.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/meteor/client/views/root/SAMLLoginRoute.tsx b/apps/meteor/client/views/root/SAMLLoginRoute.tsx index efabbc93b4794..396515179edd2 100644 --- a/apps/meteor/client/views/root/SAMLLoginRoute.tsx +++ b/apps/meteor/client/views/root/SAMLLoginRoute.tsx @@ -13,10 +13,14 @@ const SAMLLoginRoute = () => { useEffect(() => { const { token } = router.getRouteParameters(); + let timeout: NodeJS.Timeout; //SAML token handoff to the native client (mobile/desktop) if (loginClient === 'desktop' || loginClient === 'mobile') { window.location.href = buildSamlDeepLinkURL(token); + timeout = setTimeout(() => { + router.navigate('/home', { replace: true }); + }, 0); return; } @@ -41,6 +45,8 @@ const SAMLLoginRoute = () => { ); } }); + + return () => clearTimeout(timeout); }, [dispatchToastMessage, inviteToken, loginClient, router]); return null; From ba762044cb8fb5bf8c2b4aed2293fecbe9a1c122 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 22:40:52 +0530 Subject: [PATCH 05/14] add e2e test --- apps/meteor/tests/e2e/saml.spec.ts | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts index da4ec228fba0b..8b7a8dee6fafe 100644 --- a/apps/meteor/tests/e2e/saml.spec.ts +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -808,6 +808,59 @@ test.describe('SAML', () => { }); }); + test.describe('SAML Login handoff', () => { + // When a native client starts the login it passes `loginClient`, and the web client must hand the SAML + // credential token over to the app instead of logging itself in, so that only one session is ever created. + const findCredentialToken = async (credentialToken: string) => { + const connection = await MongoClient.connect(constants.URL_MONGODB); + try { + return await connection + .db() + .collection<{ _id: string; userInfo?: { profile?: Record } }>('rocketchat_credential_tokens') + .findOne({ _id: credentialToken }); + } finally { + await connection.close(); + } + }; + + test.only('Hand the credential token to the desktop client without logging in the browser', async ({ page }) => { + await page.goto('/home?loginClient=desktop'); + + await expect(page).toHaveURL(/loginClient=desktop/); + + // Passing null skips the logged-in assertions, since the browser must not get a session here. + await doLoginStep(page, 'samluser1', null); + + let credentialToken: string | null = null; + + await test.step('expect to land on the SAML handoff route carrying the credential token', async () => { + // SAMLLoginRoute redirects to rocketchat://auth from here. The browser has no handler for that + // scheme, so the page stays put and we can inspect exactly what would have been handed over. + await expect(page).toHaveURL(/\/saml\/[^?]+\?.*loginClient=desktop/); + + credentialToken = new URL(page.url()).searchParams.get('saml_idp_credentialToken'); + expect(credentialToken).toBeTruthy(); + }); + + await test.step('expect the credential to be stored server side, ready for the app to redeem', async () => { + const storedCredential = await findCredentialToken(credentialToken as string); + + expect(storedCredential).not.toBeNull(); + expect(storedCredential?.userInfo?.profile).toBeDefined(); + expect(storedCredential?.userInfo?.profile?.email).toBe('samluser1@example.com'); + }); + + await test.step('expect the browser to remain unauthenticated', async () => { + await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + + // Reload to prove no session was persisted for this browser. + await page.goto('/home'); + await expect(poRegistration.btnLoginWithSaml).toBeVisible(); + await expect(page.getByRole('button', { name: 'User menu' })).not.toBeVisible(); + }); + }); + }); + test.fixme('Data Sync - Custom Field Map', async () => { // Test the data sync using a custom fieldmap setting }); From 206c32e5c617206ddead966ea493ff282b115cee Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 22:45:25 +0530 Subject: [PATCH 06/14] add cs --- .changeset/cute-pumas-trade.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cute-pumas-trade.md diff --git a/.changeset/cute-pumas-trade.md b/.changeset/cute-pumas-trade.md new file mode 100644 index 0000000000000..7bb0069a6ef75 --- /dev/null +++ b/.changeset/cute-pumas-trade.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Adds support for SAML authentication in the mobile and desktop apps via the system browser. From 1c6f72c1dc24546e44a09e470f22619edd69f791 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 22:55:47 +0530 Subject: [PATCH 07/14] refactor: clear timeout --- apps/meteor/client/views/root/SAMLLoginRoute.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/views/root/SAMLLoginRoute.tsx b/apps/meteor/client/views/root/SAMLLoginRoute.tsx index 396515179edd2..45c775b8ab4f8 100644 --- a/apps/meteor/client/views/root/SAMLLoginRoute.tsx +++ b/apps/meteor/client/views/root/SAMLLoginRoute.tsx @@ -13,15 +13,14 @@ const SAMLLoginRoute = () => { useEffect(() => { const { token } = router.getRouteParameters(); - let timeout: NodeJS.Timeout; //SAML token handoff to the native client (mobile/desktop) if (loginClient === 'desktop' || loginClient === 'mobile') { window.location.href = buildSamlDeepLinkURL(token); - timeout = setTimeout(() => { + const timeout = setTimeout(() => { router.navigate('/home', { replace: true }); }, 0); - return; + return () => clearTimeout(timeout); } Meteor.loginWithSamlToken(token, (error?: unknown) => { @@ -45,8 +44,6 @@ const SAMLLoginRoute = () => { ); } }); - - return () => clearTimeout(timeout); }, [dispatchToastMessage, inviteToken, loginClient, router]); return null; From 477169d4591f00fe011788a13e9825aadcf1435d Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 22:57:14 +0530 Subject: [PATCH 08/14] chore: remove test only --- apps/meteor/tests/e2e/saml.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts index 8b7a8dee6fafe..2fcde28bd1815 100644 --- a/apps/meteor/tests/e2e/saml.spec.ts +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -823,7 +823,7 @@ test.describe('SAML', () => { } }; - test.only('Hand the credential token to the desktop client without logging in the browser', async ({ page }) => { + test('Hand the credential token to the desktop client without logging in the browser', async ({ page }) => { await page.goto('/home?loginClient=desktop'); await expect(page).toHaveURL(/loginClient=desktop/); From 27c7f6d28adaf775f546fb308d1c14cabe525067 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 22:59:11 +0530 Subject: [PATCH 09/14] refactor --- apps/meteor/server/lib/saml/lib/Utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/server/lib/saml/lib/Utils.ts b/apps/meteor/server/lib/saml/lib/Utils.ts index 4bc9debc95529..2d6114b803d5c 100644 --- a/apps/meteor/server/lib/saml/lib/Utils.ts +++ b/apps/meteor/server/lib/saml/lib/Utils.ts @@ -154,7 +154,7 @@ export class SAMLUtils { return {}; } - if (relayState.includes('provider=')) { + if (relayState.startsWith('provider=') && relayState.includes('&loginClient=')) { const params = new URLSearchParams(relayState); const provider = params.get('provider') ?? undefined; const loginClient = params.get('loginClient'); From 6486b8914b1e0169a8caa600e90c2d456415ad14 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 23:14:17 +0530 Subject: [PATCH 10/14] hide SAML buttons on desktop and mobile --- apps/meteor/server/api/v1/settings.ts | 2 +- packages/web-ui-registration/src/LoginServices.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/server/api/v1/settings.ts b/apps/meteor/server/api/v1/settings.ts index c0f01dbe4e59b..a18408cd7eb74 100644 --- a/apps/meteor/server/api/v1/settings.ts +++ b/apps/meteor/server/api/v1/settings.ts @@ -197,7 +197,7 @@ API.v1.get( return { ...service, hideButtonOnMobile: false }; } - if (service.service && ['saml', 'cas', 'ldap'].includes(service.service)) { + if (service.service && ['cas', 'ldap'].includes(service.service)) { return { ...service, hideButtonOnMobile: false }; } diff --git a/packages/web-ui-registration/src/LoginServices.tsx b/packages/web-ui-registration/src/LoginServices.tsx index 8ac27f035cab2..cb5ce3ab9bb41 100644 --- a/packages/web-ui-registration/src/LoginServices.tsx +++ b/packages/web-ui-registration/src/LoginServices.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import type { LoginErrorState } from './LoginForm'; import LoginServicesButton from './LoginServicesButton'; -const servicesToBeShownOnDesktop = ['saml', 'cas', 'ldap']; +const servicesToBeShownOnDesktop = ['cas', 'ldap']; export type LoginServicesProps = { disabled?: boolean; setError: Dispatch> }; From 58f198acfe5e8f78fa333be48aabfda800983bd3 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Mon, 17 Aug 2026 23:15:46 +0530 Subject: [PATCH 11/14] respect Accounts_OAuth_Use_Modern_Flow setting to enable or disable the deeplink --- apps/meteor/client/meteor/login/saml.ts | 2 +- .../client/views/root/SAMLLoginRoute.spec.tsx | 17 ++++++++++++++++- .../meteor/client/views/root/SAMLLoginRoute.tsx | 7 ++++--- apps/meteor/server/lib/saml/lib/SAML.ts | 2 +- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/meteor/login/saml.ts b/apps/meteor/client/meteor/login/saml.ts index 3a7f4dadcbc23..b530afdee1d36 100644 --- a/apps/meteor/client/meteor/login/saml.ts +++ b/apps/meteor/client/meteor/login/saml.ts @@ -95,7 +95,7 @@ Meteor.loginWithSaml = (options) => { // Forward the loginClient so the session can be deep-linked back to the native client. const loginClient = new URLSearchParams(window.location.search).get('loginClient'); - if (loginClient === 'desktop' || loginClient === 'mobile') { + if (settings.peek('Accounts_OAuth_Use_Modern_Flow') && (loginClient === 'desktop' || loginClient === 'mobile')) { url += `?loginClient=${loginClient}`; } diff --git a/apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx b/apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx index 2049fc97bf745..e1c4abd715ba2 100644 --- a/apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx +++ b/apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx @@ -1,4 +1,4 @@ -import { MockedServerContext, MockedUserContext } from '@rocket.chat/mock-providers'; +import { MockedServerContext, MockedSettingsContext, MockedUserContext } from '@rocket.chat/mock-providers'; import { render } from '@testing-library/react'; import { Meteor } from 'meteor/meteor'; @@ -136,4 +136,19 @@ describe('native client handoff', () => { expect(buildSamlDeepLinkURL).not.toHaveBeenCalled(); expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1); }); + + it('should ignore loginClient and log in normally when the modern flow is disabled', async () => { + render( + + + + + + + , + ); + + expect(buildSamlDeepLinkURL).not.toHaveBeenCalled(); + expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/meteor/client/views/root/SAMLLoginRoute.tsx b/apps/meteor/client/views/root/SAMLLoginRoute.tsx index 45c775b8ab4f8..5ed8347725ab8 100644 --- a/apps/meteor/client/views/root/SAMLLoginRoute.tsx +++ b/apps/meteor/client/views/root/SAMLLoginRoute.tsx @@ -1,4 +1,4 @@ -import { useRouter, useToastMessageDispatch, useSearchParameter } from '@rocket.chat/ui-contexts'; +import { useRouter, useToastMessageDispatch, useSearchParameter, useSetting } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; import { useEffect } from 'react'; @@ -10,12 +10,13 @@ const SAMLLoginRoute = () => { const dispatchToastMessage = useToastMessageDispatch(); const [inviteToken] = useSamlInviteToken(); const loginClient = useSearchParameter('loginClient'); + const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true); useEffect(() => { const { token } = router.getRouteParameters(); //SAML token handoff to the native client (mobile/desktop) - if (loginClient === 'desktop' || loginClient === 'mobile') { + if (enableModernOAuthFlow && (loginClient === 'desktop' || loginClient === 'mobile')) { window.location.href = buildSamlDeepLinkURL(token); const timeout = setTimeout(() => { router.navigate('/home', { replace: true }); @@ -44,7 +45,7 @@ const SAMLLoginRoute = () => { ); } }); - }, [dispatchToastMessage, inviteToken, loginClient, router]); + }, [dispatchToastMessage, enableModernOAuthFlow, inviteToken, loginClient, router]); return null; }; diff --git a/apps/meteor/server/lib/saml/lib/SAML.ts b/apps/meteor/server/lib/saml/lib/SAML.ts index bcb9cf3cbaecd..78b0a0491b4fe 100644 --- a/apps/meteor/server/lib/saml/lib/SAML.ts +++ b/apps/meteor/server/lib/saml/lib/SAML.ts @@ -463,7 +463,7 @@ export class SAML { ): Promise { const serviceProvider = new SAMLServiceProvider(service); let url: string | undefined; - const requestedLoginClient = req.query.loginClient; + const requestedLoginClient = settings.get('Accounts_OAuth_Use_Modern_Flow') ? req.query.loginClient : undefined; const loginClient = requestedLoginClient === 'desktop' || requestedLoginClient === 'mobile' ? requestedLoginClient : undefined; try { From 32609889ce01f8f20c3f2ffedef4c72ba8ec8f7e Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Tue, 18 Aug 2026 18:46:59 +0530 Subject: [PATCH 12/14] fix saml service config return to mobile --- apps/meteor/server/api/v1/settings.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/meteor/server/api/v1/settings.ts b/apps/meteor/server/api/v1/settings.ts index a18408cd7eb74..7d7076e0c2038 100644 --- a/apps/meteor/server/api/v1/settings.ts +++ b/apps/meteor/server/api/v1/settings.ts @@ -201,7 +201,10 @@ API.v1.get( return { ...service, hideButtonOnMobile: false }; } - if ((service as OAuthConfiguration).custom || (service.service && service.service === 'wordpress')) { + if ( + (service as OAuthConfiguration).custom || + (service.service && (service.service === 'wordpress' || service.service === 'saml')) + ) { return { ...service, hideButtonOnMobile: isPassportFlowEnabled }; } From 20e5c60b8ec0330f26ee4d17557eb84cadedf1b9 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Tue, 18 Aug 2026 21:00:18 +0530 Subject: [PATCH 13/14] reuse helper for loginClient --- apps/meteor/server/lib/saml/lib/SAML.ts | 2 +- apps/meteor/server/lib/saml/lib/Utils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/server/lib/saml/lib/SAML.ts b/apps/meteor/server/lib/saml/lib/SAML.ts index 78b0a0491b4fe..d4ccf4b503e0f 100644 --- a/apps/meteor/server/lib/saml/lib/SAML.ts +++ b/apps/meteor/server/lib/saml/lib/SAML.ts @@ -464,7 +464,7 @@ export class SAML { const serviceProvider = new SAMLServiceProvider(service); let url: string | undefined; const requestedLoginClient = settings.get('Accounts_OAuth_Use_Modern_Flow') ? req.query.loginClient : undefined; - const loginClient = requestedLoginClient === 'desktop' || requestedLoginClient === 'mobile' ? requestedLoginClient : undefined; + const loginClient = SAMLUtils.isSupportedLoginClient(requestedLoginClient) ? requestedLoginClient : undefined; try { url = await serviceProvider.getAuthorizeUrl(samlObject.credentialToken, loginClient); diff --git a/apps/meteor/server/lib/saml/lib/Utils.ts b/apps/meteor/server/lib/saml/lib/Utils.ts index 2d6114b803d5c..5e5ded5c28443 100644 --- a/apps/meteor/server/lib/saml/lib/Utils.ts +++ b/apps/meteor/server/lib/saml/lib/Utils.ts @@ -137,7 +137,7 @@ export class SAMLUtils { return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`; } - private static isSupportedLoginClient(value: unknown): value is 'desktop' | 'mobile' { + public static isSupportedLoginClient(value: unknown): value is 'desktop' | 'mobile' { return value === 'desktop' || value === 'mobile'; } From f05b7bd50fb2e680b32ef62a823864d033bd79f0 Mon Sep 17 00:00:00 2001 From: yash-rajpal Date: Tue, 18 Aug 2026 22:32:23 +0530 Subject: [PATCH 14/14] e2e tests setting before e2e --- apps/meteor/tests/e2e/saml.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/meteor/tests/e2e/saml.spec.ts b/apps/meteor/tests/e2e/saml.spec.ts index 2fcde28bd1815..07bff7fd8fac8 100644 --- a/apps/meteor/tests/e2e/saml.spec.ts +++ b/apps/meteor/tests/e2e/saml.spec.ts @@ -823,6 +823,14 @@ test.describe('SAML', () => { } }; + test.beforeAll(async ({ api }) => { + await api.post('/settings/Accounts_OAuth_Use_Modern_Flow', { value: true }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/settings/Accounts_OAuth_Use_Modern_Flow', { value: false }); + }); + test('Hand the credential token to the desktop client without logging in the browser', async ({ page }) => { await page.goto('/home?loginClient=desktop');