From 01c46e4af218193db75825948b692bdd844ea0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Tue, 24 Jul 2018 20:57:13 +0200 Subject: [PATCH 01/12] added an RFC 7662 compliant OAuth2 auth adapter --- src/Adapters/Auth/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 77101935b5..309904c95d 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -16,6 +16,7 @@ const vkontakte = require("./vkontakte"); const qq = require("./qq"); const wechat = require("./wechat"); const weibo = require("./weibo"); +const oauth2 = require("./oauth2"); const anonymous = { validateAuthData: () => { @@ -43,7 +44,8 @@ const providers = { vkontakte, qq, wechat, - weibo + weibo, + oauth2 } function authDataValidator(adapter, appIds, options) { return function(authData) { From b1db2d2930b86d2e0b4151a323199fba6697e73d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Tue, 24 Jul 2018 21:20:45 +0200 Subject: [PATCH 02/12] forgot to add the actual auth adapter to the previous commit --- src/Adapters/Auth/oauth2.js | 204 ++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 src/Adapters/Auth/oauth2.js diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js new file mode 100644 index 0000000000..6a7c34c006 --- /dev/null +++ b/src/Adapters/Auth/oauth2.js @@ -0,0 +1,204 @@ +/* + * This auth adapter is based on the OAuth 2.0 Token Introspection specification. + * See RFC 7662 for details (https://tools.ietf.org/html/rfc7662). + * It's purpose is to validate OAuth2 access tokens using the OAuth2 provider's + * token introspection endpoint (if implemented by the provider). + * + * The adapter accepts the following config parameters: + * + * 1. "token_introspection_endpoint_url" (required) + * The URL of the token introspection endpoint of the OAuth2 provider that + * issued the access token to the client that is to be validated. + * + * 2. "userid_field" (optional) + * The name of the field in the token introspection response that contains + * the userid. If specified, it will be used to verify the value of the "id" + * field in the "authData" JSON that is coming from the client. + * This can be the "aud" (i.e. audience), the "sub" (i.e. subject) or the + * "username" field in the introspection response, but since only the + * "active" field is required and all other reponse fields are optional + * in the RFC, it has to be optional in this adapter as well. + * Default: - (undefined) + * + * 3. "appid_field" (optional) + * The name of the field in the token introspection response that contains + * the appId of the client. If specified, it will be used to verify it's + * value against the set of appIds in the adapter config. The concept of + * appIds comes from the two major social login providers + * (Google and Facebook). They have not yet implemented the token + * introspection endpoint, but the concept can be valid for any OAuth2 + * provider. + * Default: - (undefined) + * + * 4. "appIds" (optional) + * A set of appIds that are used to restrict accepted access tokens based + * on a specific field's value in the token introspection response. + * Default: - (undefined) + * + * 5. "authorization_header" (optional) + * The value of the "Authorization" HTTP header in requests sent to the + * introspection endpoint. + * Eg. "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" + * + * The adapter expects requests with the following authData JSON: + * + * { + * "oauth2": { + * "id": "user's OAuth2 provider-specific id as a string", + * "access_token": "an authorized OAuth2 access token for the user", + * } + * } + */ + +var Https = require('https'); +var Parse = require('parse/node').Parse; +var Url = require('url'); +var Querystring = require('querystring'); +var logger = require('../../logger').default; + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData, options) { + var loggingEnabled = isLoggingEnabled(options); + if (loggingEnabled) { + logger.verbose('oauth2.validateAuthData(): authData'); + logger.verbose(authData); + logger.verbose('oauth2.validateAuthData(): options'); + logger.verbose(options); + } + return requestJson(options, authData.access_token, loggingEnabled).then((response) => { + if ( response && response.active && ( !options || !options.hasOwnProperty('userid_field') || !options.userid_field || authData.id == response[options.userid_field] ) ) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'OAuth2 access token is invalid for this user.'); + }); +} + +function validateAppId(appIds, authData, options) { + var loggingEnabled = isLoggingEnabled(options); + if (loggingEnabled) { + logger.verbose('oauth2.validateAppId(): appIds'); + logger.verbose(appIds); + logger.verbose('oauth2.validateAppId(): authData'); + logger.verbose(authData); + logger.verbose('oauth2.validateAppId(): options'); + logger.verbose(options); + } + if (options && options.hasOwnProperty('appid_field') && options.appid_field) { + if (!appIds.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'); + } + return requestJson(options, authData.access_token, loggingEnabled).then((response) => { + var appidField = options.appid_field; + if (response && response[appidField]) { + var responseValue = response[appidField]; + if (Array.isArray(responseValue)) { + for (idx = responseValue.length - 1; idx >= 0; idx--) { + if (appIds.indexOf(responseValue[idx]) != -1) { + return; + } + } + } else { + if (appIds.indexOf(responseValue) != -1) { + return; + } + } + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'OAuth2: the access_token\'s appID is empty or is not in the list of permitted appIDs in the auth configuration.'); + }); + } else { + return Promise.resolve(); + } +} + +function isLoggingEnabled(options) { + return options && options.debug; +} + +// A promise wrapper for api requests +function requestJson(options, access_token, loggingEnabled) { + if (loggingEnabled) { + logger.verbose('oauth2.requestJson(): options'); + logger.verbose(options); + } + return new Promise(function(resolve, reject) { + if (!options || !options.token_introspection_endpoint_url) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'OAuth2 token introspection endpoint URL is missing from configuration!'); + } + var url = options.token_introspection_endpoint_url; + if (loggingEnabled) { + logger.verbose('oauth2.requestJson(): url = %s', url); + } + var parsedUrl = Url.parse(url); + var postData = Querystring.stringify({ + 'token': access_token + }); + var headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(postData) + } + // Note: the "authorization_header" adapter config must contain the raw value. + // Thus if HTTP Basic authorization is to be used, it must contain the + // base64 encoded version of the concatenated + ":" + string. + if (options.authorization_header) { + headers['Authorization'] = options.authorization_header; + } + var headersStr = JSON.stringify(headers, null, 2); + if (loggingEnabled) { + logger.verbose('oauth2.requestJson(): request headers'); + logger.verbose(headersStr); + logger.verbose('oauth2.requestJson(): request data'); + logger.verbose(postData); + } + var postOptions = { + hostname: parsedUrl.hostname, + path: parsedUrl.pathname, + method: 'POST', + headers: headers + } + var postRequest = Https.request( postOptions, function(res) { + var data = ''; + res.setEncoding('utf8'); + res.on('data', function(chunk) { + data += chunk; + }); + res.on('end', function() { + try { + data = JSON.parse(data); + } catch (e) { + logger.error('oauth2.requestJson(): failed to parse response data from %s as JSON', url); + if (loggingEnabled) { + logger.verbose('oauth2.requestJson(): request headers'); + logger.verbose(headersStr); + logger.verbose('oauth2.requestJson(): request data'); + logger.verbose(postData); + logger.verbose('oauth2.requestJson(): response data'); + logger.verbose(data); + } + return reject(e); + } + if (loggingEnabled) { + logger.verbose('oauth2.requestJson(): JSON response from %s', url); + logger.verbose(data); + } + resolve(data); + }); + }).on('error', function() { + if (loggingEnabled) { + logger.error('oauth2.requestJson(): error while trying to fetch %s', url); + } + reject('Failed to validate access token ' + access_token + ' with OAuth2 provider (url = ' + url + ', headers: ' + headersStr + ')'); + }); + + postRequest.write(postData); + postRequest.end(); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; From eca9e9e03dc7dd9ad656d8b9371c1e5b3db313ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Wed, 25 Jul 2018 02:06:51 +0200 Subject: [PATCH 03/12] fixed lint errors --- src/Adapters/Auth/oauth2.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index 6a7c34c006..27a945d16a 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -66,7 +66,7 @@ function validateAuthData(authData, options) { logger.verbose(options); } return requestJson(options, authData.access_token, loggingEnabled).then((response) => { - if ( response && response.active && ( !options || !options.hasOwnProperty('userid_field') || !options.userid_field || authData.id == response[options.userid_field] ) ) { + if (response && response.active && (!options || !options.hasOwnProperty('userid_field') || !options.userid_field || authData.id == response[options.userid_field])) { return; } throw new Parse.Error( @@ -94,7 +94,7 @@ function validateAppId(appIds, authData, options) { if (response && response[appidField]) { var responseValue = response[appidField]; if (Array.isArray(responseValue)) { - for (idx = responseValue.length - 1; idx >= 0; idx--) { + for (var idx = responseValue.length - 1; idx >= 0; idx--) { if (appIds.indexOf(responseValue[idx]) != -1) { return; } @@ -159,7 +159,7 @@ function requestJson(options, access_token, loggingEnabled) { method: 'POST', headers: headers } - var postRequest = Https.request( postOptions, function(res) { + var postRequest = Https.request(postOptions, function(res) { var data = ''; res.setEncoding('utf8'); res.on('data', function(chunk) { From c7c001b13fe55cbb23ee2115d3cf564a619cb8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Wed, 25 Jul 2018 13:58:47 +0200 Subject: [PATCH 04/12] * added test coverage * changed option names in auth adapter from snake case to camel case * added underscore prefix to helper function names * merged consecutive logger calls into one call and use JSON.stringify() to convert JSON objects to strings * changed error handling (ParseErrors are no longer thrown, but returned) --- spec/AuthenticationAdapters.spec.js | 57 ++++++++++++++++++- src/Adapters/Auth/oauth2.js | 87 ++++++++++++----------------- 2 files changed, 92 insertions(+), 52 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index f7d1d645c1..5d0492d3ac 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -5,7 +5,7 @@ const authenticationLoader = require('../lib/Adapters/Auth'); const path = require('path'); describe('AuthenticationProviders', function() { - ["facebook", "facebookaccountkit", "github", "instagram", "google", "linkedin", "meetup", "twitter", "janrainengage", "janraincapture", "vkontakte"].map(function(providerName){ + ["facebook", "facebookaccountkit", "github", "instagram", "google", "linkedin", "meetup", "twitter", "janrainengage", "janraincapture", "vkontakte", "oauth2"].map(function(providerName){ it("Should validate structure of " + providerName, (done) => { const provider = require("../lib/Adapters/Auth/" + providerName); jequal(typeof provider.validateAuthData, "function"); @@ -411,4 +411,59 @@ describe('AuthenticationProviders', function() { done(); }) }); + + it('properly loads OAuth2 adapter with options', () => { + const options = { + oauth2: { + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + useridField: 'sub', + appidField: 'appId', + appIds: ['a', 'b'], + authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=' + } + }; + const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2', options); + validateAuthenticationAdapter(adapter); + expect(providerOptions.tokenIntrospectionEndpointUrl).toEqual('https://example.com/introspect'); + expect(providerOptions.useridField).toEqual('sub'); + expect(providerOptions.appidField).toEqual('appId'); + expect(providerOptions.authorizationHeader).toEqual('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); + expect(appIds).toEqual(['a', 'b']); + }); + + it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', (done) => { + const options = { + oauth2: { + appIds: ['a', 'b'], + appidField: 'appId' + } + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken' + }; + const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2', options); + adapter.validateAppId(appIds, authData, providerOptions) + .then(done.fail, err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + }) + }); + + it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', (done) => { + const options = { + oauth2: { } + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken' + }; + const {adapter, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2', options); + adapter.validateAuthData(authData, providerOptions) + .then(done.fail, err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + }) + }); + }); diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index 27a945d16a..e4ed636cdb 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -6,11 +6,11 @@ * * The adapter accepts the following config parameters: * - * 1. "token_introspection_endpoint_url" (required) + * 1. "tokenIntrospectionEndpointUrl" (required) * The URL of the token introspection endpoint of the OAuth2 provider that * issued the access token to the client that is to be validated. * - * 2. "userid_field" (optional) + * 2. "useridField" (optional) * The name of the field in the token introspection response that contains * the userid. If specified, it will be used to verify the value of the "id" * field in the "authData" JSON that is coming from the client. @@ -20,7 +20,7 @@ * in the RFC, it has to be optional in this adapter as well. * Default: - (undefined) * - * 3. "appid_field" (optional) + * 3. "appidField" (optional) * The name of the field in the token introspection response that contains * the appId of the client. If specified, it will be used to verify it's * value against the set of appIds in the adapter config. The concept of @@ -35,7 +35,7 @@ * on a specific field's value in the token introspection response. * Default: - (undefined) * - * 5. "authorization_header" (optional) + * 5. "authorizationHeader" (optional) * The value of the "Authorization" HTTP header in requests sent to the * introspection endpoint. * Eg. "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" @@ -58,77 +58,72 @@ var logger = require('../../logger').default; // Returns a promise that fulfills if this user id is valid. function validateAuthData(authData, options) { - var loggingEnabled = isLoggingEnabled(options); + var loggingEnabled = _isLoggingEnabled(options); if (loggingEnabled) { - logger.verbose('oauth2.validateAuthData(): authData'); - logger.verbose(authData); - logger.verbose('oauth2.validateAuthData(): options'); - logger.verbose(options); + logger.verbose('oauth2.validateAuthData(), authData = %s, options = %s', _objectToString(authData), _objectToString(options)); } return requestJson(options, authData.access_token, loggingEnabled).then((response) => { - if (response && response.active && (!options || !options.hasOwnProperty('userid_field') || !options.userid_field || authData.id == response[options.userid_field])) { - return; + if (response && response.active && (!options || !options.hasOwnProperty('useridField') || !options.useridField || authData.id == response[options.useridField])) { + return Promise.resolve(); } - throw new Parse.Error( + return Promise.reject(new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, - 'OAuth2 access token is invalid for this user.'); + 'OAuth2 access token is invalid for this user.')); }); } function validateAppId(appIds, authData, options) { - var loggingEnabled = isLoggingEnabled(options); + var loggingEnabled = _isLoggingEnabled(options); if (loggingEnabled) { - logger.verbose('oauth2.validateAppId(): appIds'); - logger.verbose(appIds); - logger.verbose('oauth2.validateAppId(): authData'); - logger.verbose(authData); - logger.verbose('oauth2.validateAppId(): options'); - logger.verbose(options); + logger.verbose('oauth2.validateAppId(): appIds = %s, authData = %s, options = %s', _objectToString(appIds), _objectToString(authData), _objectToString(options)); } - if (options && options.hasOwnProperty('appid_field') && options.appid_field) { + if (options && options.hasOwnProperty('appidField') && options.appidField) { if (!appIds.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'); + return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).')); } return requestJson(options, authData.access_token, loggingEnabled).then((response) => { - var appidField = options.appid_field; + var appidField = options.appidField; if (response && response[appidField]) { var responseValue = response[appidField]; if (Array.isArray(responseValue)) { for (var idx = responseValue.length - 1; idx >= 0; idx--) { if (appIds.indexOf(responseValue[idx]) != -1) { - return; + return Promise.resolve(); } } } else { if (appIds.indexOf(responseValue) != -1) { - return; + return Promise.resolve(); } } } - throw new Parse.Error( + return Promise.reject(new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, - 'OAuth2: the access_token\'s appID is empty or is not in the list of permitted appIDs in the auth configuration.'); + 'OAuth2: the access_token\'s appID is empty or is not in the list of permitted appIDs in the auth configuration.')); }); } else { return Promise.resolve(); } } -function isLoggingEnabled(options) { +function _isLoggingEnabled(options) { return options && options.debug; } +function _objectToString(object) { + return JSON.stringify(object, null, 2); +} + // A promise wrapper for api requests function requestJson(options, access_token, loggingEnabled) { if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): options'); - logger.verbose(options); + logger.verbose('oauth2.requestJson(): options = %s', _objectToString(options)); } return new Promise(function(resolve, reject) { - if (!options || !options.token_introspection_endpoint_url) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'OAuth2 token introspection endpoint URL is missing from configuration!'); + if (!options || !options.tokenIntrospectionEndpointUrl) { + reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing from configuration!')); } - var url = options.token_introspection_endpoint_url; + var url = options.tokenIntrospectionEndpointUrl; if (loggingEnabled) { logger.verbose('oauth2.requestJson(): url = %s', url); } @@ -140,18 +135,14 @@ function requestJson(options, access_token, loggingEnabled) { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } - // Note: the "authorization_header" adapter config must contain the raw value. + // Note: the "authorizationHeader" adapter config must contain the raw value. // Thus if HTTP Basic authorization is to be used, it must contain the // base64 encoded version of the concatenated + ":" + string. - if (options.authorization_header) { - headers['Authorization'] = options.authorization_header; + if (options.authorizationHeader) { + headers['Authorization'] = options.authorizationHeader; } - var headersStr = JSON.stringify(headers, null, 2); if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): request headers'); - logger.verbose(headersStr); - logger.verbose('oauth2.requestJson(): request data'); - logger.verbose(postData); + logger.verbose('oauth2.requestJson(): req headers = %s, req data = %s', _objectToString(headers), _objectToString(postData)); } var postOptions = { hostname: parsedUrl.hostname, @@ -171,26 +162,20 @@ function requestJson(options, access_token, loggingEnabled) { } catch (e) { logger.error('oauth2.requestJson(): failed to parse response data from %s as JSON', url); if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): request headers'); - logger.verbose(headersStr); - logger.verbose('oauth2.requestJson(): request data'); - logger.verbose(postData); - logger.verbose('oauth2.requestJson(): response data'); - logger.verbose(data); + logger.verbose('oauth2.requestJson(): req headers = %s, req data = %s, resp data = %s', _objectToString(headers), _objectToString(postData), _objectToString(data)); } return reject(e); } if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): JSON response from %s', url); - logger.verbose(data); + logger.verbose('oauth2.requestJson(): JSON response from %s = %s', url, _objectToString(data)); } - resolve(data); + return resolve(data); }); }).on('error', function() { if (loggingEnabled) { logger.error('oauth2.requestJson(): error while trying to fetch %s', url); } - reject('Failed to validate access token ' + access_token + ' with OAuth2 provider (url = ' + url + ', headers: ' + headersStr + ')'); + return reject('Failed to validate access token %s with OAuth2 provider (url = %s, headers = %s)', access_token, url, _objectToString(headers)); }); postRequest.write(postData); From 672f06f2e19787dc2f578847f8b390771303bba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Wed, 25 Jul 2018 14:25:52 +0200 Subject: [PATCH 05/12] added description of the "debug" option and added this option to the tests too --- spec/AuthenticationAdapters.spec.js | 3 ++- src/Adapters/Auth/oauth2.js | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 5d0492d3ac..8696b23e5c 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -419,7 +419,8 @@ describe('AuthenticationProviders', function() { useridField: 'sub', appidField: 'appId', appIds: ['a', 'b'], - authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=' + authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + debug: true } }; const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2', options); diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index e4ed636cdb..fb3bf9dfe1 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -6,11 +6,11 @@ * * The adapter accepts the following config parameters: * - * 1. "tokenIntrospectionEndpointUrl" (required) + * 1. "tokenIntrospectionEndpointUrl" (string, required) * The URL of the token introspection endpoint of the OAuth2 provider that * issued the access token to the client that is to be validated. * - * 2. "useridField" (optional) + * 2. "useridField" (string, optional) * The name of the field in the token introspection response that contains * the userid. If specified, it will be used to verify the value of the "id" * field in the "authData" JSON that is coming from the client. @@ -20,7 +20,7 @@ * in the RFC, it has to be optional in this adapter as well. * Default: - (undefined) * - * 3. "appidField" (optional) + * 3. "appidField" (string, optional) * The name of the field in the token introspection response that contains * the appId of the client. If specified, it will be used to verify it's * value against the set of appIds in the adapter config. The concept of @@ -30,16 +30,19 @@ * provider. * Default: - (undefined) * - * 4. "appIds" (optional) + * 4. "appIds" (array of strings, optional) * A set of appIds that are used to restrict accepted access tokens based * on a specific field's value in the token introspection response. * Default: - (undefined) * - * 5. "authorizationHeader" (optional) + * 5. "authorizationHeader" (string, optional) * The value of the "Authorization" HTTP header in requests sent to the * introspection endpoint. * Eg. "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" * + * 6. "debug" (boolean, optional) + * Enables extensive logging using the "verbose" level. + * * The adapter expects requests with the following authData JSON: * * { From fe157451cb92d11aa547d04c3abf5eb5d8a18230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Wed, 25 Jul 2018 17:56:37 +0200 Subject: [PATCH 06/12] added a check of the "debug" option to the unittests and replaced require() of the logger with an import (the former does not work correctly) --- spec/AuthenticationAdapters.spec.js | 3 ++- src/Adapters/Auth/oauth2.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 8696b23e5c..981e62b94d 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -428,8 +428,9 @@ describe('AuthenticationProviders', function() { expect(providerOptions.tokenIntrospectionEndpointUrl).toEqual('https://example.com/introspect'); expect(providerOptions.useridField).toEqual('sub'); expect(providerOptions.appidField).toEqual('appId'); - expect(providerOptions.authorizationHeader).toEqual('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); expect(appIds).toEqual(['a', 'b']); + expect(providerOptions.authorizationHeader).toEqual('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); + expect(providerOptions.debug).toEqual(true); }); it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', (done) => { diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index fb3bf9dfe1..339d16571d 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -53,11 +53,11 @@ * } */ +import logger from '../../logger'; var Https = require('https'); var Parse = require('parse/node').Parse; var Url = require('url'); var Querystring = require('querystring'); -var logger = require('../../logger').default; // Returns a promise that fulfills if this user id is valid. function validateAuthData(authData, options) { From 53a26a9efa9efd6132924f0d8e939f415ee206fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Mon, 30 Jul 2018 04:07:52 +0200 Subject: [PATCH 07/12] added AuthAdapter based auth adapter runtime validation to src/Adapters/Auth/index.js, added capability to define arbitrary providernames with an "adapter" property in auth config, replaced various "var" keywords with "const" in oauth2.js --- spec/AuthenticationAdapters.spec.js | 19 +++++-- src/Adapters/Auth/AuthAdapter.js | 3 +- src/Adapters/Auth/index.js | 62 +++++++++++++++++--- src/Adapters/Auth/oauth2.js | 87 +++++++++++++++++++++-------- 4 files changed, 134 insertions(+), 37 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 981e62b94d..ca0217ab1e 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -10,11 +10,11 @@ describe('AuthenticationProviders', function() { const provider = require("../lib/Adapters/Auth/" + providerName); jequal(typeof provider.validateAuthData, "function"); jequal(typeof provider.validateAppId, "function"); - const authDataPromise = provider.validateAuthData({}, {}); + const validateAuthDataPromise = provider.validateAuthData({}, {}); const validateAppIdPromise = provider.validateAppId("app", "key", {}); - jequal(authDataPromise.constructor, Promise.prototype.constructor); + jequal(validateAuthDataPromise.constructor, Promise.prototype.constructor); jequal(validateAppIdPromise.constructor, Promise.prototype.constructor); - authDataPromise.then(()=>{}, ()=>{}); + validateAuthDataPromise.then(()=>{}, ()=>{}); validateAppIdPromise.then(()=>{}, ()=>{}); done(); }); @@ -233,7 +233,7 @@ describe('AuthenticationProviders', function() { function validateAuthenticationHandler(authenticationHandler) { expect(authenticationHandler).not.toBeUndefined(); expect(typeof authenticationHandler.getValidatorForProvider).toBe('function'); - expect(typeof authenticationHandler.getValidatorForProvider).toBe('function'); + expect(typeof authenticationHandler.setEnableAnonymousUsers).toBe('function'); } function validateAuthenticationAdapter(authAdapter) { @@ -320,6 +320,17 @@ describe('AuthenticationProviders', function() { }) }); + it('properly loads provider with adapter name', () => { + const options = { + myAuthentication: { + adapter: 'facebook' + } + }; + const loadedAuthAdapter1 = authenticationLoader.loadAuthAdapter('myAuthentication', options, 'facebook'); + const loadedAuthAdapter2 = authenticationLoader.loadAuthAdapter('facebook', options); + expect(loadedAuthAdapter1.adapter).toEqual(loadedAuthAdapter2.adapter); + }); + it('properly loads a default adapter with options', () => { const options = { facebook: { diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index dd8fd838c3..c9401dcfe9 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -4,9 +4,10 @@ export class AuthAdapter { /* @param appIds: the specified app ids in the configuration @param authData: the client provided authData + @param options: additional options @returns a promise that resolves if the applicationId is valid */ - validateAppId(appIds, authData) { + validateAppId(appIds, authData, options) { return Promise.resolve({}); } diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 309904c95d..ffd084c75b 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,3 +1,4 @@ +import AuthAdapter from './AuthAdapter'; import loadAdapter from '../AdapterLoader'; const facebook = require('./facebook'); @@ -27,7 +28,7 @@ const anonymous = { } } -const providers = { +const adapters = { facebook, facebookaccountkit, instagram, @@ -47,6 +48,7 @@ const providers = { weibo, oauth2 } + function authDataValidator(adapter, appIds, options) { return function(authData) { return adapter.validateAuthData(authData, options).then(() => { @@ -58,10 +60,41 @@ function authDataValidator(adapter, appIds, options) { } } -function loadAuthAdapter(provider, authOptions) { - const defaultAdapter = providers[provider]; +function providerName2adapterName(providerName, authOptions) { + let adapterName = providerName; + if (authOptions && authOptions.hasOwnProperty(providerName)) { + const providerOptions = authOptions[providerName]; + if (providerOptions && providerOptions.hasOwnProperty('adapter')) { + adapterName = providerOptions['adapter']; + } + } + return adapterName; +} + +function validateAdapter(adapter) { + const mismatches = Object.getOwnPropertyNames(AuthAdapter.prototype).reduce((obj, key) => { + const adapterType = typeof adapter[key]; + const expectedType = typeof AuthAdapter.prototype[key]; + if (adapterType !== expectedType) { + obj[key] = { + expected: expectedType, + actual: adapterType + } + } + return obj; + }, {}); + + let ret = null; + if (Object.keys(mismatches).length > 0) { + ret = new Error('Adapter prototype doesn\'t match expected prototype', adapter, mismatches); + } + return ret; +} + +function loadAuthAdapter(providerName, authOptions, adapterName) { + const defaultAdapter = adapters[adapterName ? adapterName : providerName]; const adapter = Object.assign({}, defaultAdapter); - const providerOptions = authOptions[provider]; + const providerOptions = authOptions[providerName]; if (!defaultAdapter && !providerOptions) { return; @@ -71,6 +104,9 @@ function loadAuthAdapter(provider, authOptions) { // Try the configuration methods if (providerOptions) { + if (providerOptions.hasOwnProperty('adapter')) { + delete providerOptions['adapter']; + } const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { ['validateAuthData', 'validateAppId'].forEach((key) => { @@ -81,7 +117,7 @@ function loadAuthAdapter(provider, authOptions) { } } - if (!adapter.validateAuthData || !adapter.validateAppId) { + if (validateAdapter(adapter)) { return; } @@ -93,10 +129,20 @@ module.exports = function(authOptions = {}, enableAnonymousUsers = true) { const setEnableAnonymousUsers = function(enable) { _enableAnonymousUsers = enable; } + + for (var adapterName in adapters) { + const adapter = adapters[adapterName]; + const validationError = validateAdapter(adapter); + if (validationError) { + throw validationError; + } + } + // To handle the test cases on configuration - const getValidatorForProvider = function(provider) { + const getValidatorForProvider = function(providerName) { + const adapterName = providerName2adapterName(providerName); - if (provider === 'anonymous' && !_enableAnonymousUsers) { + if (adapterName === 'anonymous' && !_enableAnonymousUsers) { return; } @@ -104,7 +150,7 @@ module.exports = function(authOptions = {}, enableAnonymousUsers = true) { adapter, appIds, providerOptions - } = loadAuthAdapter(provider, authOptions); + } = loadAuthAdapter(providerName, authOptions, adapterName); return authDataValidator(adapter, appIds, providerOptions); } diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index 339d16571d..143a16f699 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -54,16 +54,20 @@ */ import logger from '../../logger'; -var Https = require('https'); -var Parse = require('parse/node').Parse; -var Url = require('url'); -var Querystring = require('querystring'); +const Https = require('https'); +const Parse = require('parse/node').Parse; +const Path = require('path'); +const Url = require('url'); +const Querystring = require('querystring'); + +const scriptName = Path.basename(__filename, '.js'); // Returns a promise that fulfills if this user id is valid. function validateAuthData(authData, options) { - var loggingEnabled = _isLoggingEnabled(options); + const _logger = _getLogger(validateAuthData.name); + const loggingEnabled = _isLoggingEnabled(options); if (loggingEnabled) { - logger.verbose('oauth2.validateAuthData(), authData = %s, options = %s', _objectToString(authData), _objectToString(options)); + _logger.verbose('authData = %s, options = %s', _objectToString(authData), _objectToString(options)); } return requestJson(options, authData.access_token, loggingEnabled).then((response) => { if (response && response.active && (!options || !options.hasOwnProperty('useridField') || !options.useridField || authData.id == response[options.useridField])) { @@ -76,18 +80,19 @@ function validateAuthData(authData, options) { } function validateAppId(appIds, authData, options) { - var loggingEnabled = _isLoggingEnabled(options); + const _logger = _getLogger(validateAppId.name); + const loggingEnabled = _isLoggingEnabled(options); if (loggingEnabled) { - logger.verbose('oauth2.validateAppId(): appIds = %s, authData = %s, options = %s', _objectToString(appIds), _objectToString(authData), _objectToString(options)); + _logger.verbose('appIds = %s, authData = %s, options = %s', _objectToString(appIds), _objectToString(authData), _objectToString(options)); } if (options && options.hasOwnProperty('appidField') && options.appidField) { if (!appIds.length) { return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).')); } return requestJson(options, authData.access_token, loggingEnabled).then((response) => { - var appidField = options.appidField; + const appidField = options.appidField; if (response && response[appidField]) { - var responseValue = response[appidField]; + const responseValue = response[appidField]; if (Array.isArray(responseValue)) { for (var idx = responseValue.length - 1; idx >= 0; idx--) { if (appIds.indexOf(responseValue[idx]) != -1) { @@ -117,24 +122,58 @@ function _objectToString(object) { return JSON.stringify(object, null, 2); } +function _getLogger(functionName) { + const prefix = scriptName + '.' + functionName + '(): '; + var getFormat = function(arg) { + return (new Date()).toISOString() + ' ' + prefix + arg; + } + return { + info: function() { + arguments[0] = getFormat(arguments[0]); + return logger.info.apply(logger, arguments); + }, + error: function () { + arguments[0] = getFormat(arguments[0]); + return logger.error.apply(logger, arguments); + }, + warn: function() { + arguments[0] = getFormat(arguments[0]); + return logger.warn.apply(logger, arguments); + }, + verbose: function() { + arguments[0] = getFormat(arguments[0]); + return logger.verbose.apply(logger, arguments); + }, + debug: function() { + arguments[0] = getFormat(arguments[0]); + return logger.debug.apply(logger, arguments); + }, + silly: function() { + arguments[0] = getFormat(arguments[0]); + return logger.silly.apply(logger, arguments); + } + }; +} + // A promise wrapper for api requests function requestJson(options, access_token, loggingEnabled) { + const _logger = _getLogger(requestJson.name); if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): options = %s', _objectToString(options)); + _logger.verbose('options = %s', _objectToString(options)); } return new Promise(function(resolve, reject) { if (!options || !options.tokenIntrospectionEndpointUrl) { reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing from configuration!')); } - var url = options.tokenIntrospectionEndpointUrl; + const url = options.tokenIntrospectionEndpointUrl; if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): url = %s', url); + _logger.verbose('url = %s', url); } - var parsedUrl = Url.parse(url); - var postData = Querystring.stringify({ + const parsedUrl = Url.parse(url); + const postData = Querystring.stringify({ 'token': access_token }); - var headers = { + const headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } @@ -145,16 +184,16 @@ function requestJson(options, access_token, loggingEnabled) { headers['Authorization'] = options.authorizationHeader; } if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): req headers = %s, req data = %s', _objectToString(headers), _objectToString(postData)); + _logger.verbose('req headers = %s, req data = %s', _objectToString(headers), _objectToString(postData)); } - var postOptions = { + const postOptions = { hostname: parsedUrl.hostname, path: parsedUrl.pathname, method: 'POST', headers: headers } - var postRequest = Https.request(postOptions, function(res) { - var data = ''; + const postRequest = Https.request(postOptions, function(res) { + let data = ''; res.setEncoding('utf8'); res.on('data', function(chunk) { data += chunk; @@ -163,20 +202,20 @@ function requestJson(options, access_token, loggingEnabled) { try { data = JSON.parse(data); } catch (e) { - logger.error('oauth2.requestJson(): failed to parse response data from %s as JSON', url); + _logger.error('failed to parse response data from %s as JSON', url); if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): req headers = %s, req data = %s, resp data = %s', _objectToString(headers), _objectToString(postData), _objectToString(data)); + _logger.verbose('req headers = %s, req data = %s, resp data = %s', _objectToString(headers), _objectToString(postData), _objectToString(data)); } return reject(e); } if (loggingEnabled) { - logger.verbose('oauth2.requestJson(): JSON response from %s = %s', url, _objectToString(data)); + _logger.verbose('JSON response from %s = %s', url, _objectToString(data)); } return resolve(data); }); }).on('error', function() { if (loggingEnabled) { - logger.error('oauth2.requestJson(): error while trying to fetch %s', url); + _logger.error('error while trying to fetch %s', url); } return reject('Failed to validate access token %s with OAuth2 provider (url = %s, headers = %s)', access_token, url, _objectToString(headers)); }); From b9d523a99e2d8d03b4e485cd9ccd3ba329a6b17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Tue, 31 Jul 2018 02:35:48 +0200 Subject: [PATCH 08/12] incorporated changes requested by flovilmart (mainly that oauth2 is now not a standalone adapter, but can be selected by setting the "oauth2" property to true in auth config --- spec/AuthenticationAdapters.spec.js | 38 ++++++++++++++----------- src/Adapters/Auth/index.js | 44 +++++++++++------------------ 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index ca0217ab1e..1a1dc96c52 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -320,17 +320,6 @@ describe('AuthenticationProviders', function() { }) }); - it('properly loads provider with adapter name', () => { - const options = { - myAuthentication: { - adapter: 'facebook' - } - }; - const loadedAuthAdapter1 = authenticationLoader.loadAuthAdapter('myAuthentication', options, 'facebook'); - const loadedAuthAdapter2 = authenticationLoader.loadAuthAdapter('facebook', options); - expect(loadedAuthAdapter1.adapter).toEqual(loadedAuthAdapter2.adapter); - }); - it('properly loads a default adapter with options', () => { const options = { facebook: { @@ -423,9 +412,21 @@ describe('AuthenticationProviders', function() { }) }); + it('properly loads OAuth2 adapter via the "oauth2" option', () => { + const options = { + oauth2Authentication: { + oauth2: true + } + }; + const oauth2Adapter = require("../lib/Adapters/Auth/oauth2"); + const loadedAuthAdapter = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + expect(loadedAuthAdapter.adapter).toEqual(oauth2Adapter); + }); + it('properly loads OAuth2 adapter with options', () => { const options = { - oauth2: { + oauth2Authentication: { + oauth2: true, tokenIntrospectionEndpointUrl: 'https://example.com/introspect', useridField: 'sub', appidField: 'appId', @@ -434,7 +435,7 @@ describe('AuthenticationProviders', function() { debug: true } }; - const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2', options); + const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); validateAuthenticationAdapter(adapter); expect(providerOptions.tokenIntrospectionEndpointUrl).toEqual('https://example.com/introspect'); expect(providerOptions.useridField).toEqual('sub'); @@ -446,7 +447,8 @@ describe('AuthenticationProviders', function() { it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', (done) => { const options = { - oauth2: { + oauth2Authentication: { + oauth2: true, appIds: ['a', 'b'], appidField: 'appId' } @@ -455,7 +457,7 @@ describe('AuthenticationProviders', function() { id: 'fakeid', access_token: 'sometoken' }; - const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2', options); + const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); adapter.validateAppId(appIds, authData, providerOptions) .then(done.fail, err => { expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); @@ -465,13 +467,15 @@ describe('AuthenticationProviders', function() { it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', (done) => { const options = { - oauth2: { } + oauth2Authentication: { + oauth2: true + } }; const authData = { id: 'fakeid', access_token: 'sometoken' }; - const {adapter, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2', options); + const {adapter, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); adapter.validateAuthData(authData, providerOptions) .then(done.fail, err => { expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index ffd084c75b..fb124cf323 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -28,7 +28,7 @@ const anonymous = { } } -const adapters = { +const providers = { facebook, facebookaccountkit, instagram, @@ -45,8 +45,7 @@ const adapters = { vkontakte, qq, wechat, - weibo, - oauth2 + weibo } function authDataValidator(adapter, appIds, options) { @@ -60,17 +59,6 @@ function authDataValidator(adapter, appIds, options) { } } -function providerName2adapterName(providerName, authOptions) { - let adapterName = providerName; - if (authOptions && authOptions.hasOwnProperty(providerName)) { - const providerOptions = authOptions[providerName]; - if (providerOptions && providerOptions.hasOwnProperty('adapter')) { - adapterName = providerOptions['adapter']; - } - } - return adapterName; -} - function validateAdapter(adapter) { const mismatches = Object.getOwnPropertyNames(AuthAdapter.prototype).reduce((obj, key) => { const adapterType = typeof adapter[key]; @@ -91,22 +79,24 @@ function validateAdapter(adapter) { return ret; } -function loadAuthAdapter(providerName, authOptions, adapterName) { - const defaultAdapter = adapters[adapterName ? adapterName : providerName]; - const adapter = Object.assign({}, defaultAdapter); - const providerOptions = authOptions[providerName]; +function loadAuthAdapter(provider, authOptions) { + let defaultAdapter = providers[provider]; + const providerOptions = authOptions[provider]; + if (providerOptions && providerOptions.hasOwnProperty('oauth2')) { + if (providerOptions['oauth2'] === true) { + defaultAdapter = oauth2; + } + } if (!defaultAdapter && !providerOptions) { return; } + const adapter = Object.assign({}, defaultAdapter); const appIds = providerOptions ? providerOptions.appIds : undefined; // Try the configuration methods if (providerOptions) { - if (providerOptions.hasOwnProperty('adapter')) { - delete providerOptions['adapter']; - } const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { ['validateAuthData', 'validateAppId'].forEach((key) => { @@ -130,8 +120,8 @@ module.exports = function(authOptions = {}, enableAnonymousUsers = true) { _enableAnonymousUsers = enable; } - for (var adapterName in adapters) { - const adapter = adapters[adapterName]; + for (var prov in providers) { + const adapter = providers[prov]; const validationError = validateAdapter(adapter); if (validationError) { throw validationError; @@ -139,10 +129,8 @@ module.exports = function(authOptions = {}, enableAnonymousUsers = true) { } // To handle the test cases on configuration - const getValidatorForProvider = function(providerName) { - const adapterName = providerName2adapterName(providerName); - - if (adapterName === 'anonymous' && !_enableAnonymousUsers) { + const getValidatorForProvider = function(provider) { + if (provider === 'anonymous' && !_enableAnonymousUsers) { return; } @@ -150,7 +138,7 @@ module.exports = function(authOptions = {}, enableAnonymousUsers = true) { adapter, appIds, providerOptions - } = loadAuthAdapter(providerName, authOptions, adapterName); + } = loadAuthAdapter(provider, authOptions); return authDataValidator(adapter, appIds, providerOptions); } From 498b0a1562388efd42da0edfcaf45f1f04622416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Sat, 6 Oct 2018 03:09:42 +0200 Subject: [PATCH 09/12] modified oauth2 adapter as requested by flovilmart --- spec/AuthenticationAdapters.spec.js | 83 +++++++----- src/Adapters/Auth/oauth2.js | 194 +++++++++------------------- 2 files changed, 112 insertions(+), 165 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index fcc4a4af92..a2a734f247 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -39,7 +39,10 @@ describe('AuthenticationProviders', function() { jequal(typeof provider.validateAppId, 'function'); const validateAuthDataPromise = provider.validateAuthData({}, {}); const validateAppIdPromise = provider.validateAppId('app', 'key', {}); - jequal(validateAuthDataPromise.constructor, Promise.prototype.constructor); + jequal( + validateAuthDataPromise.constructor, + Promise.prototype.constructor + ); jequal(validateAppIdPromise.constructor, Promise.prototype.constructor); validateAuthDataPromise.then(() => {}, () => {}); validateAppIdPromise.then(() => {}, () => {}); @@ -583,16 +586,22 @@ describe('google auth adapter', () => { expect(e.message).toBe('Google auth is invalid for this user.'); } }); +}); + +describe('oauth2 auth adapter', () => { + const oauth2 = require('../lib/Adapters/Auth/oauth2'); it('properly loads OAuth2 adapter via the "oauth2" option', () => { const options = { oauth2Authentication: { - oauth2: true - } + oauth2: true, + }, }; - const oauth2Adapter = require("../lib/Adapters/Auth/oauth2"); - const loadedAuthAdapter = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); - expect(loadedAuthAdapter.adapter).toEqual(oauth2Adapter); + const loadedAuthAdapter = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + expect(loadedAuthAdapter.adapter).toEqual(oauth2); }); it('properly loads OAuth2 adapter with options', () => { @@ -604,55 +613,69 @@ describe('google auth adapter', () => { appidField: 'appId', appIds: ['a', 'b'], authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', - debug: true - } + debug: true, + }, }; - const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); - validateAuthenticationAdapter(adapter); - expect(providerOptions.tokenIntrospectionEndpointUrl).toEqual('https://example.com/introspect'); + const loadedAuthAdapter = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + const appIds = loadedAuthAdapter.appIds; + const providerOptions = loadedAuthAdapter.providerOptions; + expect(providerOptions.tokenIntrospectionEndpointUrl).toEqual( + 'https://example.com/introspect' + ); expect(providerOptions.useridField).toEqual('sub'); expect(providerOptions.appidField).toEqual('appId'); expect(appIds).toEqual(['a', 'b']); - expect(providerOptions.authorizationHeader).toEqual('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); + expect(providerOptions.authorizationHeader).toEqual( + 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=' + ); expect(providerOptions.debug).toEqual(true); }); - it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', (done) => { + it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', done => { const options = { oauth2Authentication: { oauth2: true, appIds: ['a', 'b'], - appidField: 'appId' - } + appidField: 'appId', + }, }; const authData = { id: 'fakeid', - access_token: 'sometoken' + access_token: 'sometoken', }; - const {adapter, appIds, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); - adapter.validateAppId(appIds, authData, providerOptions) + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + adapter + .validateAppId(appIds, authData, providerOptions) .then(done.fail, err => { expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); done(); - }) + }); }); - it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', (done) => { + it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', done => { const options = { oauth2Authentication: { - oauth2: true - } + oauth2: true, + }, }; const authData = { id: 'fakeid', - access_token: 'sometoken' + access_token: 'sometoken', }; - const {adapter, providerOptions} = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); - adapter.validateAuthData(authData, providerOptions) - .then(done.fail, err => { - expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); - done(); - }) + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + adapter.validateAuthData(authData, providerOptions).then(done.fail, err => { + expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); + done(); + }); }); - }); diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index 143a16f699..4c3c873071 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -37,7 +37,10 @@ * * 5. "authorizationHeader" (string, optional) * The value of the "Authorization" HTTP header in requests sent to the - * introspection endpoint. + * introspection endpoint. It must contain the raw value. + * Thus if HTTP Basic authorization is to be used, it must contain the + * "Basic" string, followed by whitespace, then by the base64 encoded + * version of the concatenated + ":" + string. * Eg. "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" * * 6. "debug" (boolean, optional) @@ -46,7 +49,7 @@ * The adapter expects requests with the following authData JSON: * * { - * "oauth2": { + * "someadapter": { * "id": "user's OAuth2 provider-specific id as a string", * "access_token": "an authorized OAuth2 access token for the user", * } @@ -54,178 +57,99 @@ */ import logger from '../../logger'; -const Https = require('https'); const Parse = require('parse/node').Parse; -const Path = require('path'); -const Url = require('url'); -const Querystring = require('querystring'); - -const scriptName = Path.basename(__filename, '.js'); +const url = require('url'); +const querystring = require('querystring'); +const httpsRequest = require('./httpsRequest'); // Returns a promise that fulfills if this user id is valid. function validateAuthData(authData, options) { - const _logger = _getLogger(validateAuthData.name); - const loggingEnabled = _isLoggingEnabled(options); - if (loggingEnabled) { - _logger.verbose('authData = %s, options = %s', _objectToString(authData), _objectToString(options)); - } - return requestJson(options, authData.access_token, loggingEnabled).then((response) => { - if (response && response.active && (!options || !options.hasOwnProperty('useridField') || !options.useridField || authData.id == response[options.useridField])) { - return Promise.resolve(); + return requestTokenInfo(options, authData.access_token).then(response => { + if ( + response && + response.active && + (!options || + !options.hasOwnProperty('useridField') || + !options.useridField || + authData.id == response[options.useridField]) + ) { + return; } - return Promise.reject(new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'OAuth2 access token is invalid for this user.')); + const errorMessage = 'OAuth2 access token is invalid for this user.'; + logger.error(errorMessage); + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, errorMessage); }); } function validateAppId(appIds, authData, options) { - const _logger = _getLogger(validateAppId.name); - const loggingEnabled = _isLoggingEnabled(options); - if (loggingEnabled) { - _logger.verbose('appIds = %s, authData = %s, options = %s', _objectToString(appIds), _objectToString(authData), _objectToString(options)); - } - if (options && options.hasOwnProperty('appidField') && options.appidField) { + if ( + !(options && options.hasOwnProperty('appidField') && options.appidField) + ) { + return Promise.resolve(); + } else { if (!appIds.length) { - return Promise.reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).')); + const errorMessage = + 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'; + logger.error(errorMessage); + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, errorMessage); } - return requestJson(options, authData.access_token, loggingEnabled).then((response) => { + return requestTokenInfo(options, authData.access_token).then(response => { const appidField = options.appidField; if (response && response[appidField]) { const responseValue = response[appidField]; if (Array.isArray(responseValue)) { - for (var idx = responseValue.length - 1; idx >= 0; idx--) { - if (appIds.indexOf(responseValue[idx]) != -1) { - return Promise.resolve(); - } + if ( + typeof responseValue.find(function(element) { + return appIds.includes(element); + }) !== 'undefined' + ) { + return; } } else { - if (appIds.indexOf(responseValue) != -1) { - return Promise.resolve(); + if (appIds.includes(responseValue)) { + return; } } } - return Promise.reject(new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'OAuth2: the access_token\'s appID is empty or is not in the list of permitted appIDs in the auth configuration.')); + const errorMessage2 = + "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration."; + logger.error(errorMessage2); + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, errorMessage2); }); - } else { - return Promise.resolve(); - } -} - -function _isLoggingEnabled(options) { - return options && options.debug; -} - -function _objectToString(object) { - return JSON.stringify(object, null, 2); -} - -function _getLogger(functionName) { - const prefix = scriptName + '.' + functionName + '(): '; - var getFormat = function(arg) { - return (new Date()).toISOString() + ' ' + prefix + arg; } - return { - info: function() { - arguments[0] = getFormat(arguments[0]); - return logger.info.apply(logger, arguments); - }, - error: function () { - arguments[0] = getFormat(arguments[0]); - return logger.error.apply(logger, arguments); - }, - warn: function() { - arguments[0] = getFormat(arguments[0]); - return logger.warn.apply(logger, arguments); - }, - verbose: function() { - arguments[0] = getFormat(arguments[0]); - return logger.verbose.apply(logger, arguments); - }, - debug: function() { - arguments[0] = getFormat(arguments[0]); - return logger.debug.apply(logger, arguments); - }, - silly: function() { - arguments[0] = getFormat(arguments[0]); - return logger.silly.apply(logger, arguments); - } - }; } -// A promise wrapper for api requests -function requestJson(options, access_token, loggingEnabled) { - const _logger = _getLogger(requestJson.name); - if (loggingEnabled) { - _logger.verbose('options = %s', _objectToString(options)); - } - return new Promise(function(resolve, reject) { +// A promise wrapper for requests to the OAuth2 token introspection endpoint. +function requestTokenInfo(options, access_token) { + return new Promise(() => { if (!options || !options.tokenIntrospectionEndpointUrl) { - reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing from configuration!')); - } - const url = options.tokenIntrospectionEndpointUrl; - if (loggingEnabled) { - _logger.verbose('url = %s', url); + const errorMessage = + 'OAuth2 token introspection endpoint URL is missing from configuration!'; + logger.error(errorMessage); + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, errorMessage); } - const parsedUrl = Url.parse(url); - const postData = Querystring.stringify({ - 'token': access_token + const parsedUrl = url.parse(options.tokenIntrospectionEndpointUrl); + const postData = querystring.stringify({ + token: access_token, }); const headers = { 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(postData) - } - // Note: the "authorizationHeader" adapter config must contain the raw value. - // Thus if HTTP Basic authorization is to be used, it must contain the - // base64 encoded version of the concatenated + ":" + string. + 'Content-Length': Buffer.byteLength(postData), + }; if (options.authorizationHeader) { headers['Authorization'] = options.authorizationHeader; } - if (loggingEnabled) { - _logger.verbose('req headers = %s, req data = %s', _objectToString(headers), _objectToString(postData)); - } const postOptions = { hostname: parsedUrl.hostname, path: parsedUrl.pathname, method: 'POST', - headers: headers - } - const postRequest = Https.request(postOptions, function(res) { - let data = ''; - res.setEncoding('utf8'); - res.on('data', function(chunk) { - data += chunk; - }); - res.on('end', function() { - try { - data = JSON.parse(data); - } catch (e) { - _logger.error('failed to parse response data from %s as JSON', url); - if (loggingEnabled) { - _logger.verbose('req headers = %s, req data = %s, resp data = %s', _objectToString(headers), _objectToString(postData), _objectToString(data)); - } - return reject(e); - } - if (loggingEnabled) { - _logger.verbose('JSON response from %s = %s', url, _objectToString(data)); - } - return resolve(data); - }); - }).on('error', function() { - if (loggingEnabled) { - _logger.error('error while trying to fetch %s', url); - } - return reject('Failed to validate access token %s with OAuth2 provider (url = %s, headers = %s)', access_token, url, _objectToString(headers)); - }); - - postRequest.write(postData); - postRequest.end(); + headers: headers, + }; + return httpsRequest.request(postOptions, postData); }); } module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData + validateAuthData: validateAuthData, }; From 9e3570f5648aded4ef29c5a81ce572065b1db5bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Sat, 6 Oct 2018 04:16:23 +0200 Subject: [PATCH 10/12] bugfix: defaultAdapter can be null in loadAuthAdapter() of index.js (my change broke the tests) --- src/Adapters/Auth/index.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index ddeb6efb68..14e2e9490a 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -61,11 +61,15 @@ function authDataValidator(adapter, appIds, options) { function loadAuthAdapter(provider, authOptions) { let defaultAdapter = providers[provider]; const providerOptions = authOptions[provider]; - if (providerOptions && providerOptions.hasOwnProperty('oauth2') && providerOptions['oauth2'] === true) { + if ( + providerOptions && + providerOptions.hasOwnProperty('oauth2') && + providerOptions['oauth2'] === true + ) { defaultAdapter = oauth2; } - if (!defaultAdapter) { + if (!defaultAdapter && !providerOptions) { return; } From 6b7fb4accef3c22cd6748e45ed10f19988913a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20M=C3=9CLLER?= Date: Sat, 6 Oct 2018 04:57:06 +0200 Subject: [PATCH 11/12] added TODO on need for a validateAdapter() to validate auth adapters --- src/Adapters/Auth/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 14e2e9490a..426c513185 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -92,6 +92,10 @@ function loadAuthAdapter(provider, authOptions) { } } + // TODO: create a new module from validateAdapter() in + // src/Controllers/AdaptableController.js so we can use it here for adapter + // validation based on the src/Adapters/Auth/AuthAdapter.js expected class + // signature. if (!adapter.validateAuthData || !adapter.validateAppId) { return; } From 81cac2c665a9b5070485d0ca88d4b5f84f144bac Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Wed, 3 Apr 2019 15:38:08 -0500 Subject: [PATCH 12/12] test cases and cleanup --- spec/AuthenticationAdapters.spec.js | 374 +++++++++++++++++++++++++++- src/Adapters/Auth/oauth2.js | 130 +++++----- 2 files changed, 421 insertions(+), 83 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index a2a734f247..9214506b7b 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -590,6 +590,7 @@ describe('google auth adapter', () => { describe('oauth2 auth adapter', () => { const oauth2 = require('../lib/Adapters/Auth/oauth2'); + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); it('properly loads OAuth2 adapter via the "oauth2" option', () => { const options = { @@ -634,7 +635,7 @@ describe('oauth2 auth adapter', () => { expect(providerOptions.debug).toEqual(true); }); - it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', done => { + it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => { const options = { oauth2Authentication: { oauth2: true, @@ -651,15 +652,248 @@ describe('oauth2 auth adapter', () => { appIds, providerOptions, } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); - adapter - .validateAppId(appIds, authData, providerOptions) - .then(done.fail, err => { - expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); - done(); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + 'OAuth2 token introspection endpoint URL is missing from configuration!' + ); + } + }); + + it('validateAppId appidField optional', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + // Should not reach here + fail(e); + } + }); + + it('validateAppId should fail without appIds', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).' + ); + } + }); + + it('validateAppId should fail empty appIds', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: [], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).' + ); + } + }); + + it('validateAppId invalid accessToken', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({}); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe('OAuth2 access token is invalid for this user.'); + } + }); + + it('validateAppId invalid accessToken appId', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ active: true }); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration." + ); + } + }); + + it('validateAppId valid accessToken appId', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + appId: 'a', }); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + // Should not enter here + fail(e); + } + }); + + it('validateAppId valid accessToken appId array', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + appId: ['a'], + }); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + // Should not enter here + fail(e); + } + }); + + it('validateAppId valid accessToken invalid appId', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { + adapter, + appIds, + providerOptions, + } = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + appId: 'unknown', + }); + }); + try { + await adapter.validateAppId(appIds, authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration." + ); + } }); - it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', done => { + it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => { const options = { oauth2Authentication: { oauth2: true, @@ -673,9 +907,129 @@ describe('oauth2 auth adapter', () => { 'oauth2Authentication', options ); - adapter.validateAuthData(authData, providerOptions).then(done.fail, err => { - expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); - done(); + try { + await adapter.validateAuthData(authData, providerOptions); + } catch (e) { + expect(e.message).toBe( + 'OAuth2 token introspection endpoint URL is missing from configuration!' + ); + } + }); + + it('validateAuthData invalid accessToken', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + useridField: 'sub', + appidField: 'appId', + appIds: ['a', 'b'], + authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({}); + }); + try { + await adapter.validateAuthData(authData, providerOptions); + } catch (e) { + expect(e.message).toBe('OAuth2 access token is invalid for this user.'); + } + expect(httpsRequest.request).toHaveBeenCalledWith( + { + hostname: 'example.com', + path: '/introspect', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': 15, + Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + }, + }, + 'token=sometoken' + ); + }); + + it('validateAuthData valid accessToken', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + useridField: 'sub', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + sub: 'fakeid', + }); + }); + try { + await adapter.validateAuthData(authData, providerOptions); + } catch (e) { + // Should not enter here + fail(e); + } + expect(httpsRequest.request).toHaveBeenCalledWith( + { + hostname: 'example.com', + path: '/introspect', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': 15, + }, + }, + 'token=sometoken' + ); + }); + + it('validateAuthData valid accessToken without useridField', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + appidField: 'appId', + appIds: ['a', 'b'], + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + spyOn(httpsRequest, 'request').and.callFake(() => { + return Promise.resolve({ + active: true, + sub: 'fakeid', + }); }); + try { + await adapter.validateAuthData(authData, providerOptions); + } catch (e) { + // Should not enter here + fail(e); + } }); }); diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index 4c3c873071..80564d5b32 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -30,7 +30,7 @@ * provider. * Default: - (undefined) * - * 4. "appIds" (array of strings, optional) + * 4. "appIds" (array of strings, required if appidField is defined) * A set of appIds that are used to restrict accepted access tokens based * on a specific field's value in the token introspection response. * Default: - (undefined) @@ -43,9 +43,6 @@ * version of the concatenated + ":" + string. * Eg. "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" * - * 6. "debug" (boolean, optional) - * Enables extensive logging using the "verbose" level. - * * The adapter expects requests with the following authData JSON: * * { @@ -56,97 +53,84 @@ * } */ -import logger from '../../logger'; const Parse = require('parse/node').Parse; const url = require('url'); const querystring = require('querystring'); const httpsRequest = require('./httpsRequest'); +const INVALID_ACCESS = 'OAuth2 access token is invalid for this user.'; +const INVALID_ACCESS_APPID = + "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration."; +const MISSING_APPIDS = + 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'; +const MISSING_URL = + 'OAuth2 token introspection endpoint URL is missing from configuration!'; + // Returns a promise that fulfills if this user id is valid. function validateAuthData(authData, options) { return requestTokenInfo(options, authData.access_token).then(response => { if ( - response && - response.active && - (!options || - !options.hasOwnProperty('useridField') || - !options.useridField || - authData.id == response[options.useridField]) + !response || + !response.active || + (options.useridField && authData.id !== response[options.useridField]) ) { - return; + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS); } - const errorMessage = 'OAuth2 access token is invalid for this user.'; - logger.error(errorMessage); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, errorMessage); }); } function validateAppId(appIds, authData, options) { - if ( - !(options && options.hasOwnProperty('appidField') && options.appidField) - ) { + if (!options || !options.appidField) { return Promise.resolve(); - } else { - if (!appIds.length) { - const errorMessage = - 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'; - logger.error(errorMessage); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, errorMessage); - } - return requestTokenInfo(options, authData.access_token).then(response => { - const appidField = options.appidField; - if (response && response[appidField]) { - const responseValue = response[appidField]; - if (Array.isArray(responseValue)) { - if ( - typeof responseValue.find(function(element) { - return appIds.includes(element); - }) !== 'undefined' - ) { - return; - } - } else { - if (appIds.includes(responseValue)) { - return; - } - } - } - const errorMessage2 = - "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration."; - logger.error(errorMessage2); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, errorMessage2); - }); } + if (!appIds || appIds.length === 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, MISSING_APPIDS); + } + return requestTokenInfo(options, authData.access_token).then(response => { + if (!response || !response.active) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS); + } + const appidField = options.appidField; + if (!response[appidField]) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS_APPID); + } + const responseValue = response[appidField]; + if (!Array.isArray(responseValue) && appIds.includes(responseValue)) { + return; + } else if ( + Array.isArray(responseValue) && + responseValue.some(appId => appIds.includes(appId)) + ) { + return; + } else { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS_APPID); + } + }); } // A promise wrapper for requests to the OAuth2 token introspection endpoint. function requestTokenInfo(options, access_token) { - return new Promise(() => { - if (!options || !options.tokenIntrospectionEndpointUrl) { - const errorMessage = - 'OAuth2 token introspection endpoint URL is missing from configuration!'; - logger.error(errorMessage); - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, errorMessage); - } - const parsedUrl = url.parse(options.tokenIntrospectionEndpointUrl); - const postData = querystring.stringify({ - token: access_token, - }); - const headers = { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(postData), - }; - if (options.authorizationHeader) { - headers['Authorization'] = options.authorizationHeader; - } - const postOptions = { - hostname: parsedUrl.hostname, - path: parsedUrl.pathname, - method: 'POST', - headers: headers, - }; - return httpsRequest.request(postOptions, postData); + if (!options || !options.tokenIntrospectionEndpointUrl) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, MISSING_URL); + } + const parsedUrl = url.parse(options.tokenIntrospectionEndpointUrl); + const postData = querystring.stringify({ + token: access_token, }); + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': Buffer.byteLength(postData), + }; + if (options.authorizationHeader) { + headers['Authorization'] = options.authorizationHeader; + } + const postOptions = { + hostname: parsedUrl.hostname, + path: parsedUrl.pathname, + method: 'POST', + headers: headers, + }; + return httpsRequest.request(postOptions, postData); } module.exports = {