The communications platform that puts data protection first.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
Rocket.Chat/packages/rocketchat-custom-oauth/custom_oauth_server.js

298 lines
7.7 KiB

/*globals OAuth*/
const logger = new Logger('CustomOAuth');
const Services = {};
const BeforeUpdateOrCreateUserFromExternalService = [];
export class CustomOAuth {
constructor(name, options) {
logger.debug('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();
}
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';
}
this.serverURL = options.serverURL;
this.tokenPath = options.tokenPath;
this.identityPath = options.identityPath;
this.tokenSentVia = options.tokenSentVia;
this.usernameField = (options.usernameField || '').trim();
this.mergeUsers = options.mergeUsers;
if (!/^https?:\/\/.+/.test(this.tokenPath)) {
this.tokenPath = this.serverURL + this.tokenPath;
}
if (!/^https?:\/\/.+/.test(this.identityPath)) {
this.identityPath = this.serverURL + this.identityPath;
}
if (Match.test(options.addAutopublishFields, Object)) {
Accounts.addAutopublishFields(options.addAutopublishFields);
}
}
getAccessToken(query) {
const config = ServiceConfiguration.configurations.findOne({service: this.name});
if (!config) {
throw new ServiceConfiguration.ConfigError();
}
let response = undefined;
try {
response = HTTP.post(this.tokenPath, {
auth: config.clientId + ':' + OAuth.openSecret(config.secret),
headers: {
Accept: 'application/json',
'User-Agent': this.userAgent
},
params: {
code: query.code,
client_id: config.clientId,
client_secret: OAuth.openSecret(config.secret),
redirect_uri: OAuth._redirectUri(this.name, config),
grant_type: 'authorization_code',
state: query.state
}
});
} 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.data.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.data.error}`);
} else {
return response.data.access_token;
}
}
getIdentity(accessToken) {
const params = {};
const headers = {
'User-Agent': this.userAgent // http://doc.gitlab.com/ce/api/users.html#Current-user
};
if (this.tokenSentVia === 'header') {
headers['Authorization'] = 'Bearer ' + accessToken;
} else {
params['access_token'] = accessToken;
}
try {
const response = HTTP.get(this.identityPath, {
headers: headers,
params: params
});
let data;
if (response.data) {
data = response.data;
} else {
data = JSON.parse(response.content);
}
logger.debug('Identity response', JSON.stringify(data, null, 2));
return data;
} 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, (query) => {
const accessToken = self.getAccessToken(query);
// console.log 'at:', accessToken
let identity = self.getIdentity(accessToken);
if (identity) {
// Fix for Reddit
if (identity.result) {
identity = identity.result;
}
// Fix WordPress-like identities having 'ID' instead of 'id'
if (identity.ID && !identity.id) {
identity.id = identity.ID;
}
// Fix Auth0-like identities having 'user_id' instead of 'id'
if (identity.user_id && !identity.id) {
identity.id = identity.user_id;
}
if (identity.CharacterID && !identity.id) {
identity.id = identity.CharacterID;
}
// Fix Dataporten having 'user.userid' instead of 'id'
if (identity.user && identity.user.userid && !identity.id) {
identity.id = identity.user.userid;
identity.email = identity.user.email;
}
// Fix general 'phid' instead of 'id' from phabricator
if (identity.phid && !identity.id) {
identity.id = identity.phid;
}
// Fix Keycloak-like identities having 'sub' instead of 'id'
if (identity.sub && !identity.id) {
identity.id = identity.sub;
}
// Fix general 'userid' instead of 'id' from provider
if (identity.userid && !identity.id) {
identity.id = identity.userid;
}
}
// console.log 'id:', JSON.stringify identity, null, ' '
const serviceData = {
_OAuthCustom: true,
accessToken: accessToken
};
_.extend(serviceData, identity);
const data = {
serviceData: serviceData,
options: {
profile: {
name: identity.name || identity.username || identity.nickname || identity.CharacterName || identity.userName || identity.preferred_username || (identity.user && identity.user.name)
}
}
};
// console.log data
return data;
});
}
retrieveCredential(credentialToken, credentialSecret) {
return OAuth.retrieveCredential(credentialToken, credentialSecret);
}
getUsername(data) {
let username = '';
if (this.usernameField.indexOf('#{') > -1) {
username = this.usernameField.replace(/#{(.+?)}/g, function(match, field) {
if (!data[field]) {
throw new Meteor.Error(`Username template item "${field}" not found in data`, data);
}
return data[field];
});
} else {
username = data[this.usernameField];
if (!username) {
throw new Meteor.Error(`Username field "${this.usernameField}" not found in data`, data);
}
}
return username;
}
addHookToProcessUser() {
BeforeUpdateOrCreateUserFromExternalService.push((serviceName, serviceData/*, options*/) => {
if (serviceName !== this.name) {
return;
}
if (this.usernameField) {
const username = this.getUsername(serviceData);
const user = RocketChat.models.Users.findOneByUsername(username);
if (!user) {
return;
}
// User already created or merged
if (user.services && user.services[serviceName] && user.services[serviceName].id === serviceData.id) {
return;
}
if (this.mergeUsers !== true) {
throw new Meteor.Error('CustomOAuth', `User with username ${user.username} already exists`);
}
const serviceIdKey = `services.${serviceName}.id`;
const update = {
$set: {
[serviceIdKey]: serviceData.id
}
};
RocketChat.models.Users.update({_id: user._id}, update);
}
});
Accounts.validateNewUser((user) => {
if (!user.services || !user.services[this.name] || !user.services[this.name].id) {
return true;
}
if (this.usernameField) {
user.username = this.getUsername(user.services[this.name]);
}
return true;
});
}
}
const updateOrCreateUserFromExternalService = Accounts.updateOrCreateUserFromExternalService;
Accounts.updateOrCreateUserFromExternalService = function(/*serviceName, serviceData, options*/) {
for (const hook of BeforeUpdateOrCreateUserFromExternalService) {
hook.apply(this, arguments);
}
return updateOrCreateUserFromExternalService.apply(this, arguments);
};