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/metal-regions-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes an issue where temporary AD/LDAP lockouts would deactivate users on rocket.chat.
5 changes: 5 additions & 0 deletions .changeset/rich-bananas-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
7 changes: 7 additions & 0 deletions .changeset/slick-hats-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/federation-matrix': patch
'@rocket.chat/core-typings': patch
'@rocket.chat/meteor': patch
---

Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
133 changes: 133 additions & 0 deletions apps/meteor/app/apple/lib/handleIdentityToken.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { generateKeyPairSync, sign } from 'node:crypto';

import { serverFetch } from '@rocket.chat/server-fetch';
import { Response } from 'node-fetch';

import { handleIdentityToken } from './handleIdentityToken';

jest.mock('@rocket.chat/server-fetch', () => ({
serverFetch: jest.fn(),
}));

const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
});

const jwkPublicKey = publicKey.export({ format: 'jwk' });

const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');

describe('handleIdentityToken', () => {
const mockClientId = 'com.yourcompany.app';

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z'));
});

afterEach(() => {
jest.useRealTimers();
});

it('should throw an error if the token has the wrong audience', async () => {
const header = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
const payload = toBase64Url({
iss: 'https://appleid.apple.com',
aud: 'wrong.client.id',
exp: Math.floor(Date.now() / 1000) + 3600,
sub: 'user123',
});

const mockToken = `${header}.${payload}.dummySignature`;

await expect(handleIdentityToken(mockToken, mockClientId)).rejects.toThrow('identityToken is not a valid Apple JWT or has expired');
});

it('should successfully validate a valid token', async () => {
const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
const payloadB64 = toBase64Url({
iss: 'https://appleid.apple.com',
aud: mockClientId,
exp: Math.floor(Date.now() / 1000) + 3600,
sub: 'user123',
});

const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey);
const signatureB64 = signatureBytes.toString('base64url');

const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`;

if (!jwkPublicKey.n || !jwkPublicKey.e) {
throw new Error('Generated test key is missing modulus or exponent');
}

const mockJwksPayload = {
keys: [
{
kty: 'RSA',
kid: 'mock-key-id',
use: 'sig',
alg: 'RS256',
n: jwkPublicKey.n,
e: jwkPublicKey.e,
},
],
};

jest.mocked(serverFetch).mockResolvedValue(
new Response(JSON.stringify(mockJwksPayload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);

const result = await handleIdentityToken(validMockToken, mockClientId);

expect(result.id).toBe('user123');
expect(result.iss).toBe('https://appleid.apple.com');
});

it('should accept default mobile audience when client id setting is empty', async () => {
const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
const payloadB64 = toBase64Url({
iss: 'https://appleid.apple.com',
aud: 'chat.rocket.ios',
exp: Math.floor(Date.now() / 1000) + 3600,
sub: 'user123',
});

const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey);
const signatureB64 = signatureBytes.toString('base64url');

const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`;

if (!jwkPublicKey.n || !jwkPublicKey.e) {
throw new Error('Generated test key is missing modulus or exponent');
}

const mockJwksPayload = {
keys: [
{
kty: 'RSA',
kid: 'mock-key-id',
use: 'sig',
alg: 'RS256',
n: jwkPublicKey.n,
e: jwkPublicKey.e,
},
],
};

jest.mocked(serverFetch).mockResolvedValue(
new Response(JSON.stringify(mockJwksPayload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);

const result = await handleIdentityToken(validMockToken, '');

expect(result.id).toBe('user123');
expect(result.aud).toBe('chat.rocket.ios');
});
});
175 changes: 144 additions & 31 deletions apps/meteor/app/apple/lib/handleIdentityToken.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,165 @@
import { createPublicKey, verify } from 'node:crypto';

import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { KJUR } from 'jsrsasign';
import NodeRSA from 'node-rsa';

async function isValidAppleJWT(identityToken: string, header: any): Promise<boolean> {
const request = await fetch('https://appleid.apple.com/auth/keys', {
method: 'GET',
// SECURITY: Hardcoded URL, no SSRF protection needed
ignoreSsrfValidation: true,
});
const applePublicKeys = ((await request.json()) as { keys: { kid: string; e: string; n: string }[] }).keys;
const { kid } = header;
type AppleJWK = {
kty: string;
kid: string;
use: string;
alg: string;
n: string;
e: string;
};

type AppleJWTPayload = {
iss: string;
sub: string;
aud: string | string[];
exp: number;
iat: number;
email?: string;
email_verified?: string | boolean;
is_private_email?: string | boolean;
};

const DEFAULT_APPLE_AUDIENCES = ['chat.rocket.ios'];

let cachedKeys: AppleJWK[] | null = null;
let lastFetchTime = 0;
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours

async function getApplePublicKeys(forceRefresh = false): Promise<AppleJWK[]> {
const now = Date.now();

const key = applePublicKeys.find((k: any) => k.kid === kid);
if (!key) {
return false;
if (!forceRefresh && cachedKeys && now - lastFetchTime < CACHE_TTL_MS) {
return cachedKeys;
}

const pubKey = new NodeRSA();
pubKey.importKey({ n: Buffer.from(key.n, 'base64'), e: Buffer.from(key.e, 'base64') }, 'components-public');
const userKey = pubKey.exportKey('public');
try {
const response = await fetch('https://appleid.apple.com/auth/keys', {
method: 'GET',
// SECURITY: Hardcoded URL, no SSRF protection needed
ignoreSsrfValidation: true,
});

if (!response.ok) {
throw new Error(`Failed to fetch Apple keys: ${response.status} ${response.statusText}`);
}

const data = (await response.json()) as { keys: AppleJWK[] };
cachedKeys = data.keys;
lastFetchTime = now;

return cachedKeys;
} catch (error) {
if (cachedKeys) {
console.warn('Failed to refresh Apple public keys, using stale cache', error);
return cachedKeys;
}
throw new Error('Could not retrieve Apple public keys', { cause: error });
}
}

function decodeBase64Url(str: string): string {
return Buffer.from(str, 'base64url').toString('utf8');
}

async function verifyAppleJWT(
headerB64: string,
payloadB64: string,
signatureB64: string,
clientId: string,
): Promise<AppleJWTPayload | null> {
const header = JSON.parse(decodeBase64Url(headerB64));
const payload = JSON.parse(decodeBase64Url(payloadB64)) as AppleJWTPayload;

const nowInSeconds = Math.floor(Date.now() / 1000);

if (payload.exp < nowInSeconds) {
console.error('Apple JWT has expired');
return null;
}

if (payload.iss !== 'https://appleid.apple.com') {
console.error('Invalid issuer. Expected https://appleid.apple.com');
return null;
}

const audArray = Array.isArray(payload.aud) ? payload.aud : [payload.aud];

const configuredAudiences = clientId
.split(',')
.map((id) => id.trim())
.filter(Boolean);

const allowedAudiences = Array.from(new Set([...DEFAULT_APPLE_AUDIENCES, ...configuredAudiences]));

const isAudienceValid = allowedAudiences.some((allowedAud) => audArray.includes(allowedAud));

if (!isAudienceValid) {
console.error(`Invalid audience. Expected one of: ${allowedAudiences.join(', ')}`);
return null;
}

let applePublicKeys = await getApplePublicKeys();
let keyData = applePublicKeys.find((k) => k.kid === header.kid);

if (!keyData) {
applePublicKeys = await getApplePublicKeys(true); // Force refresh
keyData = applePublicKeys.find((k) => k.kid === header.kid);

if (!keyData) {
console.error('Matching Key ID (kid) not found in Apple JWKS');
return null;
}
}

try {
return KJUR.jws.JWS.verify(identityToken, userKey, ['RS256']);
} catch {
return false;
const publicKey = createPublicKey({
key: {
kty: keyData.kty,
n: keyData.n,
e: keyData.e,
},
format: 'jwk',
});

const isSignatureValid = verify(
'RSA-SHA256',
Buffer.from(`${headerB64}.${payloadB64}`),
publicKey,
Buffer.from(signatureB64, 'base64url'),
);

return isSignatureValid ? payload : null;
} catch (error) {
console.error('Cryptographic signature verification failed:', error);
return null;
}
}

export async function handleIdentityToken(identityToken: string): Promise<{ id: string; email: string; name: string }> {
const decodedToken = KJUR.jws.JWS.parse(identityToken);
export async function handleIdentityToken(identityToken: string, clientId: string): Promise<Record<string, any>> {
const parts = identityToken.split('.');

if (!(await isValidAppleJWT(identityToken, decodedToken.headerObj))) {
throw new Error('identityToken is not a valid JWT');
if (parts.length !== 3) {
throw new Error('Malformed identityToken: JWT must have 3 parts');
}

if (!decodedToken.payloadObj) {
throw new Error('identityToken does not have a payload');
const [headerB64, payloadB64, signatureB64] = parts;

const payload = await verifyAppleJWT(headerB64, payloadB64, signatureB64, clientId);

if (!payload) {
throw new Error('identityToken is not a valid Apple JWT or has expired');
}

const { iss, sub, email } = decodedToken.payloadObj as any;
if (!iss) {
throw new Error('Insufficient data in auth response token');
if (!payload.sub) {
throw new Error('Insufficient data: Missing subject (sub) in auth response token');
}

const serviceData = {
id: sub,
email,
name: '',
id: payload.sub,
...payload,
};

return serviceData;
Expand Down
5 changes: 4 additions & 1 deletion apps/meteor/app/apple/server/AppleCustomOAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MeteorError } from '@rocket.chat/core-services';
import { Accounts } from 'meteor/accounts-base';

import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server';
import { settings } from '../../settings/server';
import { handleIdentityToken } from '../lib/handleIdentityToken';

export class AppleCustomOAuth extends CustomOAuth {
Expand All @@ -16,7 +17,9 @@ export class AppleCustomOAuth extends CustomOAuth {
}

try {
const serviceData = await handleIdentityToken(identityToken);
const clientId = settings.get<string>('Accounts_OAuth_Apple_id') || '';

const serviceData = await handleIdentityToken(identityToken, clientId);

if (usrObj?.name) {
serviceData.name = `${usrObj.name.firstName}${usrObj.name.middleName ? ` ${usrObj.name.middleName}` : ''}${
Expand Down
Loading
Loading