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

View File

@@ -0,0 +1,424 @@
import type { IUser } from '@rocket.chat/core-typings';
import { cronJobs } from '@rocket.chat/cron';
import { Users } from '@rocket.chat/models';
import { SHA256 } from '@rocket.chat/sha256';
import AtlassianCrowd from 'atlassian-crowd-patched';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import { logger } from './logger';
import { settings } from '../../../settings';
import { crowdIntervalValuesToCronMap } from '../../../settings/crowd';
import { notifyOnUserChange, notifyOnUserChangeById, notifyOnUserChangeAsync } from '../../notifyListener';
import { deleteUser } from '../../users/deleteUser';
import { setRealName } from '../../users/setRealName';
import { setUserActiveStatus } from '../../users/setUserActiveStatus';
type CrowdUser = Pick<IUser, '_id' | 'username'> & { crowd: Record<string, any>; crowd_username: string };
function fallbackDefaultAccountSystem(bind: typeof Accounts, username: string | Record<string, any>, password: string) {
if (typeof username === 'string') {
if (username.indexOf('@') === -1) {
username = { username };
} else {
username = { email: username };
}
}
logger.info({ msg: 'Fallback to default account system', username });
const loginRequest = {
user: username,
password: {
digest: SHA256(password),
algorithm: 'sha-256',
},
};
return Accounts._runLoginHandlers(bind, loginRequest);
}
export class CROWD {
private crowdClient: any;
private options: {
crowd: {
base: string;
};
application: {
name: string;
password: string;
};
rejectUnauthorized: boolean;
};
constructor() {
let url = settings.get<string>('CROWD_URL');
this.options = {
crowd: {
base: !/\/$/.test(url) ? (url += '/') : url,
},
application: {
name: settings.get('CROWD_APP_USERNAME'),
password: settings.get('CROWD_APP_PASSWORD'),
},
rejectUnauthorized: settings.get('CROWD_Reject_Unauthorized'),
};
this.crowdClient = new AtlassianCrowd(this.options);
}
async checkConnection(): Promise<void> {
return new Promise((resolve, reject) =>
this.crowdClient.ping((err: any) => {
if (err) {
reject(err);
}
resolve();
}),
);
}
async fetchCrowdUser(crowdUsername: string): Promise<Record<string, any>> {
return new Promise((resolve, reject) =>
this.crowdClient.user.find(crowdUsername, (err: any, userResponse: Record<string, any>) => {
if (err) {
reject(err);
}
resolve({
displayname: userResponse['display-name'],
username: userResponse.name,
email: userResponse.email,
active: userResponse.active,
crowd_username: crowdUsername,
});
}),
);
}
async searchForCrowdUserByMail(email?: string): Promise<Record<string, any> | undefined> {
return new Promise((resolve) =>
this.crowdClient.search('user', `email=" ${email} "`, (err: any, response: Record<string, any>) => {
if (err) {
resolve(undefined);
}
resolve(response);
}),
);
}
async authenticate(username: string, password: string): Promise<Record<string, any> | undefined> {
if (!username || !password) {
logger.error('No username or password');
return;
}
const projection = { username: 1, crowd_username: 1, crowd: 1 };
logger.info('Extracting crowd_username');
let user = null;
let crowdUsername = username;
if (username.indexOf('@') !== -1) {
const email = username;
user = await Users.findOne<CrowdUser>({ 'emails.address': email }, { projection });
if (user) {
crowdUsername = user.crowd_username;
} else {
logger.debug({ msg: 'Could not find a user by email', username });
}
}
if (user == null) {
user = await Users.findOne<CrowdUser>({ username }, { projection });
if (user) {
crowdUsername = user.crowd_username;
} else {
logger.debug('Could not find a user by username');
}
}
if (user == null) {
user = await Users.findOne<CrowdUser>({ crowd_username: username }, { projection });
if (user) {
crowdUsername = user.crowd_username;
} else {
logger.debug({ msg: 'Could not find a user with by crowd_username', username });
}
}
if (user && !crowdUsername) {
logger.debug('Local user found, redirecting to fallback login');
return {
crowd: false,
};
}
if (!user && crowdUsername) {
logger.debug('New user. User is not synced yet.');
}
logger.debug({ msg: 'Going to crowd', crowdUsername });
return new Promise((resolve, reject) =>
this.crowdClient.user.authenticate(crowdUsername, password, async (err: any, res: Record<string, any>) => {
if (err) {
reject(err);
}
const user = res;
try {
const crowdUser: Record<string, any> = await this.fetchCrowdUser(crowdUsername);
if (user && settings.get('CROWD_Allow_Custom_Username') === true) {
crowdUser.username = user.name;
}
if (user) {
crowdUser._id = user._id;
}
crowdUser.password = password;
resolve(crowdUser);
} catch (err) {
reject(err);
}
}),
);
}
async syncDataToUser(crowdUser: Record<string, any>, id: string) {
const user = {
username: this.cleanUsername(crowdUser.username),
crowd_username: crowdUser.crowd_username,
emails: [
{
address: crowdUser.email,
verified: settings.get<boolean>('Accounts_Verify_Email_For_External_Accounts'),
},
],
crowd: true,
};
if (crowdUser.password) {
await Accounts.setPasswordAsync(id, crowdUser.password, {
logout: false,
});
await Users.unsetRequirePasswordChange(id);
}
if (crowdUser.displayname) {
await setRealName(id, crowdUser.displayname);
}
await Users.updateOne(
{ _id: id },
{
$set: user,
},
);
void notifyOnUserChange({
clientAction: 'updated',
id,
diff: {
...user,
...(crowdUser.displayname && { name: crowdUser.displayname }),
},
});
await setUserActiveStatus(id, crowdUser.active);
}
async sync() {
// if crowd is disabled bail out
if (settings.get('CROWD_Enable') !== true) {
return;
}
const users =
((await Users.findCrowdUsers().toArray()) as unknown as (IUser & { crowd: Record<string, any>; crowd_username: string })[]) || [];
logger.info('Sync started...');
for (const user of users) {
let crowdUsername = user.hasOwnProperty('crowd_username') ? user.crowd_username : user.username;
logger.info({ msg: 'Syncing user', crowdUsername });
if (!crowdUsername) {
logger.warn({ msg: 'User has no crowd_username', username: user.username });
continue;
}
let crowdUser = null;
try {
crowdUser = await this.fetchCrowdUser(crowdUsername);
} catch (err) {
logger.debug({ msg: 'Error while syncing user from CROWD', err });
logger.error({ msg: 'Could not sync user with username', crowd_username: crowdUsername });
const email = user.emails?.[0].address;
logger.info({ msg: 'Attempting to find user by email', email });
const response = await this.searchForCrowdUserByMail(email);
if (!response || response.users.length === 0) {
logger.warn({ msg: 'Could not find user in CROWD with username or email', crowd_username: crowdUsername, email });
if (settings.get('CROWD_Remove_Orphaned_Users') === true) {
logger.info({ msg: 'Removing user', crowd_username: crowdUsername });
setImmediate(async () => {
await deleteUser(user._id);
logger.info({ msg: 'User removed', crowd_username: crowdUsername });
});
}
return;
}
crowdUsername = response.users[0].name;
logger.info({ msg: 'User found by email. Syncing user', crowd_username: crowdUsername });
if (!crowdUsername) {
logger.warn({ msg: 'User has no crowd_username', username: user.username });
continue;
}
crowdUser = await this.fetchCrowdUser(crowdUsername);
}
if (settings.get('CROWD_Allow_Custom_Username') === true) {
crowdUser.username = user.username;
}
await this.syncDataToUser(crowdUser, user._id);
}
}
cleanUsername(username: string) {
if (settings.get('CROWD_Clean_Usernames') === true) {
return username.split('@')[0];
}
return username;
}
async updateUserCollection(crowdUser: Record<string, any>) {
const username = crowdUser.crowd_username || crowdUser.username;
const mail = crowdUser.email;
// If id is not provided, user is linked by crowd_username or email address
const userQuery = {
...(crowdUser._id && { _id: crowdUser._id }),
...(!crowdUser._id && {
$or: [{ crowd_username: username }, { 'emails.address': mail }],
}),
};
// find our existing user if they exist
const user = await Users.findOne(userQuery);
if (user) {
const stampedToken = Accounts._generateStampedLoginToken();
await Users.updateOne(
{ _id: user._id },
{
$push: {
'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken),
},
},
);
// TODO this can be optmized so places that care about loginTokens being removed are invoked directly
// instead of having to listen to every watch.users event
void notifyOnUserChangeAsync(async () => {
const userTokens = await Users.findOneById(crowdUser._id, { projection: { 'services.resume.loginTokens': 1 } });
if (!userTokens) {
return;
}
return {
clientAction: 'updated',
id: crowdUser._id,
diff: { 'services.resume.loginTokens': userTokens.services?.resume?.loginTokens },
};
});
await this.syncDataToUser(crowdUser, user._id);
return {
userId: user._id,
token: stampedToken.token,
};
}
// Attempt to create the new user
try {
crowdUser._id = await Accounts.createUserAsync(crowdUser);
void notifyOnUserChangeById({ clientAction: 'inserted', id: crowdUser._id });
// sync the user data
await this.syncDataToUser(crowdUser, crowdUser._id);
return {
userId: crowdUser._id,
};
} catch (err) {
logger.error({ msg: 'Error creating new crowd user.', err });
}
}
}
Accounts.registerLoginHandler('crowd', async function (this: typeof Accounts, loginRequest) {
if (!loginRequest.crowd) {
return undefined;
}
logger.info({ msg: 'Init CROWD login', username: loginRequest.username });
if (settings.get('CROWD_Enable') !== true) {
return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.crowdPassword);
}
try {
const crowd = new CROWD();
const user = await crowd.authenticate(loginRequest.username, loginRequest.crowdPassword);
if (user?.crowd === false) {
logger.debug({ msg: 'User is not a valid crowd user, falling back', username: loginRequest.username });
return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.crowdPassword);
}
if (!user) {
logger.debug({ msg: 'User is not allowed to access Rocket.Chat', username: loginRequest.username });
return new Meteor.Error('not-authorized', 'User is not authorized by crowd');
}
const result = await crowd.updateUserCollection(user);
return result;
} catch (err: any) {
logger.error({ msg: 'Crowd user not authenticated due to an error', err });
throw new Meteor.Error('user-not-found', err.message);
}
});
const jobName = 'CROWD_Sync';
Meteor.startup(() => {
settings.watchMultiple(['CROWD_Sync_User_Data', 'CROWD_Sync_Interval'], async function addCronJobDebounced([data, interval]) {
if (data !== true) {
logger.info('Disabling CROWD Background Sync');
if (await cronJobs.has(jobName)) {
await cronJobs.remove(jobName);
}
return;
}
const crowd = new CROWD();
if (interval) {
if (await cronJobs.has(jobName)) {
await cronJobs.remove(jobName);
}
logger.info('Enabling CROWD Background Sync');
const cronInterval = crowdIntervalValuesToCronMap[String(interval)];
await cronJobs.add(jobName, cronInterval, () => crowd.sync());
}
});
});

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('CROWD');

