Skip to content

added an RFC 7662 compliant OAuth2 auth adapter #4910

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Apr 11, 2019
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 98 additions & 3 deletions spec/AuthenticationAdapters.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -584,3 +587,95 @@ describe('google auth adapter', () => {
}
});
});

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,
},
};
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', done => {
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);
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 = {
oauth2Authentication: {
oauth2: true,
},
};
const authData = {
id: 'fakeid',
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();
});
});
});
3 changes: 2 additions & 1 deletion src/Adapters/Auth/AuthAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
}

Expand Down
17 changes: 15 additions & 2 deletions src/Adapters/Auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {
Expand Down Expand Up @@ -45,6 +46,7 @@ const providers = {
wechat,
weibo,
};

function authDataValidator(adapter, appIds, options) {
return function(authData) {
return adapter.validateAuthData(authData, options).then(() => {
Expand All @@ -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
Expand All @@ -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;
}
Expand Down
155 changes: 155 additions & 0 deletions src/Adapters/Auth/oauth2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* 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, 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" (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="
*
* 6. "debug" (boolean, optional)
* Enables extensive logging using the "verbose" level.
*
* 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",
* }
* }
*/

import logger from '../../logger';
const Parse = require('parse/node').Parse;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer destructuring.

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) {
return requestTokenInfo(options, authData.access_token).then(response => {
if (
response &&
response.active &&
(!options ||
!options.hasOwnProperty('useridField') ||
!options.useridField ||
authData.id == response[options.useridField])
) {
return;
}
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)
) {
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we get a few comments on this logic please?
I have no idea how to debug / maintain this

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean comments on what the "appidField" in the auth adapter's options object is supposed to do?
I did write about it in the leading long comment (documentation) of the adapter.

See:

  1. "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)

So the basic idea is that the "appId" term comes from Google/Facebook (and their respective Parse auth adapters). However they do not implement RFC 7662 yet, so we cannot know how they would incorporate this appId in a RFC-compliant token introspection response (it can contain custom/non-standard fields).
To make this OAuth2 auth adapter flexible and future-proof, I added an optional appId support. If we specifiy in the adapter config a field name, then the code will check whether such a field exists in the token introspection endpoint response and it checks it's contents against the appId list in the adapter config.

Should I add any of this as a comment to the code? I thought that the documentation of the "appidField" config property is detailed enough and the actual implementation of the appId check speaks for itself.

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

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

module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};