diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 59e03b8b59..9214506b7b 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -37,11 +37,14 @@ 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(); }); @@ -584,3 +587,449 @@ 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 = { + oauth2Authentication: { + oauth2: true, + }, + }; + const loadedAuthAdapter = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + expect(loadedAuthAdapter.adapter).toEqual(oauth2); + }); + + it('properly loads OAuth2 adapter with options', () => { + const options = { + oauth2Authentication: { + oauth2: true, + tokenIntrospectionEndpointUrl: 'https://example.com/introspect', + useridField: 'sub', + appidField: 'appId', + appIds: ['a', 'b'], + authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', + debug: true, + }, + }; + 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.debug).toEqual(true); + }); + + it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + appIds: ['a', 'b'], + 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 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', async () => { + const options = { + oauth2Authentication: { + oauth2: true, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'oauth2Authentication', + options + ); + 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/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 28c1479eeb..9af6d5e449 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -3,9 +3,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 9a9fa82b3f..426c513185 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: () => { @@ -45,6 +46,7 @@ const providers = { wechat, weibo, }; + function authDataValidator(adapter, appIds, options) { return function(authData) { return adapter.validateAuthData(authData, options).then(() => { @@ -57,14 +59,21 @@ function authDataValidator(adapter, appIds, options) { } function loadAuthAdapter(provider, authOptions) { - const defaultAdapter = providers[provider]; - const adapter = Object.assign({}, defaultAdapter); + let defaultAdapter = providers[provider]; const providerOptions = authOptions[provider]; + if ( + providerOptions && + providerOptions.hasOwnProperty('oauth2') && + providerOptions['oauth2'] === true + ) { + defaultAdapter = oauth2; + } if (!defaultAdapter && !providerOptions) { return; } + const adapter = Object.assign({}, defaultAdapter); const appIds = providerOptions ? providerOptions.appIds : undefined; // Try the configuration methods @@ -83,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; } diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js new file mode 100644 index 0000000000..80564d5b32 --- /dev/null +++ b/src/Adapters/Auth/oauth2.js @@ -0,0 +1,139 @@ +/* + * 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. "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" (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. + * 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. "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 + * 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" (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) + * + * 5. "authorizationHeader" (string, optional) + * The value of the "Authorization" HTTP header in requests sent to the + * 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=" + * + * The adapter expects requests with the following authData JSON: + * + * { + * "someadapter": { + * "id": "user's OAuth2 provider-specific id as a string", + * "access_token": "an authorized OAuth2 access token for the user", + * } + * } + */ + +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.useridField && authData.id !== response[options.useridField]) + ) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS); + } + }); +} + +function validateAppId(appIds, authData, options) { + if (!options || !options.appidField) { + return Promise.resolve(); + } + 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) { + 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 = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +};