Skip to content

Commit

Permalink
[Feature] Custom Oauth login with provider access Token
Browse files Browse the repository at this point in the history
Closes #14108
  • Loading branch information
ralfbecker committed Apr 12, 2019
1 parent c0fab2b commit 63f6e52
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 5 deletions.
1 change: 1 addition & 0 deletions app/lib/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import './oauth/facebook';
import './oauth/google';
import './oauth/proxy';
import './oauth/twitter';
import './oauth/custom';
import './methods/addOAuthService';
import './methods/addUsersToRoom';
import './methods/addUserToRoom';
Expand Down
110 changes: 110 additions & 0 deletions app/lib/server/oauth/custom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { Match, check } from 'meteor/check';
import _ from 'underscore';
import { HTTP } from 'meteor/http';
import { ServiceConfiguration } from 'meteor/service-configuration';
import { OAuth } from 'meteor/oauth';
import { registerAccessTokenService } from './oauth';

function getIdentity(accessToken, config) {
try {
return HTTP.get(
config.serverURL + config.identityPath,
{
headers: {
Authorization: `Bearer ${ accessToken }`,
Accept: 'application/json',
},
}).data;
} catch (err) {
throw _.extend(new Error(`Failed to fetch identity from custom OAuth ${ config.service }. ${ err.message }`), { response: err.response });
}
}

// use RFC7662 OAuth 2.0 Token Introspection
function getTokeninfo(idToken, config) {
try {
const introspectPath = '/introspect'; // not yet configurable in Rocket.Chat, thought RFC defines that path
return HTTP.post(
config.serverURL + introspectPath,
{
auth: `${ config.clientId }:${ OAuth.openSecret(config.secret) }`,
headers: {
Accept: 'application/json',
},
params: {
token: idToken,
token_type_hint: 'access_token',
},
}).data;
} catch (err) {
throw _.extend(new Error(`Failed to fetch tokeninfo from custom OAuth ${ config.service }. ${ err.message }`), { response: err.response });
}
}

registerAccessTokenService('custom', function(options) {
check(options, Match.ObjectIncluding({
accessToken: String,
expiresIn: Match.Maybe(Match.Integer),
scope: Match.Maybe(String),
identity: Match.Maybe(Object),
}));

const config = ServiceConfiguration.configurations.findOne({ service: options.serviceName });
let tokeninfo;
if (!options.expiresIn || !options.scope) {
try {
tokeninfo = getTokeninfo(options.accessToken, config);
// console.log('app/lib/server/oauth/custom.js: tokeninfo=', tokeninfo);
} catch (err) {
// ignore tokeninfo failures, as getIdentity still validates the token, we just dont know how long it's valid
console.log(err);
}
}
const identity = options.identity || getIdentity(options.accessToken, config);

// support OpenID Connect /userinfo names
if (typeof identity.profile_image_url === 'undefined' && identity.picture) {
identity.profile_image_url = identity.picture;
}
if (typeof identity.lang === 'undefined' && identity.locale) {
identity.profile_image_url = identity.picture;
}

const serviceData = {
_OAuthCustom: true,
accessToken: options.accessToken,
expiresAt: tokeninfo.exp || (+new Date) + (1000 * parseInt(options.expiresIn, 10)),
scope: options.scope || tokeninfo.scope || config.scope.split(/ /),
id: identity[config.usernameField],
};

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

const whitelistedFields = [
'name',
'description',
'profile_image_url',
'profile_image_url_https',
'lang',
'email',
];
if (!whitelistedFields.includes(config.usernameField) && typeof serviceData[config.usernameField] === 'undefined') {
whitelistedFields.push(config.usernameField);
}
const fields = _.pick(identity, whitelistedFields);
_.extend(serviceData, fields);

return {
serviceData,
options: {
profile: {
name: identity.name,
},
},
};
});
17 changes: 12 additions & 5 deletions app/lib/server/oauth/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,34 +24,41 @@ Accounts.registerLoginHandler(function(options) {
serviceName: String,
}));

const service = AccessTokenServices[options.serviceName];
// Check if service is configured and therefore a custom OAuth
const config = ServiceConfiguration.configurations.findOne({ service: options.serviceName });

let service = AccessTokenServices[options.serviceName];

if (!service && config) {
service = AccessTokenServices.custom;
}

// 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 (!ServiceConfiguration.configurations.findOne({ service: service.serviceName })) {
if (!config) {
throw new ServiceConfiguration.ConfigError();
}

if (!_.contains(Accounts.oauth.serviceNames(), service.serviceName)) {
if (!_.contains(Accounts.oauth.serviceNames(), options.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 }`
`No registered oauth service found for: ${ options.serviceName }`
),
};
}

const oauthResult = service.handleAccessTokenRequest(options);

return Accounts.updateOrCreateUserFromExternalService(service.serviceName, oauthResult.serviceData, oauthResult.options);
return Accounts.updateOrCreateUserFromExternalService(options.serviceName, oauthResult.serviceData, oauthResult.options);
});


0 comments on commit 63f6e52

Please sign in to comment.