diff --git a/app/lib/server/index.js b/app/lib/server/index.js index 2ca58a15f606b..c4dc9e428fabd 100644 --- a/app/lib/server/index.js +++ b/app/lib/server/index.js @@ -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'; diff --git a/app/lib/server/oauth/custom.js b/app/lib/server/oauth/custom.js new file mode 100644 index 0000000000000..e326d31cf5c79 --- /dev/null +++ b/app/lib/server/oauth/custom.js @@ -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, + }, + }, + }; +}); diff --git a/app/lib/server/oauth/oauth.js b/app/lib/server/oauth/oauth.js index c735ff0cc5b33..68c13c6989c99 100644 --- a/app/lib/server/oauth/oauth.js +++ b/app/lib/server/oauth/oauth.js @@ -24,7 +24,14 @@ 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) { @@ -32,11 +39,11 @@ Accounts.registerLoginHandler(function(options) { } // 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. @@ -44,14 +51,14 @@ Accounts.registerLoginHandler(function(options) { 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); });