added an RFC 7662 compliant OAuth2 auth adapter (#4910)

* added an RFC 7662 compliant OAuth2 auth adapter

* forgot to add the actual auth adapter to the previous commit

* fixed lint errors

* * 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)

* added description of the "debug" option and added this option to the tests too

* 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)

* 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

* 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

* modified oauth2 adapter as requested by flovilmart

* bugfix: defaultAdapter can be null in loadAuthAdapter() of index.js (my change broke the tests)

* added TODO on need for a validateAdapter() to validate auth adapters

* test cases and cleanup
This commit is contained in:
Müller Zsolt
2019-04-11 18:05:55 +02:00
committed by Diamond Lewis
parent a3746cab00
commit 019cf0a986
4 changed files with 608 additions and 6 deletions

View File

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

View File

@@ -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({});
}

View File

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

139
src/Adapters/Auth/oauth2.js Normal file
View File

@@ -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 <username> + ":" + <password> 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,
};