snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
import { settings } from '../../../settings';
|
||||
import { CustomOAuth } from '../custom-oauth/custom_oauth_server';
|
||||
|
||||
export class AppleCustomOAuth extends CustomOAuth {
|
||||
override async getIdentity(_accessToken: string, query: Record<string, any>): Promise<any> {
|
||||
const { id_token: identityToken, user: userStr = '' } = query;
|
||||
|
||||
let usrObj = {} as any;
|
||||
try {
|
||||
usrObj = JSON.parse(userStr);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
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}` : ''}${
|
||||
usrObj.name.lastName ? ` ${usrObj.name.lastName}` : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
return serviceData;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
type: 'apple',
|
||||
error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createPrivateKey, sign } from 'node:crypto';
|
||||
|
||||
import { ServiceConfiguration } from 'meteor/service-configuration';
|
||||
|
||||
import { AppleCustomOAuth } from './AppleCustomOAuth';
|
||||
import { config } from '../../../../app/apple/lib/config';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
new AppleCustomOAuth('apple', config);
|
||||
|
||||
const toBase64Url = (obj: Record<string, any>) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
|
||||
function generateAppleClientSecret(header: Record<string, any>, payload: Record<string, any>, privateKeyString: string): string {
|
||||
const headerB64 = toBase64Url(header);
|
||||
const payloadB64 = toBase64Url(payload);
|
||||
const dataToSign = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const privateKey = createPrivateKey({
|
||||
key: privateKeyString,
|
||||
format: 'pem',
|
||||
type: 'pkcs8',
|
||||
});
|
||||
|
||||
const signature = sign('sha256', Buffer.from(dataToSign), {
|
||||
key: privateKey,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
|
||||
const signatureB64 = signature.toString('base64url');
|
||||
|
||||
return `${dataToSign}.${signatureB64}`;
|
||||
}
|
||||
|
||||
settings.watchMultiple(
|
||||
[
|
||||
'Accounts_OAuth_Apple',
|
||||
'Accounts_OAuth_Apple_id',
|
||||
'Accounts_OAuth_Apple_secretKey',
|
||||
'Accounts_OAuth_Apple_iss',
|
||||
'Accounts_OAuth_Apple_kid',
|
||||
],
|
||||
async ([enabled, clientId, serverSecret, iss, kid]) => {
|
||||
if (!enabled) {
|
||||
return ServiceConfiguration.configurations.removeAsync({
|
||||
service: 'apple',
|
||||
});
|
||||
}
|
||||
|
||||
const [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid] = [clientId, serverSecret, iss, kid].map((value) =>
|
||||
typeof value === 'string' ? value.trim() : '',
|
||||
);
|
||||
|
||||
const hasAllFields = [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid].every(Boolean);
|
||||
|
||||
// Hide web button if settings are incomplete, but preserve mobile-only setup if enabled.
|
||||
if (!hasAllFields) {
|
||||
await ServiceConfiguration.configurations.upsertAsync(
|
||||
{
|
||||
service: 'apple',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showButton: false,
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const HEADER = {
|
||||
kid: normalizedKid,
|
||||
alg: 'ES256',
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const exp = new Date();
|
||||
exp.setMonth(exp.getMonth() + 5);
|
||||
|
||||
try {
|
||||
const secret = generateAppleClientSecret(
|
||||
HEADER,
|
||||
{
|
||||
iss: normalizedIss,
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
exp: Math.floor(exp.getTime() / 1000),
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: normalizedClientId,
|
||||
},
|
||||
normalizedServerSecret,
|
||||
);
|
||||
|
||||
await ServiceConfiguration.configurations.upsertAsync(
|
||||
{
|
||||
service: 'apple',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showButton: true,
|
||||
secret,
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
loginStyle: 'popup',
|
||||
clientId: normalizedClientId,
|
||||
buttonColor: '#000',
|
||||
buttonLabelColor: '#FFF',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to configure Apple OAuth service', error);
|
||||
|
||||
await ServiceConfiguration.configurations.upsertAsync(
|
||||
{
|
||||
service: 'apple',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showButton: false,
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,178 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import express from 'express';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { ServiceConfiguration } from 'meteor/service-configuration';
|
||||
import passport from 'passport';
|
||||
import { Strategy as AppleStrategy } from 'passport-apple';
|
||||
import type { Profile } from 'passport-apple';
|
||||
|
||||
import { AppleCustomOAuth } from './AppleCustomOAuth';
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
import { config } from '../../../../app/apple/lib/config';
|
||||
import { oAuthRouter } from '../../../configuration/configurePassport';
|
||||
import { settings } from '../../../settings';
|
||||
import { allowPassportOAuthMiddleware } from '../../oauth/allowPassportOAuthMiddleware';
|
||||
import { passportOAuthCallback } from '../../oauth/passportOAuthCallback';
|
||||
import { removeOAuthRoutes } from '../../oauth/removeOAuthRoutes';
|
||||
|
||||
new AppleCustomOAuth('apple', config);
|
||||
|
||||
type RequestWithAppleProfile = {
|
||||
appleProfile?: {
|
||||
name?: {
|
||||
firstName?: string;
|
||||
middleName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function isRequestWithAppleProfile(req: object): req is RequestWithAppleProfile {
|
||||
if (
|
||||
'appleProfile' in req &&
|
||||
req.appleProfile &&
|
||||
typeof req.appleProfile === 'object' &&
|
||||
'name' in req.appleProfile &&
|
||||
req.appleProfile.name &&
|
||||
typeof req.appleProfile.name === 'object'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.watchMultiple(
|
||||
[
|
||||
'Accounts_OAuth_Apple',
|
||||
'Accounts_OAuth_Apple_id',
|
||||
'Accounts_OAuth_Apple_secretKey',
|
||||
'Accounts_OAuth_Apple_iss',
|
||||
'Accounts_OAuth_Apple_kid',
|
||||
'Accounts_OAuth_Use_Modern_Flow',
|
||||
],
|
||||
async ([enabled, clientId, serverSecret, iss, kid, useModernFlow]) => {
|
||||
passport.unuse('apple');
|
||||
removeOAuthRoutes('apple');
|
||||
|
||||
if (!useModernFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return ServiceConfiguration.configurations.removeAsync({
|
||||
service: 'apple',
|
||||
});
|
||||
}
|
||||
|
||||
// if everything is empty but Apple login is enabled, don't show the login button
|
||||
if (!clientId && !serverSecret && !iss && !kid) {
|
||||
await ServiceConfiguration.configurations.upsertAsync(
|
||||
{
|
||||
service: 'apple',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showButton: false,
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof clientId !== 'string' || !clientId) {
|
||||
return ServiceConfiguration.configurations.removeAsync({
|
||||
service: 'apple',
|
||||
});
|
||||
}
|
||||
|
||||
passport.use(
|
||||
'apple',
|
||||
|
||||
new AppleStrategy(
|
||||
{
|
||||
clientID: clientId,
|
||||
teamID: settings.get<string>('Accounts_OAuth_Apple_iss'),
|
||||
keyID: settings.get<string>('Accounts_OAuth_Apple_kid'),
|
||||
privateKeyString: settings.get<string>('Accounts_OAuth_Apple_secretKey').replace(/\\n/g, '\n'),
|
||||
callbackURL: `${settings.get<string>('Site_Url').replace(/\/$/, '')}/_oauth/apple`,
|
||||
scope: ['name', 'email'],
|
||||
passReqToCallback: true,
|
||||
state: false,
|
||||
},
|
||||
async (req, accessToken: string, refreshToken: string, idToken: string, _profile: Profile, done) => {
|
||||
try {
|
||||
const serviceData = await handleIdentityToken(idToken, clientId);
|
||||
|
||||
if (isRequestWithAppleProfile(req)) {
|
||||
const appleName = req.appleProfile?.name;
|
||||
serviceData.name = [appleName?.firstName, appleName?.middleName, appleName?.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const user = await Accounts.updateOrCreateUserFromExternalService(
|
||||
'apple',
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
...serviceData,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
if (!user?.userId || typeof user?.userId !== 'string') {
|
||||
return done(new Error('User not found'));
|
||||
}
|
||||
|
||||
const userFromDB = await Users.findOneById(user.userId);
|
||||
|
||||
if (!userFromDB) {
|
||||
return done(new Error('User not found'));
|
||||
}
|
||||
|
||||
return done(null, userFromDB);
|
||||
} catch (error: any) {
|
||||
done(error);
|
||||
return {
|
||||
type: 'apple',
|
||||
error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message),
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const callbackHandler = [
|
||||
allowPassportOAuthMiddleware('apple'),
|
||||
express.urlencoded({ extended: true }),
|
||||
passport.authenticate('apple', { failWithError: true, session: true, keepSessionInfo: true }),
|
||||
passportOAuthCallback(settings.get<string>('Site_Url').replace(/\/$/, '')),
|
||||
];
|
||||
|
||||
oAuthRouter.get(
|
||||
'/oauth/apple',
|
||||
allowPassportOAuthMiddleware('apple'),
|
||||
(req, _res, next) => {
|
||||
const { loginClient } = req.query;
|
||||
if (loginClient === 'mobile' || loginClient === 'desktop') {
|
||||
req.session.loginClient = loginClient;
|
||||
req.session.save(() => {
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
//delete stale value from previous sessions if any
|
||||
delete req.session.loginClient;
|
||||
next();
|
||||
}
|
||||
},
|
||||
passport.authenticate('apple', {
|
||||
scope: ['name', 'email'],
|
||||
}),
|
||||
);
|
||||
|
||||
oAuthRouter
|
||||
.route('/_oauth/apple')
|
||||
.post(...callbackHandler)
|
||||
.get(...callbackHandler);
|
||||
},
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createPublicKey, verify } from 'node:crypto';
|
||||
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
|
||||
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();
|
||||
|
||||
if (!forceRefresh && cachedKeys && now - lastFetchTime < CACHE_TTL_MS) {
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
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 {
|
||||
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, clientId: string): Promise<Record<string, any>> {
|
||||
const parts = identityToken.split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Malformed identityToken: JWT must have 3 parts');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (!payload.sub) {
|
||||
throw new Error('Insufficient data: Missing subject (sub) in auth response token');
|
||||
}
|
||||
|
||||
const serviceData = {
|
||||
id: payload.sub,
|
||||
...payload,
|
||||
};
|
||||
|
||||
return serviceData;
|
||||
}
|
||||
124
apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts
Normal file
124
apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
jest.mock(
|
||||
'meteor/accounts-base',
|
||||
() => ({
|
||||
Accounts: {
|
||||
registerLoginHandler: jest.fn(),
|
||||
updateOrCreateUserFromExternalService: jest.fn(),
|
||||
LoginCancelledError: { numericError: 400 },
|
||||
},
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'meteor/meteor',
|
||||
() => ({
|
||||
Meteor: {
|
||||
Error: class extends Error {
|
||||
constructor(
|
||||
public error: number,
|
||||
public reason: string,
|
||||
) {
|
||||
super(reason);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
jest.mock('../../../settings', () => ({
|
||||
settings: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./handleIdentityToken', () => ({
|
||||
handleIdentityToken: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Apple OAuth loginHandler', () => {
|
||||
let loginHandlerCallback: Parameters<typeof Accounts.registerLoginHandler>[1];
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('./loginHandler');
|
||||
loginHandlerCallback = jest.mocked(Accounts.registerLoginHandler).mock.calls[0][1];
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(settings.get).mockImplementation((key) => {
|
||||
if (key === 'Accounts_OAuth_Apple') return true;
|
||||
if (key === 'Accounts_OAuth_Apple_id') return 'com.yourcompany.app';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('should not use the client-provided email if Apple does not provide one', async () => {
|
||||
jest.mocked(handleIdentityToken).mockResolvedValue({
|
||||
id: 'apple-sub-123',
|
||||
});
|
||||
|
||||
const maliciousLoginRequest = {
|
||||
identityToken: 'valid.token.without_email',
|
||||
email: 'alice@email.tld',
|
||||
fullName: { givenName: 'Alice', familyName: 'Sender' },
|
||||
};
|
||||
|
||||
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'new-user-id' });
|
||||
|
||||
await loginHandlerCallback(maliciousLoginRequest);
|
||||
|
||||
expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith(
|
||||
'apple',
|
||||
{ id: 'apple-sub-123' },
|
||||
{ profile: { name: 'Alice Sender' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully pass the email if Apple natively provides it in the signed JWT', async () => {
|
||||
jest.mocked(handleIdentityToken).mockResolvedValue({
|
||||
id: 'apple-sub-123',
|
||||
email: 'legit@email.tld',
|
||||
});
|
||||
|
||||
const legitLoginRequest = {
|
||||
identityToken: 'valid.token.with_email',
|
||||
fullName: { givenName: 'John', familyName: 'Doe' },
|
||||
};
|
||||
|
||||
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' });
|
||||
|
||||
await loginHandlerCallback(legitLoginRequest);
|
||||
|
||||
expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith(
|
||||
'apple',
|
||||
{ id: 'apple-sub-123', email: 'legit@email.tld' },
|
||||
{ profile: { name: 'John Doe' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass empty client id to token validation when setting is not configured', async () => {
|
||||
jest.mocked(settings.get).mockImplementation((key) => {
|
||||
if (key === 'Accounts_OAuth_Apple') return true;
|
||||
if (key === 'Accounts_OAuth_Apple_id') return '';
|
||||
return null;
|
||||
});
|
||||
|
||||
jest.mocked(handleIdentityToken).mockResolvedValue({ id: 'apple-sub-123' });
|
||||
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' });
|
||||
|
||||
await loginHandlerCallback({
|
||||
identityToken: 'valid.token.with_mobile_default_audience',
|
||||
fullName: { givenName: 'Mobile', familyName: 'User' },
|
||||
});
|
||||
|
||||
expect(handleIdentityToken).toHaveBeenCalledWith('valid.token.with_mobile_default_audience', '');
|
||||
});
|
||||
});
|
||||
46
apps/meteor/server/lib/auth-providers/apple/loginHandler.ts
Normal file
46
apps/meteor/server/lib/auth-providers/apple/loginHandler.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
Accounts.registerLoginHandler('apple', async (loginRequest) => {
|
||||
if (!loginRequest.identityToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.get('Accounts_OAuth_Apple')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { identityToken, fullName } = loginRequest;
|
||||
|
||||
try {
|
||||
const clientId = settings.get<string>('Accounts_OAuth_Apple_id') || '';
|
||||
|
||||
const serviceData = await handleIdentityToken(identityToken, clientId);
|
||||
|
||||
const profile: { name?: string } = {};
|
||||
|
||||
const { givenName, familyName } = fullName || {};
|
||||
if (givenName && familyName) {
|
||||
profile.name = `${givenName} ${familyName}`;
|
||||
}
|
||||
|
||||
const result = await Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile });
|
||||
|
||||
if (result?.userId === undefined) {
|
||||
return {
|
||||
type: 'apple',
|
||||
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
type: 'apple',
|
||||
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, error.message),
|
||||
};
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user