Generic OAuth provider support

Refactors facebook login into oauth generic login

Adds additional oauth2 providers

adds ability to pass an oAuth validator in the config

Adds Twitter validation support + OAuth 1 client

Support auth_token instead of access_token for twitter

Improves code coverage of OAuth

Adds validation of oauth provider structures

Better coverage of the OAuth spec

100% coverage of OAuth1.js

Adds passing auth_token_secret for Twitter auth.

Refactors auth validation methods to include authData parameter

- Adds ability to extens oauth validator through configuration
- Adds ability to extend oauth validator through external module (file or package)
- Adds more tests
- Adds tests to login with custom auth provider

Adds more tests for REST API

fixes twitter auth_token

f
This commit is contained in:
Florent Vilmart
2016-02-04 14:03:39 -05:00
parent f8ae863a2a
commit e010fd82f2
19 changed files with 1061 additions and 87 deletions

307
spec/OAuth.spec.js Normal file
View File

@@ -0,0 +1,307 @@
var OAuth = require("../src/oauth/OAuth1Client");
var request = require('request');
describe('OAuth', function() {
it("Nonce should have right length", (done) => {
jequal(OAuth.nonce().length, 30);
done();
});
it("Should properly build parameter string", (done) => {
var string = OAuth.buildParameterString({c:1, a:2, b:3})
jequal(string, "a=2&b=3&c=1");
done();
});
it("Should properly build empty parameter string", (done) => {
var string = OAuth.buildParameterString()
jequal(string, "");
done();
});
it("Should properly build signature string", (done) => {
var string = OAuth.buildSignatureString("get", "http://dummy.com", "");
jequal(string, "GET&http%3A%2F%2Fdummy.com&");
done();
});
it("Should properly generate request signature", (done) => {
var request = {
host: "dummy.com",
path: "path"
};
var oauth_params = {
oauth_timestamp: 123450000,
oauth_nonce: "AAAAAAAAAAAAAAAAA",
oauth_consumer_key: "hello",
oauth_token: "token"
};
var consumer_secret = "world";
var auth_token_secret = "secret";
request = OAuth.signRequest(request, oauth_params, consumer_secret, auth_token_secret);
jequal(request.headers['Authorization'], 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="8K95bpQcDi9Nd2GkhumTVcw4%2BXw%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"');
done();
});
it("Should properly build request", (done) => {
var options = {
host: "dummy.com",
consumer_key: "hello",
consumer_secret: "world",
auth_token: "token",
auth_token_secret: "secret",
// Custom oauth params for tests
oauth_params: {
oauth_timestamp: 123450000,
oauth_nonce: "AAAAAAAAAAAAAAAAA"
}
};
var path = "path";
var method = "get";
var oauthClient = new OAuth(options);
var req = oauthClient.buildRequest(method, path, {"query": "param"});
jequal(req.host, options.host);
jequal(req.path, "/"+path+"?query=param");
jequal(req.method, "GET");
jequal(req.headers['Content-Type'], 'application/x-www-form-urlencoded');
jequal(req.headers['Authorization'], 'OAuth oauth_consumer_key="hello", oauth_nonce="AAAAAAAAAAAAAAAAA", oauth_signature="wNkyEkDE%2F0JZ2idmqyrgHdvC0rs%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="123450000", oauth_token="token", oauth_version="1.0"')
done();
});
function validateCannotAuthenticateError(data, done) {
jequal(typeof data, "object");
jequal(typeof data.errors, "object");
var errors = data.errors;
jequal(typeof errors[0], "object");
// Cannot authenticate error
jequal(errors[0].code, 32);
done();
}
it("Should fail a GET request", (done) => {
var options = {
host: "api.twitter.com",
consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX",
consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
};
var path = "/1.1/help/configuration.json";
var params = {"lang": "en"};
var oauthClient = new OAuth(options);
oauthClient.get(path, params).then(function(data){
validateCannotAuthenticateError(data, done);
})
});
it("Should fail a POST request", (done) => {
var options = {
host: "api.twitter.com",
consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX",
consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
};
var body = {
lang: "en"
};
var path = "/1.1/account/settings.json";
var oauthClient = new OAuth(options);
oauthClient.post(path, null, body).then(function(data){
validateCannotAuthenticateError(data, done);
})
});
it("Should fail a request", (done) => {
var options = {
host: "localhost",
consumer_key: "XXXXXXXXXXXXXXXXXXXXXXXXX",
consumer_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
};
var body = {
lang: "en"
};
var path = "/";
var oauthClient = new OAuth(options);
oauthClient.post(path, null, body).then(function(data){
jequal(false, true);
done();
}).catch(function(){
jequal(true, true);
done();
})
});
["facebook", "github", "instagram", "google", "linkedin", "meetup", "twitter"].map(function(providerName){
it("Should validate structure of "+providerName, (done) => {
var provider = require("../src/oauth/"+providerName);
jequal(typeof provider.validateAuthData, "function");
jequal(typeof provider.validateAppId, "function");
jequal(provider.validateAuthData({}, {}).constructor, Promise.prototype.constructor);
jequal(provider.validateAppId("app", "key", {}).constructor, Promise.prototype.constructor);
done();
});
});
var getMockMyOauthProvider = function() {
return {
authData: {
id: "12345",
access_token: "12345",
expiration_date: new Date().toJSON(),
},
shouldError: false,
loggedOut: false,
synchronizedUserId: null,
synchronizedAuthToken: null,
synchronizedExpiration: null,
authenticate: function(options) {
if (this.shouldError) {
options.error(this, "An error occurred");
} else if (this.shouldCancel) {
options.error(this, null);
} else {
options.success(this, this.authData);
}
},
restoreAuthentication: function(authData) {
if (!authData) {
this.synchronizedUserId = null;
this.synchronizedAuthToken = null;
this.synchronizedExpiration = null;
return true;
}
this.synchronizedUserId = authData.id;
this.synchronizedAuthToken = authData.access_token;
this.synchronizedExpiration = authData.expiration_date;
return true;
},
getAuthType: function() {
return "myoauth";
},
deauthenticate: function() {
this.loggedOut = true;
this.restoreAuthentication(null);
}
};
};
var ExtendedUser = Parse.User.extend({
extended: function() {
return true;
}
});
var createOAuthUser = function(callback) {
var jsonBody = {
authData: {
myoauth: getMockMyOauthProvider().authData
}
};
var headers = {'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json' }
var options = {
headers: {'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json' },
url: 'http://localhost:8378/1/users',
body: JSON.stringify(jsonBody)
};
return request.post(options, callback);
}
it("should create user with REST API", (done) => {
createOAuthUser((error, response, body) => {
expect(error).toBe(null);
var b = JSON.parse(body);
expect(b.objectId).not.toBeNull();
expect(b.objectId).not.toBeUndefined();
done();
});
});
it("should only create a single user with REST API", (done) => {
var objectId;
createOAuthUser((error, response, body) => {
expect(error).toBe(null);
var b = JSON.parse(body);
expect(b.objectId).not.toBeNull();
expect(b.objectId).not.toBeUndefined();
objectId = b.objectId;
createOAuthUser((error, response, body) => {
expect(error).toBe(null);
var b = JSON.parse(body);
expect(b.objectId).not.toBeNull();
expect(b.objectId).not.toBeUndefined();
expect(b.objectId).toBe(objectId);
done();
});
});
});
it("unlink and link with custom provider", (done) => {
var provider = getMockMyOauthProvider();
Parse.User._registerAuthenticationProvider(provider);
Parse.User._logInWith("myoauth", {
success: function(model) {
ok(model instanceof Parse.User, "Model should be a Parse.User");
strictEqual(Parse.User.current(), model);
ok(model.extended(), "Should have used the subclass.");
strictEqual(provider.authData.id, provider.synchronizedUserId);
strictEqual(provider.authData.access_token, provider.synchronizedAuthToken);
strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration);
ok(model._isLinked("myoauth"), "User should be linked to myoauth");
model._unlinkFrom("myoauth", {
success: function(model) {
ok(!model._isLinked("myoauth"),
"User should not be linked to myoauth");
ok(!provider.synchronizedUserId, "User id should be cleared");
ok(!provider.synchronizedAuthToken, "Auth token should be cleared");
ok(!provider.synchronizedExpiration,
"Expiration should be cleared");
model._linkWith("myoauth", {
success: function(model) {
ok(provider.synchronizedUserId, "User id should have a value");
ok(provider.synchronizedAuthToken,
"Auth token should have a value");
ok(provider.synchronizedExpiration,
"Expiration should have a value");
ok(model._isLinked("myoauth"),
"User should be linked to myoauth");
done();
},
error: function(model, error) {
ok(false, "linking again should succeed");
done();
}
});
},
error: function(model, error) {
ok(false, "unlinking should succeed");
done();
}
});
},
error: function(model, error) {
ok(false, "linking should have worked");
done();
}
});
});
})

