Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
8 changes: 8 additions & 0 deletions .changeset/sour-pugs-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@rocket.chat/model-typings': patch
'@rocket.chat/models': patch
'@rocket.chat/meteor': patch
'@rocket.chat/i18n': patch
---

Fixes the messages count displayed on a discussion taking into account system messages which are hidden inside of it, making the count higher than the number of messages actually visible after opening the discussion. The count now excludes every system message type hidden either globally or on the discussion itself. A hint was also added to the `Hide system messages` option of the room edit panel clarifying that the hidden messages are not included in the count.
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 });

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')) {
url += `?loginClient=${loginClient}`;
}

window.location.href = url;
};

const loginWithSamlToken = (credentialToken: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,13 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) =>
control={control}
name='hideSysMes'
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={hideSysMesField} {...field} checked={value} disabled={isFederated} />
<ToggleSwitch
id={hideSysMesField}
{...field}
checked={value}
disabled={isFederated}
aria-describedby={`${hideSysMesField}-hint`}
/>
)}
/>
</FieldRow>
Expand All @@ -490,10 +496,14 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) =>
disabled={!hideSysMes || isFederated}
placeholder={t('Select_messages_to_hide')}
aria-label={t('Select_messages_to_hide')}
aria-describedby={`${hideSysMesField}-hint`}
/>
)}
/>
</FieldRow>
<FieldRow>
<FieldHint id={`${hideSysMesField}-hint`}>{t('Hide_System_Messages_Hint')}</FieldHint>
</FieldRow>
</Field>
)}
</FieldGroup>
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();

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)) {
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 };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ callbacks.add(
projection: {
msgs: 1,
lm: 1,
sysMes: 1,
},
});

Expand All @@ -62,6 +63,7 @@ callbacks.add(
projection: {
msgs: 1,
lm: 1,
sysMes: 1,
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,53 @@
import type { IRoom } from '@rocket.chat/core-typings';
import type { IRoom, MessageTypesValues } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';

import { settings } from '../../../settings/cached';
import { notifyOnMessageChange } from '../../notifyListener';

export const updateAndNotifyParentRoomWithParentMessage = async (room: IRoom): Promise<void> => {
type DiscussionRoom = Pick<IRoom, '_id' | 'msgs' | 'lm' | 'sysMes'>;

/**
* Both the system messages hidden globally and the ones hidden on the room itself are
* filtered out from the room history, so the count has to consider both of them.
* Type `rm` is filtered out because it's already discounted when the message is deleted.
*/
const getHiddenTypesToDiscount = (room: DiscussionRoom): MessageTypesValues[] => {
const globalHiddenTypes = settings.get<MessageTypesValues[]>('Hide_System_Messages');
const globallyHiddenTypes = Array.isArray(globalHiddenTypes) ? globalHiddenTypes : [];
const roomHiddenTypes = Array.isArray(room.sysMes) ? room.sysMes : [];

return [...new Set([...globallyHiddenTypes, ...roomHiddenTypes])]
.flatMap<MessageTypesValues>((type) =>
// `mute_unmute` is a single option covering two different message types
type === 'mute_unmute' ? ['user-muted', 'user-unmuted'] : [type],
)
.filter((type) => type !== 'rm');
};

const getDiscussionMessagesCount = async (room: DiscussionRoom): Promise<number> => {
const hiddenMessageTypes = getHiddenTypesToDiscount(room);

if (!hiddenMessageTypes.length) {
return room.msgs;
}

const hiddenMessagesCount = await Messages.countVisibleByRoomIdContainingTypes(room._id, hiddenMessageTypes);

return Math.max(room.msgs - hiddenMessagesCount, 0);
};

/**
* Copies the current metadata of a discussion (messages count and last message timestamp) to the
* message which links to it on the parent room, and notifies the change to the clients.
*/
export const updateAndNotifyParentRoomWithParentMessage = async (room: DiscussionRoom): Promise<void> => {
room.msgs = await getDiscussionMessagesCount(room);

const parentMessage = await Messages.refreshDiscussionMetadata(room);
if (!parentMessage) {
return;
}

void notifyOnMessageChange({
id: parentMessage._id,
data: parentMessage,
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
Loading
Loading