View File

@@ -0,0 +1,7 @@
# Custom OAuth
An implementation of the OAuth2 flow. It works with your own private OAuth2 server instance.
This software is supplied courtesy of the [Rocket.Chat](https://rocket.chat/) open source communications platform.
See the [project page](https://www.meteor.com/accounts) on Meteor Accounts for more details.

View File

@@ -0,0 +1,403 @@
import { LDAP } from '@rocket.chat/core-services';
import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { isAbsoluteURL } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Meteor } from 'meteor/meteor';
import type { DoneCallback } from 'passport';
import type { VerifyFunction, StrategyOptions } from 'passport-oauth2';
import { Strategy } from 'passport-oauth2';
import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers';
import { client } from '../../../database/utils';
import { settings } from '../../../settings/cached';
import { callbacks } from '../../callbacks';
import { notifyOnUserChange } from '../../notifyListener';
import { saveUserIdentity } from '../../users/saveUserIdentity';
const logger = new Logger('CustomOAuth');
const BeforeUpdateOrCreateUserFromExternalService = new Map<
string,
(serviceName: string, serviceData: Record<string, any>) => Promise<void>
>();
const CustomOAuthInstances = new Map<string, CustomOAuthStrategy>();
export class CustomOAuthStrategy extends Strategy {
options: StrategyOptions;
config: OAuthConfiguration & { clientSecret: string };
identityPath: string;
emailPath: string;
tokenSentVia: string;
identityTokenSentVia: string;
keyField: string;
usernameField: string;
emailField: string;
nameField: string;
avatarField: string;
mergeUsers: boolean;
mergeUsersDistinctServices: boolean;
rolesClaim: string;
accessTokenParam: string;
serverURL: string;
tokenPath: string;
constructor(name: string, config: OAuthConfiguration & { clientSecret: string }, verify: VerifyFunction) {
if (!config.serverURL || typeof config.serverURL !== 'string') {
throw new Meteor.Error('customOAuth: serverURL is required and must be String');
}
const options: StrategyOptions = {
authorizationURL: config.serverURL + config.authorizePath,
tokenURL: config.serverURL + config.tokenPath,
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${settings.get<string>('Site_Url').replace(/\/$/, '')}/_oauth/${name}`,
state: true,
pkce: config.pkce ?? true,
scope: config.scope,
};
super(options, verify);
this.serverURL = config.serverURL;
this.tokenPath = config.tokenPath || '/oauth/token';
this.identityPath = config.identityPath || '/me';
this.tokenSentVia = config.tokenSentVia;
this.identityTokenSentVia = config.identityTokenSentVia;
this.keyField = config.keyField;
this.usernameField = config.usernameField?.trim();
this.emailField = config.emailField?.trim();
this.nameField = config.nameField?.trim();
this.avatarField = config.avatarField?.trim();
this.mergeUsers = !!config.mergeUsers;
this.mergeUsersDistinctServices = !!config.mergeUsersDistinctServices;
this.rolesClaim = config.rolesClaim || 'roles';
this.accessTokenParam = config.accessTokenParam || 'access_token';
if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') {
this.identityTokenSentVia = this.tokenSentVia;
}
if (!isAbsoluteURL(this.tokenPath)) {
this.tokenPath = this.serverURL + this.tokenPath;
}
if (!isAbsoluteURL(this.identityPath)) {
this.identityPath = this.serverURL + this.identityPath;
}
if (this.emailPath && !isAbsoluteURL(this.emailPath)) {
this.emailPath = this.serverURL + this.emailPath;
}
if (this.identityTokenSentVia === 'header') {
this._oauth2.useAuthorizationHeaderforGET(true);
}
if (config.accessTokenParam && config.accessTokenParam !== 'access_token') {
this._oauth2.setAccessTokenName(config.accessTokenParam);
}
if (config.addAutopublishFields && typeof config.addAutopublishFields === 'object') {
Accounts.addAutopublishFields(config.addAutopublishFields);
}
this.name = name;
this.options = options;
this.config = config;
this.addHookToProcessUser();
}
getUsername(data: Record<string, any>) {
try {
const value = fromTemplate(this.usernameField, data);
if (!value) {
logger.debug({ msg: 'Username field not found in data', usernameField: this.usernameField, data });
throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract username', { cause: error });
}
}
getEmail(data: Record<string, any>) {
try {
const value = fromTemplate(this.emailField, data);
if (!value) {
logger.debug({ msg: 'Email field not found in data', emailField: this.emailField, data });
throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract email', { cause: error });
}
}
getCustomName(data: Record<string, any>) {
try {
const value = fromTemplate(this.nameField, data);
if (!value) {
return this.getName(data);
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract custom name', { cause: error });
}
}
getAvatarUrl(data: Record<string, any>) {
try {
const value = fromTemplate(this.avatarField, data);
if (!value) {
logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data });
}
return value as string;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract avatar url', { cause: error });
}
}
getName(identity: Record<string, any>): string {
const name = (identity.name ||
identity.username ||
identity.nickname ||
identity.CharacterName ||
identity.userName ||
identity.preferred_username ||
identity.user?.name) as string;
return name;
}
normalizeIdentity(identity: Record<string, any>) {
if (identity) {
for (const normalizer of Object.values(normalizers)) {
const result = normalizer(identity);
if (result) {
identity = result;
}
}
}
if (this.usernameField) {
identity.username = this.getUsername(identity);
}
if (this.emailField) {
identity.email = this.getEmail(identity);
}
if (this.avatarField) {
identity.avatarUrl = this.getAvatarUrl(identity);
}
if (this.nameField) {
identity.name = this.getCustomName(identity);
} else {
identity.name = this.getName(identity);
}
return renameInvalidProperties(identity);
}
override userProfile(accessToken: string, done: DoneCallback) {
if (!this.identityPath) {
return done(new Error(`identityPath is required for ${this.name} custom oauth`));
}
this._oauth2.get(this.identityPath, accessToken, (err, body, res) => {
if (err) {
return done(JSON.stringify(err));
}
if ((res && res.statusCode !== 200) || !body) {
return done(new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}}`));
}
try {
const result = JSON.parse(typeof body === 'string' ? body : body.toString());
const normalizedIdentity = this.normalizeIdentity(result);
//Nextcloud URL needed on addWebdavServer
normalizedIdentity.serverURL = this.serverURL;
return done(null, normalizedIdentity);
} catch (e) {
return done(new Error(`Failed to parse identity from ${this.name} at ${this.identityPath}. ${e}`));
}
});
}
addHookToProcessUser() {
BeforeUpdateOrCreateUserFromExternalService.set(this.name, async (serviceName, serviceData) => {
if (serviceName !== this.name) {
return;
}
if (serviceData.username) {
let user: IUser | null = null;
if (this.keyField === 'username') {
user = this.mergeUsersDistinctServices
? await Users.findOneByUsernameIgnoringCase(serviceData.username)
: await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName);
} else if (this.keyField === 'email') {
user = this.mergeUsersDistinctServices
? await Users.findOneByEmailAddress(serviceData.email)
: await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName);
}
if (!user) {
return;
}
await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user });
// User already created or merged and has identical name as before
if (
user.services?.[serviceName as keyof NonNullable<IUser['services']>] &&
user.services[serviceName as keyof NonNullable<IUser['services']>].id === serviceData.id &&
user.name === serviceData.name &&
(this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email))
) {
return;
}
if (this.mergeUsers !== true) {
throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`);
}
const serviceIdKey = `services.${serviceName}.id`;
const successCallbacks = [
async () => {
const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } });
if (updatedUser) {
const { _id, ...diff } = updatedUser;
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff });
}
},
];
const session = client.startSession();
try {
// Extend the session to match the ExtendedSession type expected by saveUserIdentity
Object.assign(session, {
onceSuccesfulCommit: (cb: () => Promise<void>) => {
successCallbacks.push(cb);
},
});
session.startTransaction();
const updater = Users.getUpdater();
if (this.keyField === 'username' && serviceData.email) {
updater.set('emails', [{ address: serviceData.email, verified: true }]);
}
updater.set(serviceIdKey as keyof IUser['services'], serviceData.id);
await saveUserIdentity({
_id: user._id,
name: serviceData.name,
updater,
session,
updateUsernameInBackground: true,
// Username needs to be included otherwise the name won't be updated in some collections
username: user.username,
});
await Users.updateFromUpdater({ _id: user._id }, updater, { session });
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
await session.endSession();
}
void Promise.allSettled(successCallbacks.map((cb) => cb()));
}
});
CustomOAuthInstances.set(this.name, this);
}
}
Accounts.validateNewUser((user: IUser & { email: string }) => {
for (const [name, instance] of CustomOAuthInstances) {
const service = user.services?.[name as keyof NonNullable<IUser['services']>];
if (!service?.id) {
continue;
}
if (instance.usernameField) {
user.username = service.username;
}
if (instance.emailField) {
user.email = service.email;
}
if (instance.nameField) {
user.name = service.name;
}
}
return true;
});
const { updateOrCreateUserFromExternalService } = Accounts;
Accounts.updateOrCreateUserFromExternalService = async function (...args) {
for (const hook of BeforeUpdateOrCreateUserFromExternalService.values()) {
await hook.apply(this, args as unknown as [string, Record<string, any>]);
}
const [serviceName, serviceData] = args;
const user = await updateOrCreateUserFromExternalService.apply(this, args);
if (!user?.userId) {
return undefined;
}
const fullUser = await Users.findOneById(user.userId as string);
if (!fullUser) {
return undefined;
}
if (settings.get('LDAP_Update_Data_On_OAuth_Login') && fullUser.username) {
await LDAP.loginAuthenticatedUserRequest(fullUser.username);
}
await callbacks.run('afterValidateNewOAuthUser', {
identity: serviceData,
serviceName,
user: fullUser,
});
return user;
};

View File

@@ -0,0 +1,7 @@
export class CustomOAuth {
constructor(name: string, options: Record<string, any>);
getIdentity(accessToken: string, query: Record<string, any>): any;
configure(options: Record<string, any>): any;
}

View File

@@ -0,0 +1,554 @@
import { LDAP } from '@rocket.chat/core-services';
import { Logger } from '@rocket.chat/logger';
import { Users } from '@rocket.chat/models';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { isAbsoluteURL } from '@rocket.chat/tools';
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
import { ServiceConfiguration } from 'meteor/service-configuration';
import _ from 'underscore';
import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers';
import { client } from '../../../database/utils';
import { settings } from '../../../settings';
import { callbacks } from '../../callbacks';
import { notifyOnUserChange } from '../../notifyListener';
import { saveUserIdentity } from '../../users/saveUserIdentity';
import { registerAccessTokenService } from '../oauth/oauth';
const logger = new Logger('CustomOAuth', { redact: ['options.clientSecret'] });
const Services = {};
const BeforeUpdateOrCreateUserFromExternalService = [];
/**
* @deprecated in favor of new Passport OAuth implementation.
*/
export class CustomOAuth {
constructor(name, options) {
logger.debug({ msg: 'Init CustomOAuth', name, options });
this.name = name;
if (!Match.test(this.name, String)) {
throw new Meteor.Error('CustomOAuth: Name is required and must be String');
}
if (Services[this.name]) {
Services[this.name].configure(options);
return;
}
Services[this.name] = this;
this.configure(options);
this.userAgent = 'Meteor';
if (Meteor.release) {
this.userAgent += `/${Meteor.release}`;
}
Accounts.oauth.registerService(this.name);
this.registerService();
this.addHookToProcessUser();
this.registerAccessTokenService(this.name, this.accessTokenParam);
}
configure(options) {
if (!Match.test(options, Object)) {
throw new Meteor.Error('CustomOAuth: Options is required and must be Object');
}
if (!Match.test(options.serverURL, String)) {
throw new Meteor.Error('CustomOAuth: Options.serverURL is required and must be String');
}
if (!Match.test(options.tokenPath, String)) {
options.tokenPath = '/oauth/token';
}
if (!Match.test(options.identityPath, String)) {
options.identityPath = '/me';
}
if (!Match.test(options.accessTokenParam, String)) {
options.accessTokenParam = 'access_token';
}
this.serverURL = options.serverURL;
this.tokenPath = options.tokenPath;
this.identityPath = options.identityPath;
this.emailPath = options.emailPath;
this.tokenSentVia = options.tokenSentVia;
this.identityTokenSentVia = options.identityTokenSentVia;
this.keyField = options.keyField;
this.usernameField = (options.usernameField || '').trim();
this.emailField = (options.emailField || '').trim();
this.nameField = (options.nameField || '').trim();
this.avatarField = (options.avatarField || '').trim();
this.mergeUsers = options.mergeUsers;
this.mergeUsersDistinctServices = options.mergeUsersDistinctServices;
this.rolesClaim = options.rolesClaim || 'roles';
this.accessTokenParam = options.accessTokenParam;
this.channelsAdmin = options.channelsAdmin || 'rocket.cat';
if (this.identityTokenSentVia == null || this.identityTokenSentVia === 'default') {
this.identityTokenSentVia = this.tokenSentVia;
}
if (!isAbsoluteURL(this.tokenPath)) {
this.tokenPath = this.serverURL + this.tokenPath;
}
if (!isAbsoluteURL(this.identityPath)) {
this.identityPath = this.serverURL + this.identityPath;
}
if (this.emailPath && !isAbsoluteURL(this.emailPath)) {
this.emailPath = this.serverURL + this.emailPath;
}
if (Match.test(options.addAutopublishFields, Object)) {
Accounts.addAutopublishFields(options.addAutopublishFields);
}
}
async getAccessToken(query) {
const config = await ServiceConfiguration.configurations.findOneAsync({ service: this.name });
if (!config) {
throw new Accounts.ConfigError();
}
let response = undefined;
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user
'Accept': 'application/json',
};
const params = new URLSearchParams({
code: query.code,
redirect_uri: OAuth._redirectUri(this.name, config),
grant_type: 'authorization_code',
state: query.state,
});
// Only send clientID / secret once on header or payload.
if (this.tokenSentVia === 'header') {
const b64 = Buffer.from(`${config.clientId}:${OAuth.openSecret(config.secret)}`).toString('base64');
headers.Authorization = `Basic ${b64}`;
} else {
params.append('client_secret', config.secret);
params.append('client_id', config.clientId);
}
try {
const request = await fetch(`${this.tokenPath}`, {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
ignoreSsrfValidation: true,
method: 'POST',
headers,
body: params,
});
if (!request.ok) {
throw new Error(request.statusText);
}
response = await request.json();
} catch (err) {
const error = new Error(`Failed to complete OAuth handshake with ${this.name} at ${this.tokenPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
if (response.error) {
// if the http response was a json object with an error attribute
throw new Error(`Failed to complete OAuth handshake with ${this.name} at ${this.tokenPath}. ${response.error}`);
} else {
return response;
}
}
async getIdentity(accessToken) {
const params = {};
const headers = {
'User-Agent': this.userAgent, // http://doc.gitlab.com/ce/api/users.html#Current-user
'Accept': 'application/json',
};
if (this.identityTokenSentVia === 'header') {
headers.Authorization = `Bearer ${accessToken}`;
} else {
params[this.accessTokenParam] = accessToken;
}
try {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
const request = await fetch(`${this.identityPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true });
if (!request.ok) {
throw new Error(request.statusText);
}
const response = await request.json();
logger.debug({ msg: 'Identity response', response });
return this.normalizeIdentity(response, accessToken);
} catch (err) {
const error = new Error(`Failed to fetch identity from ${this.name} at ${this.identityPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
}
registerService() {
const self = this;
OAuth.registerService(this.name, 2, null, async (query) => {
const response = await self.getAccessToken(query);
const identity = await self.getIdentity(response.access_token, query);
const serviceData = {
_OAuthCustom: true,
serverURL: self.serverURL,
accessToken: response.access_token,
idToken: response.id_token,
expiresAt: +new Date() + 1000 * parseInt(response.expires_in, 10),
};
// only set the token in serviceData if it's there. this ensures
// that we don't lose old ones (since we only get this on the first
// log in attempt)
if (response.refresh_token) {
serviceData.refreshToken = response.refresh_token;
}
_.extend(serviceData, identity);
const data = {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
return data;
});
}
async normalizeIdentity(identity, accessToken) {
if (identity) {
for (const normalizer of Object.values(normalizers)) {
const result = normalizer(identity);
if (result) {
identity = result;
}
}
}
if (this.usernameField) {
identity.username = this.getUsername(identity);
}
if (this.emailField) {
identity.email = this.getEmail(identity);
}
if (!identity.email && this.emailPath) {
identity.email = await this.getEmailFromPath(accessToken);
}
if (this.avatarField) {
identity.avatarUrl = this.getAvatarUrl(identity);
}
if (this.nameField) {
identity.name = this.getCustomName(identity);
} else {
identity.name = this.getName(identity);
}
return renameInvalidProperties(identity);
}
async getEmailFromPath(accessToken) {
if (!this.emailPath) {
throw new Meteor.Error('CustomOAuth: emailPath is required');
}
const params = {};
const headers = {
'User-Agent': this.userAgent,
'Accept': 'application/json',
};
if (this.identityTokenSentVia === 'header') {
headers.Authorization = `Bearer ${accessToken}`;
} else {
params[this.accessTokenParam] = accessToken;
}
try {
// SECURITY: URL can only be configured by users with enough privileges. It's ok to disable this check here.
const request = await fetch(`${this.emailPath}`, { method: 'GET', headers, params, ignoreSsrfValidation: true });
if (!request.ok) {
throw new Error(request.statusText);
}
const response = await request.json();
return response.find((email) => email.primary === true)?.email;
} catch (err) {
const error = new Error(`Failed to fetch emails from ${this.name} at ${this.emailPath}. ${err.message}`);
throw _.extend(error, { response: err.response });
}
}
retrieveCredential(credentialToken, credentialSecret) {
return OAuth.retrieveCredential(credentialToken, credentialSecret);
}
getUsername(data) {
try {
const value = fromTemplate(this.usernameField, data);
if (!value) {
throw new Meteor.Error('field_not_found', `Username field "${this.usernameField}" not found in data`, data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract username', error.message);
}
}
getEmail(data) {
try {
const value = fromTemplate(this.emailField, data);
if (!value) {
throw new Meteor.Error('field_not_found', `Email field "${this.emailField}" not found in data`, data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract email', error.message);
}
}
getCustomName(data) {
try {
const value = fromTemplate(this.nameField, data);
if (!value) {
return this.getName(data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract custom name', error.message);
}
}
getAvatarUrl(data) {
try {
const value = fromTemplate(this.avatarField, data);
if (!value) {
logger.debug({ msg: 'Avatar field not found in data', avatarField: this.avatarField, data });
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract avatar url', error.message);
}
}
getName(identity) {
const name =
identity.name ||
identity.username ||
identity.nickname ||
identity.CharacterName ||
identity.userName ||
identity.preferred_username ||
(identity.user && identity.user.name);
return name;
}
addHookToProcessUser() {
BeforeUpdateOrCreateUserFromExternalService.push(async (serviceName, serviceData /* , options*/) => {
if (serviceName !== this.name) {
return;
}
if (serviceData.username) {
let user = undefined;
if (this.keyField === 'username') {
user = this.mergeUsersDistinctServices
? await Users.findOneByUsernameIgnoringCase(serviceData.username)
: await Users.findOneByUsernameAndServiceNameIgnoringCase(serviceData.username, serviceData.id, serviceName);
} else if (this.keyField === 'email') {
user = this.mergeUsersDistinctServices
? await Users.findOneByEmailAddress(serviceData.email)
: await Users.findOneByEmailAddressAndServiceNameIgnoringCase(serviceData.email, serviceData.id, serviceName);
}
if (!user) {
return;
}
await callbacks.run('afterProcessOAuthUser', { serviceName, serviceData, user });
// User already created or merged and has identical name as before
if (
user.services &&
user.services[serviceName] &&
user.services[serviceName].id === serviceData.id &&
user.name === serviceData.name &&
(this.keyField === 'email' || !serviceData.email || user.emails?.find(({ address }) => address === serviceData.email))
) {
return;
}
if (this.mergeUsers !== true) {
throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`);
}
const serviceIdKey = `services.${serviceName}.id`;
const successCallbacks = [
async () => {
const updatedUser = await Users.findOneById(user._id, { projection: { name: 1, emails: 1, [serviceIdKey]: 1 } });
if (updatedUser) {
const { _id, ...diff } = updatedUser;
void notifyOnUserChange({ clientAction: 'updated', id: user._id, diff });
}
},
];
const session = client.startSession();
try {
// Extend the session to match the ExtendedSession type expected by saveUserIdentity
Object.assign(session, {
onceSuccesfulCommit: (cb) => {
successCallbacks.push(cb);
},
});
session.startTransaction();
const updater = Users.getUpdater();
if (this.keyField === 'username' && serviceData.email) {
updater.set('emails', [{ address: serviceData.email, verified: true }]);
}
updater.set(serviceIdKey, serviceData.id);
await saveUserIdentity({
_id: user._id,
name: serviceData.name,
updater,
session,
updateUsernameInBackground: true,
// Username needs to be included otherwise the name won't be updated in some collections
username: user.username,
});
await Users.updateFromUpdater({ _id: user._id }, updater, { session });
await session.commitTransaction();
} catch (e) {
await session.abortTransaction();
throw e;
} finally {
await session.endSession();
}
void Promise.allSettled(successCallbacks.map((cb) => cb()));
}
});
Accounts.validateNewUser((user) => {
if (!user.services || !user.services[this.name] || !user.services[this.name].id) {
return true;
}
if (this.usernameField) {
user.username = user.services[this.name].username;
}
if (this.emailField) {
user.email = user.services[this.name].email;
}
if (this.nameField) {
user.name = user.services[this.name].name;
}
return true;
});
}
registerAccessTokenService(name) {
const self = this;
const whitelisted = ['id', 'email', 'username', 'name', this.rolesClaim];
registerAccessTokenService(name, async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
expiresIn: Match.Integer,
}),
);
const identity = await self.getIdentity(options.accessToken);
const serviceData = {
accessToken: options.accessToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
};
const fields = _.pick(identity, whitelisted);
_.extend(serviceData, fields);
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});
}
}
const { updateOrCreateUserFromExternalService } = Accounts;
Accounts.updateOrCreateUserFromExternalService = async function (...args /* serviceName, serviceData, options*/) {
for (const hook of BeforeUpdateOrCreateUserFromExternalService) {
await hook.apply(this, args);
}
const [serviceName, serviceData] = args;
const user = await updateOrCreateUserFromExternalService.apply(this, args);
if (!user.userId) {
return undefined;
}
const fullUser = await Users.findOneById(user.userId);
if (settings.get('LDAP_Update_Data_On_OAuth_Login')) {
await LDAP.loginAuthenticatedUserRequest(fullUser.username);
}
await callbacks.run('afterValidateNewOAuthUser', {
identity: serviceData,
serviceName,
user: fullUser,
});
return user;
};

View File

@@ -0,0 +1,160 @@
import { isObject } from '../../../../lib/utils/isObject';
export const normalizers = {
// Set 'id' to '_id' for any sources that provide it
_id(identity) {
if (identity._id && !identity.id) {
identity.id = identity._id;
}
},
// Fix for Reddit
redit(identity) {
if (identity.result) {
return identity.result;
}
},
// Fix WordPress-like identities having 'ID' instead of 'id'
wordpress(identity) {
if (identity.ID && !identity.id) {
identity.id = identity.ID;
}
},
// Fix Auth0-like identities having 'user_id' instead of 'id'
user_id(identity) {
if (identity.user_id && !identity.id) {
identity.id = identity.user_id;
}
},
characterid(identity) {
if (identity.CharacterID && !identity.id) {
identity.id = identity.CharacterID;
}
},
// Fix Dataporten having 'user.userid' instead of 'id'
dataporten(identity) {
if (identity.user && identity.user.userid && !identity.id) {
if (identity.user.userid_sec && identity.user.userid_sec[0]) {
identity.id = identity.user.userid_sec[0];
} else {
identity.id = identity.user.userid;
}
identity.email = identity.user.email;
}
},
// Fix for Xenforo [BD]API plugin for 'user.user_id; instead of 'id'
xenforo(identity) {
if (identity.user && identity.user.user_id && !identity.id) {
identity.id = identity.user.user_id;
identity.email = identity.user.user_email;
}
},
// Fix general 'phid' instead of 'id' from phabricator
phabricator(identity) {
if (identity.phid && !identity.id) {
identity.id = identity.phid;
}
},
// Fix Keycloak-like identities having 'sub' instead of 'id'
kaycloak(identity) {
if (identity.sub && !identity.id) {
identity.id = identity.sub;
}
},
// Fix OpenShift identities where id is in 'metadata' object
openshift(identity) {
if (!identity.id && identity.metadata && identity.metadata.uid) {
identity.id = identity.metadata.uid;
identity.name = identity.fullName;
}
},
// Fix general 'userid' instead of 'id' from provider
userid(identity) {
if (identity.userid && !identity.id) {
identity.id = identity.userid;
}
},
// Fix Nextcloud provider
nextcloud(identity) {
if (!identity.id && identity.ocs && identity.ocs.data && identity.ocs.data.id) {
identity.id = identity.ocs.data.id;
identity.name = identity.ocs.data.displayname;
identity.email = identity.ocs.data.email;
}
},
// Fix when authenticating from a meteor app with 'emails' field
meteor(identity) {
if (!identity.email && identity.emails && Array.isArray(identity.emails) && identity.emails.length >= 1) {
identity.email = identity.emails[0].address ? identity.emails[0].address : undefined;
}
},
};
const IDENTITY_PROPNAME_FILTER = /(\.)/g;
export const renameInvalidProperties = (input) => {
if (Array.isArray(input)) {
return input.map(renameInvalidProperties);
}
if (!isObject(input)) {
return input;
}
return Object.entries(input).reduce(
(result, [name, value]) => ({
...result,
[name.replace(IDENTITY_PROPNAME_FILTER, '_')]: renameInvalidProperties(value),
}),
{},
);
};
export const getNestedValue = (propertyPath, source) =>
propertyPath.split('.').reduce((prev, curr) => (prev ? prev[curr] : undefined), source);
// /^(.+)@/::email
const REGEXP_FROM_FORMULA = /^\/((?!\/::).*)\/::(.+)/;
export const getRegexpMatch = (formula, data) => {
const regexAndPath = REGEXP_FROM_FORMULA.exec(formula);
if (!regexAndPath) {
return getNestedValue(formula, data);
}
if (regexAndPath.length !== 3) {
throw new Error(`expected array of length 3, got ${regexAndPath.length}`);
}
const [, regexString, path] = regexAndPath;
const nestedValue = getNestedValue(path, data);
const regex = new RegExp(regexString);
const matches = regex.exec(nestedValue);
// regexp does not match nested value
if (!matches) {
return undefined;
}
// we only support regular expressions with a single capture group
const [, value] = matches;
// this could mean we return `undefined` (e.g. when capture group is empty)
return value;
};
const templateStringRegex = /{{((?:(?!}}).)+)}}/g;
export const fromTemplate = (template, data) => {
if (!templateStringRegex.test(template)) {
return getNestedValue(template, data);
}
return template.replace(templateStringRegex, (fullMatch, match) => getRegexpMatch(match, data));
};

View File

@@ -0,0 +1,91 @@
import type { IUser, OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { ServiceConfiguration } from 'meteor/service-configuration';
import passport from 'passport';
import _ from 'underscore';
import { callbacks } from '../callbacks';
import { beforeCreateUserCallback } from '../callbacks/beforeCreateUserCallback';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
const config: Partial<OAuthConfiguration> = {
serverURL: '',
authorizePath: '/m/oauth2/auth/',
tokenPath: '/m/oauth2/token/',
identityPath: '/m/oauth2/api/me/',
scope: 'basic',
addAutopublishFields: {
forLoggedInUser: ['services.dolphin'],
forOtherUsers: ['services.dolphin.name'],
},
accessTokenParam: 'access_token',
};
const Dolphin = new CustomOAuth('dolphin', config);
function DolphinOnCreateUser(options: any, user?: IUser) {
// TODO: callbacks Fix this
if (user?.services?.dolphin?.NickName) {
user.username = user.services.dolphin.NickName;
}
return options;
}
const configureDolphinOAuth = () => {
passport.unuse('dolphin');
const enabled = settings.get<boolean>('Accounts_OAuth_Dolphin');
if (!enabled) {
return;
}
const serverURL = settings.get<string>('Accounts_OAuth_Dolphin_URL').trim().replace(/\/*$/, '');
const clientId = settings.get<string>('Accounts_OAuth_Dolphin_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Dolphin_secret');
if (!clientId || !clientSecret || !serverURL) {
return;
}
const completeConfig = { ...config, serverURL, clientId, clientSecret };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth('dolphin', completeConfig);
return;
}
Dolphin.configure(completeConfig);
};
Meteor.startup(async () => {
const updateConfig = _.debounce(configureDolphinOAuth, 300);
settings.watchMultiple(
[
'Accounts_OAuth_Dolphin',
'Accounts_OAuth_Dolphin_URL',
'Accounts_OAuth_Dolphin_id',
'Accounts_OAuth_Dolphin_secret',
'Accounts_OAuth_Use_Modern_Flow',
],
updateConfig,
);
if (settings.get('Accounts_OAuth_Dolphin_URL')) {
const data = {
buttonLabelText: settings.get<string>('Accounts_OAuth_Dolphin_button_label_text'),
buttonColor: settings.get<string>('Accounts_OAuth_Dolphin_button_color'),
buttonLabelColor: settings.get<string>('Accounts_OAuth_Dolphin_button_label_color'),
clientId: settings.get<string>('Accounts_OAuth_Dolphin_id'),
secret: settings.get<string>('Accounts_OAuth_Dolphin_secret'),
serverURL: settings.get<string>('Accounts_OAuth_Dolphin_URL'),
loginStyle: settings.get<string>('Accounts_OAuth_Dolphin_login_style'),
};
await ServiceConfiguration.configurations.upsertAsync({ service: 'dolphin' }, { $set: data });
}
beforeCreateUserCallback.add(DolphinOnCreateUser, callbacks.priority.HIGH, 'dolphin');
});

View File

@@ -0,0 +1,23 @@
#Drupal oAuth Integration module.
This module works in conjunction with the [Rocket.Chat+ Module for Drupal](https://www.drupal.org/project/rocket_chat)
Version 7.x-1.1 or later.
A full set of instructions for how to connect the 2 are present in the drupal module's documentation.
Basically to connect the 2 you first setup the oAuth server connection in your drupal, with the proper permissions
("Use OAuth2 Server" => "Anonymous User" = Checked).
In the Rocket chat you have to do the following:
- fill in the 'Client ID'.
Bear in mind that the Client ID should not be guessable,but is seen in the URL when doing the login.
- fill in the 'Client Secret'.
This should be treated as a Secret Key (like the Secret Key of a TLS certificate). it __must not__ be guesable or
derivable, and is best a Alphanumerical sequence between 16 and 48 cahracters long (longer would be better but longer
than 48 characters can be problem with long URI's)
- fill in the Drupal's BaseURL.
- on the Drupal use the "Restrict redirect URIs" Setting to limit possible exploits. and set the Redirect URI's to
whatever is in the Callback URL (like `https://Rocketchat.example.com/_oauth/drupal` and possibly also the
`https://Rocketchat.example.com/_oauth/drupal?close` URI.).
- Lastly do not forget to Enable the Drupal OAuth and `SAVE CHANGES`.
When all is a Blue Button with a drupal like logo will apear on the login page of Rocket.Chat+

View File

@@ -0,0 +1,66 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import _ from 'underscore';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
const config: Partial<OAuthConfiguration> = {
serverURL: '',
identityPath: '/oauth2/UserInfo',
authorizePath: '/oauth2/authorize',
tokenPath: '/oauth2/token',
scope: 'openid email profile offline_access',
tokenSentVia: 'payload',
usernameField: 'preferred_username',
mergeUsers: true,
addAutopublishFields: {
forLoggedInUser: ['services.drupal'],
forOtherUsers: ['services.drupal.name'],
},
accessTokenParam: 'access_token',
};
const Drupal = new CustomOAuth('drupal', config);
const configureDrupalOAuth = () => {
passport.unuse('drupal');
const enabled = settings.get<boolean>('Accounts_OAuth_Drupal');
if (!enabled) {
return;
}
const serverURL = settings.get<string>('API_Drupal_URL').trim().replace(/\/*$/, '');
const clientId = settings.get<string>('Accounts_OAuth_Drupal_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Drupal_secret');
if (!clientId || !clientSecret || !serverURL) {
return;
}
const completeConfig = { ...config, serverURL, clientId, clientSecret };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth('drupal', completeConfig);
return;
}
Drupal.configure(completeConfig);
};
Meteor.startup(() => {
const updateConfig = _.debounce(configureDrupalOAuth, 300);
settings.watchMultiple(
[
'Accounts_OAuth_Drupal',
'API_Drupal_URL',
'Accounts_OAuth_Drupal_id',
'Accounts_OAuth_Drupal_secret',
'Accounts_OAuth_Use_Modern_Flow',
],
updateConfig,
);
});

View File

@@ -0,0 +1,28 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
// GitHub Enterprise Server CallBack URL needs to be http(s)://{rocketchat.server}[:port]/_oauth/github_enterprise
// In RocketChat -> Administration the URL needs to be http(s)://{github.enterprise.server}/
const config: OauthConfig = {
serverURL: '',
identityPath: '/api/v3/user',
authorizePath: '/login/oauth/authorize',
tokenPath: '/login/oauth/access_token',
addAutopublishFields: {
forLoggedInUser: ['services.github-enterprise'],
forOtherUsers: ['services.github-enterprise.username'],
},
};
const GitHubEnterprise = new CustomOAuth('github_enterprise', config);
Meteor.startup(() => {
settings.watch<string>('API_GitHub_Enterprise_URL', (value) => {
config.serverURL = value;
GitHubEnterprise.configure(config);
});
});

View File

@@ -0,0 +1,20 @@
import type { OauthConfig } from '@rocket.chat/core-typings';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
const config: OauthConfig = {
serverURL: 'https://github.com',
identityPath: 'https://api.github.com/user',
tokenPath: 'https://github.com/login/oauth/access_token',
emailPath: 'https://api.github.com/user/emails',
scope: 'user:email',
mergeUsers: false,
addAutopublishFields: {
forLoggedInUser: ['services.github'],
forOtherUsers: ['services.github.username'],
},
accessTokenParam: 'access_token',
identityTokenSentVia: 'header',
};
export const Github = new CustomOAuth('github', config);

View File

@@ -0,0 +1,69 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import _ from 'underscore';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
const config: Partial<OAuthConfiguration> = {
serverURL: 'https://gitlab.com',
identityPath: '/api/v4/user',
authorizePath: '/oauth/authorize',
tokenPath: '/oauth/token',
scope: 'read_user',
mergeUsers: false,
addAutopublishFields: {
forLoggedInUser: ['services.gitlab'],
forOtherUsers: ['services.gitlab.username'],
},
accessTokenParam: 'access_token',
};
const Gitlab = new CustomOAuth('gitlab', config);
const configureGitlabOAuth = () => {
passport.unuse('gitlab');
const enabled = settings.get<boolean>('Accounts_OAuth_Gitlab');
if (!enabled) {
return;
}
const clientId = settings.get<string>('Accounts_OAuth_Gitlab_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Gitlab_secret');
const serverURL = settings.get<string>('API_Gitlab_URL').trim().replace(/\/*$/, '') || config.serverURL;
const identityPath = settings.get<string>('Accounts_OAuth_Gitlab_identity_path') || config.identityPath;
const mergeUsers = Boolean(settings.get<boolean>('Accounts_OAuth_Gitlab_merge_users'));
if (!clientId || !clientSecret) {
return;
}
const completeConfig = { ...config, clientId, clientSecret, serverURL, identityPath, mergeUsers };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth('gitlab', completeConfig);
return;
}
Gitlab.configure(completeConfig);
};
Meteor.startup(() => {
const updateConfig = _.debounce(configureGitlabOAuth, 300);
settings.watchMultiple(
[
'Accounts_OAuth_Gitlab',
'API_Gitlab_URL',
'Accounts_OAuth_Gitlab_id',
'Accounts_OAuth_Gitlab_secret',
'Accounts_OAuth_Gitlab_identity_path',
'Accounts_OAuth_Gitlab_merge_users',
'Accounts_OAuth_Use_Modern_Flow',
],
updateConfig,
);
});

View File

@@ -0,0 +1,106 @@
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
// The code on this file was copied directly from Meteor and modified to support mobile google oauth
// https://github.com/meteor/meteor/blob/ffcfa5062cf1bf8a64ea64fef681ffcd99fe7939/packages/oauth/oauth_server.js
Meteor.startup(() => {
const appRedirectUrl = 'rocketchat://auth';
const renderEndOfLoginResponse = async (options) => {
const escape = (s) => {
if (!s) {
return s;
}
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\'/g, '&quot;')
.replace(/\'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
};
const config = {
setCredentialToken: !!options.setCredentialToken,
credentialToken: escape(options.credentialToken),
credentialSecret: escape(options.credentialSecret),
storagePrefix: escape(OAuth._storageTokenPrefix),
redirectUrl: escape(options.redirectUrl),
isCordova: Boolean(options.isCordova),
};
let template;
if (options.loginStyle === 'popup') {
template = await OAuth._endOfPopupResponseTemplate();
} else if (options.loginStyle === 'redirect') {
template = await OAuth._endOfRedirectResponseTemplate();
} else {
throw new Error(`invalid loginStyle: ${options.loginStyle}`);
}
const result = template
.replace(/##CONFIG##/, JSON.stringify(config))
.replace(/##ROOT_URL_PATH_PREFIX##/, __meteor_runtime_config__.ROOT_URL_PATH_PREFIX);
return `<!DOCTYPE html>\n${result}`;
};
OAuth._endOfLoginResponse = async (res, details) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
let redirectUrl;
if (details.loginStyle === 'redirect') {
redirectUrl = OAuth._stateFromQuery(details.query).redirectUrl;
const appHost = Meteor.absoluteUrl();
if (redirectUrl.startsWith(appRedirectUrl)) {
redirectUrl = `${appRedirectUrl}?host=${appHost}&type=oauth`;
if (details.error) {
const error = encodeURIComponent(details.error.toString());
redirectUrl = `${redirectUrl}&error=${error}`;
}
if (details.credentials) {
const { token, secret } = details.credentials;
redirectUrl = `${redirectUrl}&credentialToken=${token}&credentialSecret=${secret}`;
}
} else if (!Meteor.settings?.packages?.oauth?.disableCheckRedirectUrlOrigin && OAuth._checkRedirectUrlOrigin(redirectUrl)) {
details.error = `redirectUrl (${redirectUrl}) is not on the same host as the app (${appHost})`;
redirectUrl = appHost;
}
}
const isCordova = OAuth._isCordovaFromQuery(details.query);
if (details.error) {
res.end(
await renderEndOfLoginResponse({
loginStyle: details.loginStyle,
setCredentialToken: false,
redirectUrl,
isCordova,
}),
'utf-8',
);
return;
}
// If we have a credentialSecret, report it back to the parent
// window, with the corresponding credentialToken. The parent window
// uses the credentialToken and credentialSecret to log in over DDP.
res.end(
await renderEndOfLoginResponse({
loginStyle: details.loginStyle,
setCredentialToken: true,
credentialToken: details.credentials.token,
credentialSecret: details.credentials.secret,
redirectUrl,
isCordova,
}),
'utf-8',
);
};
});

View File

@@ -0,0 +1,37 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Users } from '@rocket.chat/models';
import { Accounts } from 'meteor/accounts-base';
import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { OAuth } from 'meteor/oauth';
Accounts.registerLoginHandler('iframe', async (result) => {
if (!result.iframe) {
return;
}
check(result.token, String);
const user = await Users.findOne({
'services.iframe.token': result.token,
});
if (user) {
return {
userId: user._id,
};
}
});
declare module '@rocket.chat/ddp-client' {
// eslint-disable-next-line @typescript-eslint/naming-convention
interface ServerMethods {
'OAuth.retrieveCredential'(credentialToken: string, credentialSecret: string): unknown;
}
}
Meteor.methods<ServerMethods>({
'OAuth.retrieveCredential'(credentialToken, credentialSecret) {
return OAuth.retrieveCredential(credentialToken, credentialSecret);
},
});

View File

@@ -0,0 +1,49 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import { settings } from '../../settings';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
const config: Partial<OAuthConfiguration> = {
serverURL: 'https://www.linkedin.com',
authorizePath: '/oauth/v2/authorization',
tokenPath: '/oauth/v2/accessToken',
identityPath: 'https://api.linkedin.com/v2/userinfo',
scope: 'openid email profile',
tokenSentVia: 'header',
addAutopublishFields: {
forLoggedInUser: ['services.linkedin'],
forOtherUsers: ['services.linkedin.name'],
},
pkce: false,
emailField: 'email',
avatarField: 'picture',
};
const serviceKey = 'linkedin';
const configureLinkedInOAuth = (): void => {
passport.unuse(serviceKey);
const enabled = settings.get<boolean>('Accounts_OAuth_Linkedin');
if (!enabled) {
return;
}
const clientId = settings.get<string>('Accounts_OAuth_Linkedin_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Linkedin_secret');
if (!clientId || !clientSecret) {
return;
}
addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret });
};
Meteor.startup(() => {
settings.watchMultiple(
['Accounts_OAuth_Linkedin', 'Accounts_OAuth_Linkedin_id', 'Accounts_OAuth_Linkedin_secret'],
configureLinkedInOAuth,
);
});

View File

@@ -0,0 +1,46 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import passport from 'passport';
import { settings } from '../../settings';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
const config: Partial<OAuthConfiguration> = {
serverURL: 'https://www.meteor.com',
authorizePath: '/oauth2/authorize',
tokenPath: '/oauth2/token',
identityPath: '/api/v1/identity',
scope: 'email',
tokenSentVia: 'header',
addAutopublishFields: {
forLoggedInUser: ['services.meteor-developer'],
forOtherUsers: ['services.meteor-developer.username'],
},
};
const serviceKey = 'meteor';
const configureMeteorDeveloperOAuth = (): void => {
passport.unuse(serviceKey);
const enabled = settings.get<boolean>('Accounts_OAuth_Meteor');
if (!enabled) {
return;
}
const clientId = settings.get<string>('Accounts_OAuth_Meteor_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Meteor_secret');
if (!clientId || !clientSecret) {
return;
}
addPassportCustomOAuth(serviceKey, { ...config, clientId, clientSecret });
};
Meteor.startup(() => {
settings.watchMultiple(
['Accounts_OAuth_Meteor', 'Accounts_OAuth_Meteor_id', 'Accounts_OAuth_Meteor_secret'],
configureMeteorDeveloperOAuth,
);
});

View File

@@ -0,0 +1,68 @@
import crypto from 'node:crypto';
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Match, check } from 'meteor/check';
import { OAuth } from 'meteor/oauth';
import _ from 'underscore';
import { registerAccessTokenService } from './oauth';
const whitelisted = ['id', 'email', 'name', 'first_name', 'last_name', 'link', 'gender', 'locale', 'age_range'];
const FB_API_VERSION = 'v2.9';
const FB_URL = 'https://graph.facebook.com';
const getIdentity = async function (accessToken, fields, secret) {
const hmac = crypto.createHmac('sha256', OAuth.openSecret(secret));
hmac.update(accessToken);
try {
const request = await fetch(`${FB_URL}/${FB_API_VERSION}/me`, {
params: {
access_token: accessToken,
appsecret_proof: hmac.digest('hex'),
fields: fields.join(','),
},
});
if (!request.ok) {
throw new Error(await request.text());
}
return request.json();
} catch (err) {
throw _.extend(new Error(`Failed to fetch identity from Facebook. ${err.message}`), {
response: err.message,
});
}
};
registerAccessTokenService('facebook', async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
secret: String,
expiresIn: Match.Integer,
}),
);
const identity = await getIdentity(options.accessToken, whitelisted, options.secret);
const serviceData = {
accessToken: options.accessToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
};
const fields = _.pick(identity, whitelisted);
_.extend(serviceData, fields);
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});

View File

@@ -0,0 +1,83 @@
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
import { Match, check } from 'meteor/check';
import { Google } from 'meteor/google-oauth';
import _ from 'underscore';
import { registerAccessTokenService } from './oauth';
async function getIdentity(accessToken) {
try {
const request = await fetch('https://www.googleapis.com/oauth2/v1/userinfo', {
params: { access_token: accessToken },
});
if (!request.ok) {
throw new Error(await request.text());
}
return request.json();
} catch (err) {
throw _.extend(new Error(`Failed to fetch identity from Google. ${err.message}`), {
response: err.message,
});
}
}
async function getScopes(accessToken) {
try {
const request = await fetch('https://www.googleapis.com/oauth2/v1/tokeninfo', {
params: { access_token: accessToken },
});
if (!request.ok) {
throw new Error(await request.text());
}
return (await request.json()).scope.split(' ');
} catch (err) {
throw _.extend(new Error(`Failed to fetch tokeninfo from Google. ${err.message}`), {
response: err.message,
});
}
}
registerAccessTokenService('google', async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
idToken: String,
expiresIn: Match.Integer,
scope: Match.Maybe(String),
identity: Match.Maybe(Object),
}),
);
const identity = await getIdentity(options.accessToken);
const serviceData = {
accessToken: options.accessToken,
idToken: options.idToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
scope: options.scopes || (await getScopes(options.accessToken)),
};
const fields = _.pick(identity, Google.whitelistedFields);
_.extend(serviceData, fields);
// only set the token in serviceData if it's there. this ensures
// that we don't lose old ones (since we only get this on the first
// log in attempt)
if (options.refreshToken) {
serviceData.refreshToken = options.refreshToken;
}
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});

View File

@@ -0,0 +1,55 @@
import { Accounts } from 'meteor/accounts-base';
import { Match, check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';
import { ServiceConfiguration } from 'meteor/service-configuration';
import _ from 'underscore';
const AccessTokenServices = {};
export const registerAccessTokenService = function (serviceName, handleAccessTokenRequest) {
AccessTokenServices[serviceName] = {
serviceName,
handleAccessTokenRequest,
};
};
// Listen to calls to `login` with an oauth option set. This is where
// users actually get logged in to meteor via oauth.
Accounts.registerLoginHandler(async (options) => {
if (!options.accessToken) {
return undefined; // don't handle
}
check(
options,
Match.ObjectIncluding({
serviceName: String,
}),
);
const service = AccessTokenServices[options.serviceName];
// Skip everything if there's no service set by the oauth middleware
if (!service) {
throw new Error(`Unexpected AccessToken service ${options.serviceName}`);
}
// Make sure we're configured
if (!(await ServiceConfiguration.configurations.findOneAsync({ service: options.serviceName }))) {
throw new Accounts.ConfigError();
}
if (!_.contains(Accounts.oauth.serviceNames(), service.serviceName)) {
// serviceName was not found in the registered services list.
// This could happen because the service never registered itself or
// unregisterService was called on it.
return {
type: 'oauth',
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, `No registered oauth service found for: ${service.serviceName}`),
};
}
const oauthResult = await service.handleAccessTokenRequest(options);
return Accounts.updateOrCreateUserFromExternalService(service.serviceName, oauthResult.serviceData, oauthResult.options);
});

View File

@@ -0,0 +1,12 @@
import { OAuth } from 'meteor/oauth';
import _ from 'underscore';
import { settings } from '../../../settings';
OAuth._redirectUri = _.wrap(OAuth._redirectUri, (func, serviceName, ...args) => {
const proxy = settings.get('Accounts_OAuth_Proxy_services').replace(/\s/g, '').split(',');
if (proxy.includes(serviceName)) {
return `${settings.get('Accounts_OAuth_Proxy_host')}/oauth_redirect`;
}
return func(serviceName, ...args);
});

View File

@@ -0,0 +1,55 @@
import { Match, check } from 'meteor/check';
import { TwitterApi } from 'twitter-api-v2';
import _ from 'underscore';
import { registerAccessTokenService } from './oauth';
const whitelistedFields = ['id', 'name', 'description', 'profile_image_url', 'profile_image_url_https', 'lang', 'email'];
const getIdentity = async function (accessToken, appId, appSecret, accessTokenSecret) {
const client = new TwitterApi({
appKey: appId,
appSecret,
accessToken,
accessSecret: accessTokenSecret,
});
try {
return await client.v1.verifyCredentials({ include_email: true });
} catch (err) {
throw _.extend(new Error(`Failed to fetch identity from Twitter. ${err.message}`), {
data: err.data ?? err.response,
});
}
};
registerAccessTokenService('twitter', async (options) => {
check(
options,
Match.ObjectIncluding({
accessToken: String,
appSecret: String,
appId: String,
accessTokenSecret: String,
expiresIn: Match.Integer,
}),
);
const identity = await getIdentity(options.accessToken, options.appId, options.appSecret, options.accessTokenSecret);
const serviceData = {
accessToken: options.accessToken,
expiresAt: +new Date() + 1000 * parseInt(options.expiresIn, 10),
};
const fields = _.pick(identity, whitelistedFields);
_.extend(serviceData, fields);
return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});

View File

@@ -0,0 +1,115 @@
import type { OAuthConfiguration } from '@rocket.chat/core-typings';
import { Meteor } from 'meteor/meteor';
import { ServiceConfiguration } from 'meteor/service-configuration';
import passport from 'passport';
import _ from 'underscore';
import { addPassportCustomOAuth } from '../oauth/addPassportCustomOAuth';
import { CustomOAuth } from './custom-oauth/custom_oauth_server';
import { settings } from '../../settings';
const config: Partial<OAuthConfiguration> = {
serverURL: '',
identityPath: '/oauth/me',
addAutopublishFields: {
forLoggedInUser: ['services.wordpress'],
forOtherUsers: ['services.wordpress.user_login'],
},
accessTokenParam: 'access_token',
};
const serviceKey = 'wordpress';
const WordPress = new CustomOAuth(serviceKey, config);
const fillSettings = _.debounce(async (): Promise<void> => {
config.serverURL = settings.get('API_Wordpress_URL');
if (!config.serverURL) {
if (config.serverURL === undefined) {
return fillSettings();
}
return;
}
delete config.identityPath;
delete config.identityTokenSentVia;
delete config.authorizePath;
delete config.tokenPath;
delete config.scope;
passport.unuse(serviceKey);
const serverType = settings.get('Accounts_OAuth_Wordpress_server_type');
switch (serverType) {
case 'custom':
if (settings.get('Accounts_OAuth_Wordpress_identity_path')) {
config.identityPath = settings.get('Accounts_OAuth_Wordpress_identity_path');
}
if (settings.get('Accounts_OAuth_Wordpress_identity_token_sent_via')) {
config.identityTokenSentVia = settings.get('Accounts_OAuth_Wordpress_identity_token_sent_via');
}
if (settings.get('Accounts_OAuth_Wordpress_token_path')) {
config.tokenPath = settings.get('Accounts_OAuth_Wordpress_token_path');
}
if (settings.get('Accounts_OAuth_Wordpress_authorize_path')) {
config.authorizePath = settings.get('Accounts_OAuth_Wordpress_authorize_path');
}
if (settings.get('Accounts_OAuth_Wordpress_scope')) {
config.scope = settings.get('Accounts_OAuth_Wordpress_scope');
}
break;
case 'wordpress-com':
config.identityPath = '/rest/v1/me';
config.identityTokenSentVia = 'header' as OAuthConfiguration['identityTokenSentVia'];
config.authorizePath = '/oauth2/authorize';
config.tokenPath = '/oauth2/token';
config.scope = 'auth';
break;
default:
config.identityPath = '/oauth/me';
config.authorizePath = '/oauth/authorize';
config.tokenPath = '/oauth/token';
break;
}
const clientId = settings.get<string>('Accounts_OAuth_Wordpress_id');
const clientSecret = settings.get<string>('Accounts_OAuth_Wordpress_secret');
if (!clientId || !clientSecret) {
return;
}
const completeConfig = { ...config, clientId, clientSecret };
if (settings.get<boolean>('Accounts_OAuth_Use_Modern_Flow')) {
addPassportCustomOAuth(serviceKey, completeConfig);
} else {
WordPress.configure(completeConfig);
}
const enabled = settings.get('Accounts_OAuth_Wordpress');
if (enabled) {
await ServiceConfiguration.configurations.upsertAsync(
{
service: serviceKey,
},
{
$set: config,
},
);
} else {
await ServiceConfiguration.configurations.removeAsync({
service: serviceKey,
});
}
}, 1000);
Meteor.startup(() => {
settings.watchByRegex(/(API\_Wordpress\_URL)?(Accounts\_OAuth\_Wordpress\_)?/, () => fillSettings());
settings.watch('Accounts_OAuth_Use_Modern_Flow', () => fillSettings());
});