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

window.location.href = url;
};

const loginWithSamlToken = (credentialToken: string) =>
Expand Down
48 changes: 48 additions & 0 deletions apps/meteor/client/views/root/SAMLLoginRoute.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<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);
});
});
18 changes: 16 additions & 2 deletions apps/meteor/client/views/root/SAMLLoginRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
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();
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;
}

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

return () => clearTimeout(timeout);
Comment thread
yash-rajpal marked this conversation as resolved.
Outdated
}, [dispatchToastMessage, inviteToken, loginClient, router]);

return null;
};
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 = req.query.loginClient;
const loginClient = requestedLoginClient === 'desktop' || requestedLoginClient === 'mobile' ? requestedLoginClient : undefined;
Comment thread
yash-rajpal marked this conversation as resolved.
Outdated

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}`;
}

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

if (relayState.includes('provider=')) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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
53 changes: 53 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,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<string, any> } }>('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 }) => {
Comment thread
yash-rajpal marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
31 changes: 31 additions & 0 deletions apps/meteor/tests/unit/server/lib/saml/server.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 }) },
Expand Down
Loading