Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/cute-pumas-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': minor
---

Adds support for SAML authentication in the mobile and desktop apps via the system browser.
7 changes: 7 additions & 0 deletions apps/meteor/client/lib/buildAuthDeeplinkURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment thread
yash-rajpal marked this conversation as resolved.

return `rocketchat://auth?${params.toString()}`;
};
10 changes: 9 additions & 1 deletion apps/meteor/client/meteor/login/saml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (settings.peek('Accounts_OAuth_Use_Modern_Flow') && (loginClient === 'desktop' || loginClient === 'mobile')) {
Comment thread
yash-rajpal marked this conversation as resolved.
url += `?loginClient=${loginClient}`;
}

window.location.href = url;
};

const loginWithSamlToken = (credentialToken: string) =>
Expand Down
65 changes: 64 additions & 1 deletion apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -89,3 +89,66 @@ 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(
<MockedServerContext>
<RouterContextMock routeParameters={{ token: 'testToken' }} searchParameters={{ loginClient }} navigate={navigateStub}>
<SAMLLoginRoute />
</RouterContextMock>
</MockedServerContext>,
);

// The browser must never authenticate: the app redeems the credential on its own connection.
expect(Meteor.loginWithSamlToken).not.toHaveBeenCalled();
expect(navigateStub).not.toHaveBeenCalled();
Comment thread
yash-rajpal marked this conversation as resolved.

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(
<MockedServerContext>
<RouterContextMock routeParameters={{ token: 'testToken' }} searchParameters={{ loginClient: 'web' }} navigate={navigateStub}>
<SAMLLoginRoute />
</RouterContextMock>
</MockedServerContext>,
);

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(
<MockedServerContext>
<MockedSettingsContext settings={{ Accounts_OAuth_Use_Modern_Flow: false }}>
<RouterContextMock routeParameters={{ token: 'testToken' }} searchParameters={{ loginClient: 'desktop' }} navigate={navigateStub}>
<SAMLLoginRoute />
</RouterContextMock>
</MockedSettingsContext>
</MockedServerContext>,
);

expect(buildSamlDeepLinkURL).not.toHaveBeenCalled();
expect(Meteor.loginWithSamlToken).toHaveBeenCalledTimes(1);
});
});
16 changes: 14 additions & 2 deletions apps/meteor/client/views/root/SAMLLoginRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { useRouter, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useRouter, useToastMessageDispatch, useSearchParameter, useSetting } 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');
const enableModernOAuthFlow = useSetting('Accounts_OAuth_Use_Modern_Flow', true);

useEffect(() => {
const { token } = router.getRouteParameters();

//SAML token handoff to the native client (mobile/desktop)
if (enableModernOAuthFlow && (loginClient === 'desktop' || loginClient === 'mobile')) {
window.location.href = buildSamlDeepLinkURL(token);
const timeout = setTimeout(() => {
router.navigate('/home', { replace: true });
}, 0);
return () => clearTimeout(timeout);
}

Meteor.loginWithSamlToken(token, (error?: unknown) => {
if (error) {
dispatchToastMessage({ type: 'error', message: error });
Expand All @@ -33,7 +45,7 @@ const SAMLLoginRoute = () => {
);
}
});
}, [dispatchToastMessage, inviteToken, router]);
}, [dispatchToastMessage, enableModernOAuthFlow, inviteToken, loginClient, router]);

return null;
};
Expand Down
7 changes: 5 additions & 2 deletions apps/meteor/server/api/v1/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,14 @@ API.v1.get(
return { ...service, hideButtonOnMobile: false };
}

if (service.service && ['saml', 'cas', 'ldap'].includes(service.service)) {
if (service.service && ['cas', 'ldap'].includes(service.service)) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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'))
Comment thread
yash-rajpal marked this conversation as resolved.
) {
return { ...service, hideButtonOnMobile: isPassportFlowEnabled };
}

Expand Down
20 changes: 15 additions & 5 deletions apps/meteor/server/lib/saml/lib/SAML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 });
}

// sending token along with the userId
return {
userId: user._id,
Expand Down Expand Up @@ -457,15 +456,18 @@ export class SAML {
}

private static async processAuthorizeAction(
req: IIncomingMessage,
res: ServerResponse,
service: IServiceProviderOptions,
samlObject: ISAMLAction,
): Promise<void> {
const serviceProvider = new SAMLServiceProvider(service);
let url: string | undefined;
const requestedLoginClient = settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow') ? req.query.loginClient : undefined;
const loginClient = SAMLUtils.isSupportedLoginClient(requestedLoginClient) ? 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();
Expand Down Expand Up @@ -498,7 +500,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) {
Expand Down Expand Up @@ -534,7 +538,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 });
Expand Down
8 changes: 4 additions & 4 deletions apps/meteor/server/lib/saml/lib/ServiceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
public async requestToUrl(request: string, operation: string, loginClient?: string): Promise<string | undefined> {
const buffer = await util.promisify(zlib.deflateRaw)(request);
try {
const base64 = buffer.toString('base64');
Expand All @@ -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({
Expand All @@ -165,11 +165,11 @@ export class SAMLServiceProvider {
}
}

public async getAuthorizeUrl(credentialToken: string): Promise<string | undefined> {
public async getAuthorizeUrl(credentialToken: string, loginClient?: string): Promise<string | undefined> {
const request = this.generateAuthorizeRequest(credentialToken);
SAMLUtils.log({ request, msg: 'getAuthorizeUrl' });

return this.requestToUrl(request, 'authorize');
return this.requestToUrl(request, 'authorize', loginClient);
}

public async validateLogoutRequest(
Expand Down
31 changes: 31 additions & 0 deletions apps/meteor/server/lib/saml/lib/Utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,37 @@ export class SAMLUtils {
return `saml/${credentialToken}?saml_idp_credentialToken=${credentialToken}`;
}

public 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' } {
if (!relayState) {
return {};
}

if (relayState.startsWith('provider=') && relayState.includes('&loginClient=')) {
Comment thread
yash-rajpal marked this conversation as resolved.
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 };
}
Comment thread
yash-rajpal marked this conversation as resolved.

public static log(obj: object | string): void {
if (debug && logger) {
logger.debug(obj);
Expand Down
61 changes: 61 additions & 0 deletions apps/meteor/tests/e2e/saml.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,67 @@ 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<string, any> } }>('rocketchat_credential_tokens')
.findOne({ _id: credentialToken });
} finally {
await connection.close();
}
};

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 });
});
Comment thread
yash-rajpal marked this conversation as resolved.

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/);

// 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
});
Expand Down
Loading
Loading