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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -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),
};
}
}
}

View File

@@ -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'),
},
},
);
}
},
);

View File

@@ -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);
},
);

View File

@@ -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');
});
});

View File

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

View 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', '');
});
});

View 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),
};
}
});