Skip to content

Commit

Permalink
[NEW] Feature/custom oauth mail field and interpolation for mapped fi…
Browse files Browse the repository at this point in the history
…elds (#15690)
  • Loading branch information
benkroeger authored Apr 15, 2020
1 parent 480e52d commit 4ad6503
Show file tree
Hide file tree
Showing 7 changed files with 333 additions and 126 deletions.
175 changes: 49 additions & 126 deletions app/custom-oauth/server/custom_oauth_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { HTTP } from 'meteor/http';
import { ServiceConfiguration } from 'meteor/service-configuration';
import _ from 'underscore';

import { normalizers, fromTemplate, renameInvalidProperties } from './transform_helpers';
import { mapRolesFromSSO, updateRolesFromSSO } from './oauth_helpers';
import { Logger } from '../../logger';
import { Users } from '../../models';
Expand All @@ -17,107 +18,6 @@ const logger = new Logger('CustomOAuth');
const Services = {};
const BeforeUpdateOrCreateUserFromExternalService = [];

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

export class CustomOAuth {
constructor(name, options) {
logger.debug('Init CustomOAuth', name, options);
Expand Down Expand Up @@ -174,6 +74,7 @@ export class CustomOAuth {
this.tokenSentVia = options.tokenSentVia;
this.identityTokenSentVia = options.identityTokenSentVia;
this.usernameField = (options.usernameField || '').trim();
this.emailField = (options.emailField || '').trim();
this.nameField = (options.nameField || '').trim();
this.avatarField = (options.avatarField || '').trim();
this.mergeUsers = options.mergeUsers;
Expand Down Expand Up @@ -334,6 +235,10 @@ export class CustomOAuth {
identity.username = this.getUsername(identity);
}

if (this.emailField) {
identity.email = this.getEmail(identity);
}

if (this.avatarField) {
identity.avatarUrl = this.getAvatarUrl(identity);
}
Expand All @@ -344,50 +249,64 @@ export class CustomOAuth {
identity.name = this.getName(identity);
}

return identity;
return renameInvalidProperties(identity);
}

retrieveCredential(credentialToken, credentialSecret) {
return OAuth.retrieveCredential(credentialToken, credentialSecret);
}

getUsername(data) {
let username = '';
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);
}
}

username = this.usernameField.split('.').reduce(function(prev, curr) {
return prev ? prev[curr] : undefined;
}, data);
getEmail(data) {
try {
const value = fromTemplate(this.emailField, data);

if (!username) {
throw new Meteor.Error('field_not_found', `Username field "${ this.usernameField }" not found in data`, 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);
}
return username;
}

getCustomName(data) {
let customName = '';
try {
const value = fromTemplate(this.nameField, data);

customName = this.nameField.split('.').reduce(function(prev, curr) {
return prev ? prev[curr] : undefined;
}, data);
if (!value) {
return this.getName(data);
}

if (!customName) {
return this.getName(data);
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract custom name', error.message);
}

return customName;
}

getAvatarUrl(data) {
const avatarUrl = this.avatarField.split('.').reduce(function(prev, curr) {
return prev ? prev[curr] : undefined;
}, data);
try {
const value = fromTemplate(this.avatarField, data);

if (!avatarUrl) {
logger.debug(`Avatar field "${ this.avatarField }" not found in data`, data);
if (!value) {
logger.debug(`Avatar field "${ this.avatarField }" not found in data`, data);
}
return value;
} catch (error) {
throw new Error('CustomOAuth: Failed to extract avatar url', error.message);
}

return avatarUrl;
}

getName(identity) {
Expand Down Expand Up @@ -438,11 +357,15 @@ export class CustomOAuth {
}

if (this.usernameField) {
user.username = this.getUsername(user.services[this.name]);
user.username = user.services[this.name].username;
}

if (this.emailField) {
user.email = user.services[this.name].email;
}

if (this.nameField) {
user.name = this.getCustomName(user.services[this.name]);
user.name = user.services[this.name].name;
}

if (this.mergeRoles) {
Expand Down
157 changes: 157 additions & 0 deletions app/custom-oauth/server/transform_helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import _ from 'underscore';

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

0 comments on commit 4ad6503

Please sign in to comment.