View File

@@ -831,9 +831,11 @@ describe('Parse.User testing', () => {
// server-side.
var getMockFacebookProvider = function() {
return {
userId: "8675309",
authToken: "jenny",
expiration: new Date().toJSON(),
authData: {
id: "8675309",
access_token: "jenny",
expiration_date: new Date().toJSON(),
},
shouldError: false,
loggedOut: false,
synchronizedUserId: null,
@@ -846,11 +848,7 @@ describe('Parse.User testing', () => {
} else if (this.shouldCancel) {
options.error(this, null);
} else {
options.success(this, {
id: this.userId,
access_token: this.authToken,
expiration_date: this.expiration
});
options.success(this, this.authData);
}
},
restoreAuthentication: function(authData) {
@@ -889,13 +887,14 @@ describe('Parse.User testing', () => {
ok(model instanceof Parse.User, "Model should be a Parse.User");
strictEqual(Parse.User.current(), model);
ok(model.extended(), "Should have used subclass.");
strictEqual(provider.userId, provider.synchronizedUserId);
strictEqual(provider.authToken, provider.synchronizedAuthToken);
strictEqual(provider.expiration, provider.synchronizedExpiration);
strictEqual(provider.authData.id, provider.synchronizedUserId);
strictEqual(provider.authData.access_token, provider.synchronizedAuthToken);
strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration);
ok(model._isLinked("facebook"), "User should be linked to facebook");
done();
},
error: function(model, error) {
console.error(model, error);
ok(false, "linking should have worked");
done();
}
@@ -910,9 +909,9 @@ describe('Parse.User testing', () => {
ok(model instanceof Parse.User, "Model should be a Parse.User");
strictEqual(Parse.User.current(), model);
ok(model.extended(), "Should have used the subclass.");
strictEqual(provider.userId, provider.synchronizedUserId);
strictEqual(provider.authToken, provider.synchronizedAuthToken);
strictEqual(provider.expiration, provider.synchronizedExpiration);
strictEqual(provider.authData.id, provider.synchronizedUserId);
strictEqual(provider.authData.access_token, provider.synchronizedAuthToken);
strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration);
ok(model._isLinked("facebook"), "User should be linked to facebook");
Parse.User.logOut();
@@ -925,20 +924,22 @@ describe('Parse.User testing', () => {
"Model should be a Parse.User");
ok(innerModel === Parse.User.current(),
"Returned model should be the current user");
ok(provider.userId === provider.synchronizedUserId);
ok(provider.authToken === provider.synchronizedAuthToken);
ok(provider.authData.id === provider.synchronizedUserId);
ok(provider.authData.access_token === provider.synchronizedAuthToken);
ok(innerModel._isLinked("facebook"),
"User should be linked to facebook");
ok(innerModel.existed(), "User should not be newly-created");
done();
},
error: function(model, error) {
fail(error);
ok(false, "LogIn should have worked");
done();
}
});
},
error: function(model, error) {
console.error(model, error);
ok(false, "LogIn should have worked");
done();
}
@@ -987,9 +988,9 @@ describe('Parse.User testing', () => {
success: function(model) {
ok(model instanceof Parse.User, "Model should be a Parse.User");
strictEqual(Parse.User.current(), model);
strictEqual(provider.userId, provider.synchronizedUserId);
strictEqual(provider.authToken, provider.synchronizedAuthToken);
strictEqual(provider.expiration, provider.synchronizedExpiration);
strictEqual(provider.authData.id, provider.synchronizedUserId);
strictEqual(provider.authData.access_token, provider.synchronizedAuthToken);
strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration);
ok(model._isLinked("facebook"), "User should be linked");
done();
},
@@ -1020,9 +1021,9 @@ describe('Parse.User testing', () => {
success: function(model) {
ok(model instanceof Parse.User, "Model should be a Parse.User");
strictEqual(Parse.User.current(), model);
strictEqual(provider.userId, provider.synchronizedUserId);
strictEqual(provider.authToken, provider.synchronizedAuthToken);
strictEqual(provider.expiration, provider.synchronizedExpiration);
strictEqual(provider.authData.id, provider.synchronizedUserId);
strictEqual(provider.authData.access_token, provider.synchronizedAuthToken);
strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration);
ok(model._isLinked("facebook"), "User should be linked.");
var user2 = new Parse.User();
user2.set("username", "testLinkWithProviderToAlreadyLinkedUser2");
@@ -1123,9 +1124,9 @@ describe('Parse.User testing', () => {
ok(model instanceof Parse.User, "Model should be a Parse.User.");
strictEqual(Parse.User.current(), model);
ok(model.extended(), "Should have used the subclass.");
strictEqual(provider.userId, provider.synchronizedUserId);
strictEqual(provider.authToken, provider.synchronizedAuthToken);
strictEqual(provider.expiration, provider.synchronizedExpiration);
strictEqual(provider.authData.id, provider.synchronizedUserId);
strictEqual(provider.authData.access_token, provider.synchronizedAuthToken);
strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration);
ok(model._isLinked("facebook"), "User should be linked to facebook.");
model._unlinkFrom("facebook", {
@@ -1159,9 +1160,9 @@ describe('Parse.User testing', () => {
ok(model instanceof Parse.User, "Model should be a Parse.User");
strictEqual(Parse.User.current(), model);
ok(model.extended(), "Should have used the subclass.");
strictEqual(provider.userId, provider.synchronizedUserId);
strictEqual(provider.authToken, provider.synchronizedAuthToken);
strictEqual(provider.expiration, provider.synchronizedExpiration);
strictEqual(provider.authData.id, provider.synchronizedUserId);
strictEqual(provider.authData.access_token, provider.synchronizedAuthToken);
strictEqual(provider.authData.expiration_date, provider.synchronizedExpiration);
ok(model._isLinked("facebook"), "User should be linked to facebook");
model._unlinkFrom("facebook", {

View File

@@ -5,7 +5,7 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 2000;
var cache = require('../src/cache');
var DatabaseAdapter = require('../src/DatabaseAdapter');
var express = require('express');
var facebook = require('../src/facebook');
var facebook = require('../src/oauth/facebook');
var ParseServer = require('../src/index').ParseServer;
var databaseURI = process.env.DATABASE_URI;
@@ -22,7 +22,13 @@ var api = new ParseServer({
restAPIKey: 'rest',
masterKey: 'test',
collectionPrefix: 'test_',
fileKey: 'test'
fileKey: 'test',
oauth: { // Override the facebook provider
facebook: mockFacebook(),
myoauth: {
module: "../spec/myoauth" // relative path as it's run from src
}
}
});
var app = express();
@@ -40,7 +46,6 @@ Parse.Promise.disableAPlusCompliant();
beforeEach(function(done) {
Parse.initialize('test', 'test', 'test');
mockFacebook();
Parse.User.enableUnsafeCurrentUser();
done();
});
@@ -175,18 +180,20 @@ function range(n) {
}
function mockFacebook() {
facebook.validateUserId = function(userId, accessToken) {
if (userId === '8675309' && accessToken === 'jenny') {
var facebook = {};
facebook.validateAuthData = function(authData) {
if (authData.id === '8675309' && authData.access_token === 'jenny') {
return Promise.resolve();
}
return Promise.reject();
};
facebook.validateAppId = function(appId, accessToken) {
if (accessToken === 'jenny') {
facebook.validateAppId = function(appId, authData) {
if (authData.access_token === 'jenny') {
return Promise.resolve();
}
return Promise.reject();
};
return facebook;
}
function clearData() {

17
spec/myoauth.js Normal file
View File

@@ -0,0 +1,17 @@
// Custom oauth provider by module
// Returns a promise that fulfills iff this user id is valid.
function validateAuthData(authData) {
if (authData.id == "12345" && authData.access_token == "12345") {
return Promise.resolve();
}
return Promise.reject();
}
function validateAppId() {
return Promise.resolve();
}
module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData
};