snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
import { settings } from '../../../settings';
|
||||
import { CustomOAuth } from '../custom-oauth/custom_oauth_server';
|
||||
|
||||
export class AppleCustomOAuth extends CustomOAuth {
|
||||
override async getIdentity(_accessToken: string, query: Record<string, any>): Promise<any> {
|
||||
const { id_token: identityToken, user: userStr = '' } = query;
|
||||
|
||||
let usrObj = {} as any;
|
||||
try {
|
||||
usrObj = JSON.parse(userStr);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const clientId = settings.get<string>('Accounts_OAuth_Apple_id') || '';
|
||||
|
||||
const serviceData = await handleIdentityToken(identityToken, clientId);
|
||||
|
||||
if (usrObj?.name) {
|
||||
serviceData.name = `${usrObj.name.firstName}${usrObj.name.middleName ? ` ${usrObj.name.middleName}` : ''}${
|
||||
usrObj.name.lastName ? ` ${usrObj.name.lastName}` : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
return serviceData;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
type: 'apple',
|
||||
error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createPrivateKey, sign } from 'node:crypto';
|
||||
|
||||
import { ServiceConfiguration } from 'meteor/service-configuration';
|
||||
|
||||
import { AppleCustomOAuth } from './AppleCustomOAuth';
|
||||
import { config } from '../../../../app/apple/lib/config';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
new AppleCustomOAuth('apple', config);
|
||||
|
||||
const toBase64Url = (obj: Record<string, any>) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
|
||||
function generateAppleClientSecret(header: Record<string, any>, payload: Record<string, any>, privateKeyString: string): string {
|
||||
const headerB64 = toBase64Url(header);
|
||||
const payloadB64 = toBase64Url(payload);
|
||||
const dataToSign = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const privateKey = createPrivateKey({
|
||||
key: privateKeyString,
|
||||
format: 'pem',
|
||||
type: 'pkcs8',
|
||||
});
|
||||
|
||||
const signature = sign('sha256', Buffer.from(dataToSign), {
|
||||
key: privateKey,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
|
||||
const signatureB64 = signature.toString('base64url');
|
||||
|
||||
return `${dataToSign}.${signatureB64}`;
|
||||
}
|
||||
|
||||
settings.watchMultiple(
|
||||
[
|
||||
'Accounts_OAuth_Apple',
|
||||
'Accounts_OAuth_Apple_id',
|
||||
'Accounts_OAuth_Apple_secretKey',
|
||||
'Accounts_OAuth_Apple_iss',
|
||||
'Accounts_OAuth_Apple_kid',
|
||||
],
|
||||
async ([enabled, clientId, serverSecret, iss, kid]) => {
|
||||
if (!enabled) {
|
||||
return ServiceConfiguration.configurations.removeAsync({
|
||||
service: 'apple',
|
||||
});
|
||||
}
|
||||
|
||||
const [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid] = [clientId, serverSecret, iss, kid].map((value) =>
|
||||
typeof value === 'string' ? value.trim() : '',
|
||||
);
|
||||
|
||||
const hasAllFields = [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid].every(Boolean);
|
||||
|
||||
// Hide web button if settings are incomplete, but preserve mobile-only setup if enabled.
|
||||
if (!hasAllFields) {
|
||||
await ServiceConfiguration.configurations.upsertAsync(
|
||||
{
|
||||
service: 'apple',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showButton: false,
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const HEADER = {
|
||||
kid: normalizedKid,
|
||||
alg: 'ES256',
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const exp = new Date();
|
||||
exp.setMonth(exp.getMonth() + 5);
|
||||
|
||||
try {
|
||||
const secret = generateAppleClientSecret(
|
||||
HEADER,
|
||||
{
|
||||
iss: normalizedIss,
|
||||
iat: Math.floor(now.getTime() / 1000),
|
||||
exp: Math.floor(exp.getTime() / 1000),
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: normalizedClientId,
|
||||
},
|
||||
normalizedServerSecret,
|
||||
);
|
||||
|
||||
await ServiceConfiguration.configurations.upsertAsync(
|
||||
{
|
||||
service: 'apple',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showButton: true,
|
||||
secret,
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
loginStyle: 'popup',
|
||||
clientId: normalizedClientId,
|
||||
buttonColor: '#000',
|
||||
buttonLabelColor: '#FFF',
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to configure Apple OAuth service', error);
|
||||
|
||||
await ServiceConfiguration.configurations.upsertAsync(
|
||||
{
|
||||
service: 'apple',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showButton: false,
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,178 @@
|
||||
import { MeteorError } from '@rocket.chat/core-services';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
import express from 'express';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { ServiceConfiguration } from 'meteor/service-configuration';
|
||||
import passport from 'passport';
|
||||
import { Strategy as AppleStrategy } from 'passport-apple';
|
||||
import type { Profile } from 'passport-apple';
|
||||
|
||||
import { AppleCustomOAuth } from './AppleCustomOAuth';
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
import { config } from '../../../../app/apple/lib/config';
|
||||
import { oAuthRouter } from '../../../configuration/configurePassport';
|
||||
import { settings } from '../../../settings';
|
||||
import { allowPassportOAuthMiddleware } from '../../oauth/allowPassportOAuthMiddleware';
|
||||
import { passportOAuthCallback } from '../../oauth/passportOAuthCallback';
|
||||
import { removeOAuthRoutes } from '../../oauth/removeOAuthRoutes';
|
||||
|
||||
new AppleCustomOAuth('apple', config);
|
||||
|
||||
type RequestWithAppleProfile = {
|
||||
appleProfile?: {
|
||||
name?: {
|
||||
firstName?: string;
|
||||
middleName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function isRequestWithAppleProfile(req: object): req is RequestWithAppleProfile {
|
||||
if (
|
||||
'appleProfile' in req &&
|
||||
req.appleProfile &&
|
||||
typeof req.appleProfile === 'object' &&
|
||||
'name' in req.appleProfile &&
|
||||
req.appleProfile.name &&
|
||||
typeof req.appleProfile.name === 'object'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.watchMultiple(
|
||||
[
|
||||
'Accounts_OAuth_Apple',
|
||||
'Accounts_OAuth_Apple_id',
|
||||
'Accounts_OAuth_Apple_secretKey',
|
||||
'Accounts_OAuth_Apple_iss',
|
||||
'Accounts_OAuth_Apple_kid',
|
||||
'Accounts_OAuth_Use_Modern_Flow',
|
||||
],
|
||||
async ([enabled, clientId, serverSecret, iss, kid, useModernFlow]) => {
|
||||
passport.unuse('apple');
|
||||
removeOAuthRoutes('apple');
|
||||
|
||||
if (!useModernFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return ServiceConfiguration.configurations.removeAsync({
|
||||
service: 'apple',
|
||||
});
|
||||
}
|
||||
|
||||
// if everything is empty but Apple login is enabled, don't show the login button
|
||||
if (!clientId && !serverSecret && !iss && !kid) {
|
||||
await ServiceConfiguration.configurations.upsertAsync(
|
||||
{
|
||||
service: 'apple',
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
showButton: false,
|
||||
enabled: settings.get('Accounts_OAuth_Apple'),
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof clientId !== 'string' || !clientId) {
|
||||
return ServiceConfiguration.configurations.removeAsync({
|
||||
service: 'apple',
|
||||
});
|
||||
}
|
||||
|
||||
passport.use(
|
||||
'apple',
|
||||
|
||||
new AppleStrategy(
|
||||
{
|
||||
clientID: clientId,
|
||||
teamID: settings.get<string>('Accounts_OAuth_Apple_iss'),
|
||||
keyID: settings.get<string>('Accounts_OAuth_Apple_kid'),
|
||||
privateKeyString: settings.get<string>('Accounts_OAuth_Apple_secretKey').replace(/\\n/g, '\n'),
|
||||
callbackURL: `${settings.get<string>('Site_Url').replace(/\/$/, '')}/_oauth/apple`,
|
||||
scope: ['name', 'email'],
|
||||
passReqToCallback: true,
|
||||
state: false,
|
||||
},
|
||||
async (req, accessToken: string, refreshToken: string, idToken: string, _profile: Profile, done) => {
|
||||
try {
|
||||
const serviceData = await handleIdentityToken(idToken, clientId);
|
||||
|
||||
if (isRequestWithAppleProfile(req)) {
|
||||
const appleName = req.appleProfile?.name;
|
||||
serviceData.name = [appleName?.firstName, appleName?.middleName, appleName?.lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
const user = await Accounts.updateOrCreateUserFromExternalService(
|
||||
'apple',
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
...serviceData,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
if (!user?.userId || typeof user?.userId !== 'string') {
|
||||
return done(new Error('User not found'));
|
||||
}
|
||||
|
||||
const userFromDB = await Users.findOneById(user.userId);
|
||||
|
||||
if (!userFromDB) {
|
||||
return done(new Error('User not found'));
|
||||
}
|
||||
|
||||
return done(null, userFromDB);
|
||||
} catch (error: any) {
|
||||
done(error);
|
||||
return {
|
||||
type: 'apple',
|
||||
error: new MeteorError(Accounts.LoginCancelledError.numericError, error.message),
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const callbackHandler = [
|
||||
allowPassportOAuthMiddleware('apple'),
|
||||
express.urlencoded({ extended: true }),
|
||||
passport.authenticate('apple', { failWithError: true, session: true, keepSessionInfo: true }),
|
||||
passportOAuthCallback(settings.get<string>('Site_Url').replace(/\/$/, '')),
|
||||
];
|
||||
|
||||
oAuthRouter.get(
|
||||
'/oauth/apple',
|
||||
allowPassportOAuthMiddleware('apple'),
|
||||
(req, _res, next) => {
|
||||
const { loginClient } = req.query;
|
||||
if (loginClient === 'mobile' || loginClient === 'desktop') {
|
||||
req.session.loginClient = loginClient;
|
||||
req.session.save(() => {
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
//delete stale value from previous sessions if any
|
||||
delete req.session.loginClient;
|
||||
next();
|
||||
}
|
||||
},
|
||||
passport.authenticate('apple', {
|
||||
scope: ['name', 'email'],
|
||||
}),
|
||||
);
|
||||
|
||||
oAuthRouter
|
||||
.route('/_oauth/apple')
|
||||
.post(...callbackHandler)
|
||||
.get(...callbackHandler);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,133 @@
|
||||
import { generateKeyPairSync, sign } from 'node:crypto';
|
||||
|
||||
import { serverFetch } from '@rocket.chat/server-fetch';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
|
||||
jest.mock('@rocket.chat/server-fetch', () => ({
|
||||
serverFetch: jest.fn(),
|
||||
}));
|
||||
|
||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
});
|
||||
|
||||
const jwkPublicKey = publicKey.export({ format: 'jwk' });
|
||||
|
||||
const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
|
||||
describe('handleIdentityToken', () => {
|
||||
const mockClientId = 'com.yourcompany.app';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should throw an error if the token has the wrong audience', async () => {
|
||||
const header = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
|
||||
const payload = toBase64Url({
|
||||
iss: 'https://appleid.apple.com',
|
||||
aud: 'wrong.client.id',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
sub: 'user123',
|
||||
});
|
||||
|
||||
const mockToken = `${header}.${payload}.dummySignature`;
|
||||
|
||||
await expect(handleIdentityToken(mockToken, mockClientId)).rejects.toThrow('identityToken is not a valid Apple JWT or has expired');
|
||||
});
|
||||
|
||||
it('should successfully validate a valid token', async () => {
|
||||
const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
|
||||
const payloadB64 = toBase64Url({
|
||||
iss: 'https://appleid.apple.com',
|
||||
aud: mockClientId,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
sub: 'user123',
|
||||
});
|
||||
|
||||
const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey);
|
||||
const signatureB64 = signatureBytes.toString('base64url');
|
||||
|
||||
const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`;
|
||||
|
||||
if (!jwkPublicKey.n || !jwkPublicKey.e) {
|
||||
throw new Error('Generated test key is missing modulus or exponent');
|
||||
}
|
||||
|
||||
const mockJwksPayload = {
|
||||
keys: [
|
||||
{
|
||||
kty: 'RSA',
|
||||
kid: 'mock-key-id',
|
||||
use: 'sig',
|
||||
alg: 'RS256',
|
||||
n: jwkPublicKey.n,
|
||||
e: jwkPublicKey.e,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
jest.mocked(serverFetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockJwksPayload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleIdentityToken(validMockToken, mockClientId);
|
||||
|
||||
expect(result.id).toBe('user123');
|
||||
expect(result.iss).toBe('https://appleid.apple.com');
|
||||
});
|
||||
|
||||
it('should accept default mobile audience when client id setting is empty', async () => {
|
||||
const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
|
||||
const payloadB64 = toBase64Url({
|
||||
iss: 'https://appleid.apple.com',
|
||||
aud: 'chat.rocket.ios',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
sub: 'user123',
|
||||
});
|
||||
|
||||
const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey);
|
||||
const signatureB64 = signatureBytes.toString('base64url');
|
||||
|
||||
const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`;
|
||||
|
||||
if (!jwkPublicKey.n || !jwkPublicKey.e) {
|
||||
throw new Error('Generated test key is missing modulus or exponent');
|
||||
}
|
||||
|
||||
const mockJwksPayload = {
|
||||
keys: [
|
||||
{
|
||||
kty: 'RSA',
|
||||
kid: 'mock-key-id',
|
||||
use: 'sig',
|
||||
alg: 'RS256',
|
||||
n: jwkPublicKey.n,
|
||||
e: jwkPublicKey.e,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
jest.mocked(serverFetch).mockResolvedValue(
|
||||
new Response(JSON.stringify(mockJwksPayload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleIdentityToken(validMockToken, '');
|
||||
|
||||
expect(result.id).toBe('user123');
|
||||
expect(result.aud).toBe('chat.rocket.ios');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createPublicKey, verify } from 'node:crypto';
|
||||
|
||||
import { serverFetch as fetch } from '@rocket.chat/server-fetch';
|
||||
|
||||
type AppleJWK = {
|
||||
kty: string;
|
||||
kid: string;
|
||||
use: string;
|
||||
alg: string;
|
||||
n: string;
|
||||
e: string;
|
||||
};
|
||||
|
||||
type AppleJWTPayload = {
|
||||
iss: string;
|
||||
sub: string;
|
||||
aud: string | string[];
|
||||
exp: number;
|
||||
iat: number;
|
||||
email?: string;
|
||||
email_verified?: string | boolean;
|
||||
is_private_email?: string | boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_APPLE_AUDIENCES = ['chat.rocket.ios'];
|
||||
|
||||
let cachedKeys: AppleJWK[] | null = null;
|
||||
let lastFetchTime = 0;
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
|
||||
|
||||
async function getApplePublicKeys(forceRefresh = false): Promise<AppleJWK[]> {
|
||||
const now = Date.now();
|
||||
|
||||
if (!forceRefresh && cachedKeys && now - lastFetchTime < CACHE_TTL_MS) {
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://appleid.apple.com/auth/keys', {
|
||||
method: 'GET',
|
||||
// SECURITY: Hardcoded URL, no SSRF protection needed
|
||||
ignoreSsrfValidation: true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Apple keys: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { keys: AppleJWK[] };
|
||||
cachedKeys = data.keys;
|
||||
lastFetchTime = now;
|
||||
|
||||
return cachedKeys;
|
||||
} catch (error) {
|
||||
if (cachedKeys) {
|
||||
console.warn('Failed to refresh Apple public keys, using stale cache', error);
|
||||
return cachedKeys;
|
||||
}
|
||||
throw new Error('Could not retrieve Apple public keys', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(str: string): string {
|
||||
return Buffer.from(str, 'base64url').toString('utf8');
|
||||
}
|
||||
|
||||
async function verifyAppleJWT(
|
||||
headerB64: string,
|
||||
payloadB64: string,
|
||||
signatureB64: string,
|
||||
clientId: string,
|
||||
): Promise<AppleJWTPayload | null> {
|
||||
const header = JSON.parse(decodeBase64Url(headerB64));
|
||||
const payload = JSON.parse(decodeBase64Url(payloadB64)) as AppleJWTPayload;
|
||||
|
||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (payload.exp < nowInSeconds) {
|
||||
console.error('Apple JWT has expired');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.iss !== 'https://appleid.apple.com') {
|
||||
console.error('Invalid issuer. Expected https://appleid.apple.com');
|
||||
return null;
|
||||
}
|
||||
|
||||
const audArray = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
||||
|
||||
const configuredAudiences = clientId
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const allowedAudiences = Array.from(new Set([...DEFAULT_APPLE_AUDIENCES, ...configuredAudiences]));
|
||||
|
||||
const isAudienceValid = allowedAudiences.some((allowedAud) => audArray.includes(allowedAud));
|
||||
|
||||
if (!isAudienceValid) {
|
||||
console.error(`Invalid audience. Expected one of: ${allowedAudiences.join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let applePublicKeys = await getApplePublicKeys();
|
||||
let keyData = applePublicKeys.find((k) => k.kid === header.kid);
|
||||
|
||||
if (!keyData) {
|
||||
applePublicKeys = await getApplePublicKeys(true); // Force refresh
|
||||
keyData = applePublicKeys.find((k) => k.kid === header.kid);
|
||||
|
||||
if (!keyData) {
|
||||
console.error('Matching Key ID (kid) not found in Apple JWKS');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const publicKey = createPublicKey({
|
||||
key: {
|
||||
kty: keyData.kty,
|
||||
n: keyData.n,
|
||||
e: keyData.e,
|
||||
},
|
||||
format: 'jwk',
|
||||
});
|
||||
|
||||
const isSignatureValid = verify(
|
||||
'RSA-SHA256',
|
||||
Buffer.from(`${headerB64}.${payloadB64}`),
|
||||
publicKey,
|
||||
Buffer.from(signatureB64, 'base64url'),
|
||||
);
|
||||
|
||||
return isSignatureValid ? payload : null;
|
||||
} catch (error) {
|
||||
console.error('Cryptographic signature verification failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleIdentityToken(identityToken: string, clientId: string): Promise<Record<string, any>> {
|
||||
const parts = identityToken.split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Malformed identityToken: JWT must have 3 parts');
|
||||
}
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
|
||||
const payload = await verifyAppleJWT(headerB64, payloadB64, signatureB64, clientId);
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('identityToken is not a valid Apple JWT or has expired');
|
||||
}
|
||||
|
||||
if (!payload.sub) {
|
||||
throw new Error('Insufficient data: Missing subject (sub) in auth response token');
|
||||
}
|
||||
|
||||
const serviceData = {
|
||||
id: payload.sub,
|
||||
...payload,
|
||||
};
|
||||
|
||||
return serviceData;
|
||||
}
|
||||
124
apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts
Normal file
124
apps/meteor/server/lib/auth-providers/apple/loginHandler.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
jest.mock(
|
||||
'meteor/accounts-base',
|
||||
() => ({
|
||||
Accounts: {
|
||||
registerLoginHandler: jest.fn(),
|
||||
updateOrCreateUserFromExternalService: jest.fn(),
|
||||
LoginCancelledError: { numericError: 400 },
|
||||
},
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'meteor/meteor',
|
||||
() => ({
|
||||
Meteor: {
|
||||
Error: class extends Error {
|
||||
constructor(
|
||||
public error: number,
|
||||
public reason: string,
|
||||
) {
|
||||
super(reason);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
jest.mock('../../../settings', () => ({
|
||||
settings: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./handleIdentityToken', () => ({
|
||||
handleIdentityToken: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Apple OAuth loginHandler', () => {
|
||||
let loginHandlerCallback: Parameters<typeof Accounts.registerLoginHandler>[1];
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('./loginHandler');
|
||||
loginHandlerCallback = jest.mocked(Accounts.registerLoginHandler).mock.calls[0][1];
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(settings.get).mockImplementation((key) => {
|
||||
if (key === 'Accounts_OAuth_Apple') return true;
|
||||
if (key === 'Accounts_OAuth_Apple_id') return 'com.yourcompany.app';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
it('should not use the client-provided email if Apple does not provide one', async () => {
|
||||
jest.mocked(handleIdentityToken).mockResolvedValue({
|
||||
id: 'apple-sub-123',
|
||||
});
|
||||
|
||||
const maliciousLoginRequest = {
|
||||
identityToken: 'valid.token.without_email',
|
||||
email: 'alice@email.tld',
|
||||
fullName: { givenName: 'Alice', familyName: 'Sender' },
|
||||
};
|
||||
|
||||
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'new-user-id' });
|
||||
|
||||
await loginHandlerCallback(maliciousLoginRequest);
|
||||
|
||||
expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith(
|
||||
'apple',
|
||||
{ id: 'apple-sub-123' },
|
||||
{ profile: { name: 'Alice Sender' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully pass the email if Apple natively provides it in the signed JWT', async () => {
|
||||
jest.mocked(handleIdentityToken).mockResolvedValue({
|
||||
id: 'apple-sub-123',
|
||||
email: 'legit@email.tld',
|
||||
});
|
||||
|
||||
const legitLoginRequest = {
|
||||
identityToken: 'valid.token.with_email',
|
||||
fullName: { givenName: 'John', familyName: 'Doe' },
|
||||
};
|
||||
|
||||
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' });
|
||||
|
||||
await loginHandlerCallback(legitLoginRequest);
|
||||
|
||||
expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith(
|
||||
'apple',
|
||||
{ id: 'apple-sub-123', email: 'legit@email.tld' },
|
||||
{ profile: { name: 'John Doe' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass empty client id to token validation when setting is not configured', async () => {
|
||||
jest.mocked(settings.get).mockImplementation((key) => {
|
||||
if (key === 'Accounts_OAuth_Apple') return true;
|
||||
if (key === 'Accounts_OAuth_Apple_id') return '';
|
||||
return null;
|
||||
});
|
||||
|
||||
jest.mocked(handleIdentityToken).mockResolvedValue({ id: 'apple-sub-123' });
|
||||
jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' });
|
||||
|
||||
await loginHandlerCallback({
|
||||
identityToken: 'valid.token.with_mobile_default_audience',
|
||||
fullName: { givenName: 'Mobile', familyName: 'User' },
|
||||
});
|
||||
|
||||
expect(handleIdentityToken).toHaveBeenCalledWith('valid.token.with_mobile_default_audience', '');
|
||||
});
|
||||
});
|
||||
46
apps/meteor/server/lib/auth-providers/apple/loginHandler.ts
Normal file
46
apps/meteor/server/lib/auth-providers/apple/loginHandler.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { handleIdentityToken } from './handleIdentityToken';
|
||||
import { settings } from '../../../settings';
|
||||
|
||||
Accounts.registerLoginHandler('apple', async (loginRequest) => {
|
||||
if (!loginRequest.identityToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!settings.get('Accounts_OAuth_Apple')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { identityToken, fullName } = loginRequest;
|
||||
|
||||
try {
|
||||
const clientId = settings.get<string>('Accounts_OAuth_Apple_id') || '';
|
||||
|
||||
const serviceData = await handleIdentityToken(identityToken, clientId);
|
||||
|
||||
const profile: { name?: string } = {};
|
||||
|
||||
const { givenName, familyName } = fullName || {};
|
||||
if (givenName && familyName) {
|
||||
profile.name = `${givenName} ${familyName}`;
|
||||
}
|
||||
|
||||
const result = await Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile });
|
||||
|
||||
if (result?.userId === undefined) {
|
||||
return {
|
||||
type: 'apple',
|
||||
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
type: 'apple',
|
||||
error: new Meteor.Error(Accounts.LoginCancelledError.numericError, error.message),
|
||||
};
|
||||
}
|
||||
});
|
||||
424
apps/meteor/server/lib/auth-providers/crowd/crowd.ts
Normal file
424
apps/meteor/server/lib/auth-providers/crowd/crowd.ts
Normal 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());
|
||||
}
|
||||
});
|
||||
});
|
||||
3
apps/meteor/server/lib/auth-providers/crowd/logger.ts
Normal file
3
apps/meteor/server/lib/auth-providers/crowd/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('CROWD');
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
7
apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.d.ts
vendored
Normal file
7
apps/meteor/server/lib/auth-providers/custom-oauth/custom_oauth_server.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
91
apps/meteor/server/lib/auth-providers/dolphin.ts
Normal file
91
apps/meteor/server/lib/auth-providers/dolphin.ts
Normal 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');
|
||||
});
|
||||
23
apps/meteor/server/lib/auth-providers/drupal.README.md
Normal file
23
apps/meteor/server/lib/auth-providers/drupal.README.md
Normal 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+
|
||||
66
apps/meteor/server/lib/auth-providers/drupal.ts
Normal file
66
apps/meteor/server/lib/auth-providers/drupal.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
28
apps/meteor/server/lib/auth-providers/github-enterprise.ts
Normal file
28
apps/meteor/server/lib/auth-providers/github-enterprise.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
apps/meteor/server/lib/auth-providers/github.ts
Normal file
20
apps/meteor/server/lib/auth-providers/github.ts
Normal 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);
|
||||
69
apps/meteor/server/lib/auth-providers/gitlab.ts
Normal file
69
apps/meteor/server/lib/auth-providers/gitlab.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
106
apps/meteor/server/lib/auth-providers/google.js
Normal file
106
apps/meteor/server/lib/auth-providers/google.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\'/g, '"')
|
||||
.replace(/\'/g, ''')
|
||||
.replace(/\//g, '/');
|
||||
};
|
||||
|
||||
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',
|
||||
);
|
||||
};
|
||||
});
|
||||
37
apps/meteor/server/lib/auth-providers/iframe.ts
Normal file
37
apps/meteor/server/lib/auth-providers/iframe.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
49
apps/meteor/server/lib/auth-providers/linkedin.ts
Normal file
49
apps/meteor/server/lib/auth-providers/linkedin.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
46
apps/meteor/server/lib/auth-providers/meteor-developer.ts
Normal file
46
apps/meteor/server/lib/auth-providers/meteor-developer.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
68
apps/meteor/server/lib/auth-providers/oauth/facebook.js
Normal file
68
apps/meteor/server/lib/auth-providers/oauth/facebook.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
83
apps/meteor/server/lib/auth-providers/oauth/google.js
Normal file
83
apps/meteor/server/lib/auth-providers/oauth/google.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
55
apps/meteor/server/lib/auth-providers/oauth/oauth.js
Normal file
55
apps/meteor/server/lib/auth-providers/oauth/oauth.js
Normal 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);
|
||||
});
|
||||
12
apps/meteor/server/lib/auth-providers/oauth/proxy.js
Normal file
12
apps/meteor/server/lib/auth-providers/oauth/proxy.js
Normal 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);
|
||||
});
|
||||
55
apps/meteor/server/lib/auth-providers/oauth/twitter.js
Normal file
55
apps/meteor/server/lib/auth-providers/oauth/twitter.js
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
115
apps/meteor/server/lib/auth-providers/wordpress.ts
Normal file
115
apps/meteor/server/lib/auth-providers/wordpress.ts
Normal 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());
|
||||
});
|
||||
Reference in New Issue
Block a user