diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index f234011569..4f0037f9da 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -5,7 +5,7 @@ var authenticationLoader = require('../src/Adapters/Auth'); var path = require('path'); describe('AuthenticationProviders', function() { - ["facebook", "github", "instagram", "google", "linkedin", "meetup", "twitter", "janrainengage", "janraincapture", "vkontakte"].map(function(providerName){ + ["facebook", "github", "instagram", "google", "linkedin", "meetup", "twitter", "janrainengage", "janraincapture", "vkontakte", "gpgames", "gcenter","xiaomi"].map(function(providerName){ it("Should validate structure of " + providerName, (done) => { var provider = require("../src/Adapters/Auth/" + providerName); jequal(typeof provider.validateAuthData, "function"); @@ -345,4 +345,34 @@ describe('AuthenticationProviders', function() { expect(appIds).toEqual(['a', 'b']); expect(providerOptions).toEqual(options.custom); }); + + it('should fail to load adapter if provider is not available on the list', () => { + const authenticationHandler = authenticationLoader(); + validateAuthenticationHandler(authenticationHandler); + const validator = authenticationHandler.getValidatorForProvider('unknown'); + expect(validator).toBeUndefined(); + }); + + it('properly verify a game center identity', () => { + const authenticationHandler = authenticationLoader({ + gcenter: path.resolve('./src/Adapters/Auth/gcenter.js') + }); + + validateAuthenticationHandler(authenticationHandler); + const validator = authenticationHandler.getValidatorForProvider('gcenter'); + var identity = { + playerId: "G:1958377822", + publicKeyUrl: "https://static.gc.apple.com/public-key/gc-prod-3.cer", + timestamp: "1513067500622", + signature: "BYX5fJ59vVnj/3pdAfsfzkdSHG5kqPJ/gnhagaFJYwAYRlhR9P672sZk0N/cZoCabzTZM+AVMc6W+xwpVVSj/qxgfZi4ADvwKY7m8q8ugdw3Aec97w8zL3i5F5wKLr+HPzR6/OW4YsMQjtuumpWQCq+9bDro9JibiCVKm+x/MwvIgHS3gdi6XVD+JeBMLbd5bJk/wUmcjE9uWMoXRiY4WCREtlIy47eGWYSFQ2kinIKo4YRzpSK95E9jYEB0nOjwg1ExycvMBPDrxoxpk+maIkqPZ3FS6vESLnhq1/gojwECEmObRxv+a1ZRvzt2fwCMfjBzZVv867r+mpF7H/JrXA==", + salt: "WN97Bw==", + bundleId: "com.appxplore.apptest" + }; + validateValidator(validator); + validator(identity, function (err, token) + { + expect(err).toBeNull(); + expect(token).not.toBeNull(); + }); + }); }); diff --git a/src/Adapters/Auth/gcenter.js b/src/Adapters/Auth/gcenter.js new file mode 100644 index 0000000000..8d895581ab --- /dev/null +++ b/src/Adapters/Auth/gcenter.js @@ -0,0 +1,129 @@ +'use strict'; + +// Helper functions for accessing the google API. +//var Parse = require('parse/node').Parse; +var crypto = require('crypto'); +var request = require('request'); +var url = require('url'); + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData) +{ + return new Promise(function (resolve, reject) + { + var identity = { + publicKeyUrl: authData.pKeyUrl, + timestamp: authData.timeStamp, + signature: authData.sig, + salt: authData.salt, + playerId: authData.id, + bundleId: authData.bid + }; + + return verify(identity, function (err, token) + { + if(err) + return reject('Failed to validate this access token with Game Center.'); + else + return resolve(token); + }); + }); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +function verifyPublicKeyUrl(publicKeyUrl) { + var parsedUrl = url.parse(publicKeyUrl); + if (parsedUrl.protocol !== 'https:') { + return false; + } + + var hostnameParts = parsedUrl.hostname.split('.'); + var domain = hostnameParts[hostnameParts.length - 2] + "." + hostnameParts[hostnameParts.length - 1]; + if (domain !== 'apple.com') { + return false; + } + + return true; +} + +function convertX509CertToPEM(X509Cert) { + var pemPreFix = '-----BEGIN CERTIFICATE-----\n'; + var pemPostFix = '-----END CERTIFICATE-----'; + + var base64 = X509Cert.toString('base64'); + var certBody = base64.match(new RegExp('.{0,64}', 'g')).join('\n'); + + return pemPreFix + certBody + pemPostFix; +} + +function getAppleCertificate(publicKeyUrl, callback) { + if (!verifyPublicKeyUrl(publicKeyUrl)) { + callback(new Error('Invalid publicKeyUrl'), null); + return; + } + + var options = { + uri: publicKeyUrl, + encoding: null + }; + request.get(options, function (error, response, body) { + if (!error && response.statusCode === 200) { + var cert = convertX509CertToPEM(body); + callback(null, cert); + } else { + callback(error, null); + } + }); +} + +/* jslint bitwise:true */ +function convertTimestampToBigEndian(timestamp) { + // The timestamp parameter in Big-Endian UInt-64 format + var buffer = new Buffer(8); + buffer.fill(0); + + var high = ~~(timestamp / 0xffffffff); // jshint ignore:line + var low = timestamp % (0xffffffff + 0x1); // jshint ignore:line + + buffer.writeUInt32BE(parseInt(high, 10), 0); + buffer.writeUInt32BE(parseInt(low, 10), 4); + + return buffer; +} +/* jslint bitwise:false */ + +function verifySignature(publicKey, idToken) { + var verifier = crypto.createVerify('sha256'); + verifier.update(idToken.playerId, 'utf8'); + verifier.update(idToken.bundleId, 'utf8'); + verifier.update(convertTimestampToBigEndian(idToken.timestamp)); + verifier.update(idToken.salt, 'base64'); + + if (!verifier.verify(publicKey, idToken.signature, 'base64')) { + throw new Error('Invalid Signature'); + } +} + +function verify(idToken, callback) { + getAppleCertificate(idToken.publicKeyUrl, function (err, publicKey) { + if (!err) { + try { + verifySignature(publicKey, idToken); + callback(null, idToken); + } catch (e) { + callback(e, null); + } + } else { + callback(err, null); + } + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/Adapters/Auth/gpgames.js b/src/Adapters/Auth/gpgames.js new file mode 100644 index 0000000000..168511e605 --- /dev/null +++ b/src/Adapters/Auth/gpgames.js @@ -0,0 +1,94 @@ +'use strict'; + +// Helper functions for accessing the google API. +var https = require('https'); +var Parse = require('parse/node').Parse; +var request = require('request'); + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData, authOptions) +{ + var postUrl = { + url: 'https://www.googleapis.com/oauth2/v3/token', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + form: { + 'client_id': authOptions.client_id, + 'client_secret': authOptions.client_secret, + 'code': authData.access_token, + 'grant_type': 'authorization_code' + } + }; + return exchangeAccessToken(postUrl).then((authRes)=> + { + if(authRes.error) + { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, authRes.error); + } + else + { + return requestHere("https://www.googleapis.com/games/v1/players/" + authData.id + "?access_token=" + authRes.access_token).then(response => { + if (response && (response.playerId == authData.id)) + return; + else + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Google auth is invalid for this user.'); + }); + } + }).catch(error=>{ + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, error); + }); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +function exchangeAccessToken(postOptions) +{ + return new Promise(function (resolve, reject) { + request(postOptions, function (error, response, body) + { + if (!error && response.statusCode == 200) + { + try { + body = JSON.parse(body); + } catch (e) { + return reject(e); + } + resolve(body); + } + else + reject("Fail to Exchange Access Token for GPGames"); + }); + }); +} + +// A promisey wrapper for api requests +function requestHere(path) { + return new Promise(function (resolve, reject) { + https.get(path, function (res) { + var data = ''; + res.on('data', function (chunk) { + data += chunk; + }); + res.on('end', function () { + try { + data = JSON.parse(data); + } catch (e) { + return reject(e); + } + resolve(data); + }); + }).on('error', function () { + reject('Failed to validate this access token with Google.'); + }); + }); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +}; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index aa99f09cec..bbd1f6d718 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -5,6 +5,8 @@ const instagram = require("./instagram"); const linkedin = require("./linkedin"); const meetup = require("./meetup"); const google = require("./google"); +const gcenter = require("./gcenter"); +const gpgames = require("./gpgames"); const github = require("./github"); const twitter = require("./twitter"); const spotify = require("./spotify"); @@ -15,7 +17,7 @@ const vkontakte = require("./vkontakte"); const qq = require("./qq"); const wechat = require("./wechat"); const weibo = require("./weibo"); - +const xiaomi = require("./xiaomi"); const anonymous = { validateAuthData: () => { return Promise.resolve(); @@ -23,7 +25,7 @@ const anonymous = { validateAppId: () => { return Promise.resolve(); } -} +}; const providers = { facebook, @@ -31,6 +33,8 @@ const providers = { linkedin, meetup, google, + gcenter, + gpgames, github, twitter, spotify, @@ -41,18 +45,22 @@ const providers = { vkontakte, qq, wechat, - weibo -} + weibo, + xiaomi +}; function authDataValidator(adapter, appIds, options) { - return function(authData) { - return adapter.validateAuthData(authData, options).then(() => { - if (appIds) { + return function (authData) + { + return adapter.validateAuthData(authData, options).then(() => + { + if (appIds) + { return adapter.validateAppId(appIds, authData, options); } return Promise.resolve(); }); - } + }; } function loadAuthAdapter(provider, authOptions) { @@ -70,7 +78,7 @@ function loadAuthAdapter(provider, authOptions) { if (providerOptions) { const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { - ['validateAuthData', 'validateAppId'].forEach((key) => { + ['validateAuthData', 'validateAppId'].forEach(key => { if (optionalAdapter[key]) { adapter[key] = optionalAdapter[key]; } @@ -82,18 +90,26 @@ function loadAuthAdapter(provider, authOptions) { return; } - return {adapter, appIds, providerOptions}; + return { adapter, appIds, providerOptions }; } -module.exports = function(authOptions = {}, enableAnonymousUsers = true) { +module.exports = function (authOptions = {}, enableAnonymousUsers = true) +{ let _enableAnonymousUsers = enableAnonymousUsers; - const setEnableAnonymousUsers = function(enable) { + const setEnableAnonymousUsers = function (enable) + { _enableAnonymousUsers = enable; - } + }; // To handle the test cases on configuration - const getValidatorForProvider = function(provider) { + const getValidatorForProvider = function (provider) + { + if (provider === 'anonymous' && !_enableAnonymousUsers) + return; - if (provider === 'anonymous' && !_enableAnonymousUsers) { + if(!providers.hasOwnProperty(provider) && + provider !== 'myoauth' && + provider !== 'customAuthentication' && + provider !== 'shortLivedAuth') { return; } @@ -104,12 +120,12 @@ module.exports = function(authOptions = {}, enableAnonymousUsers = true) { } = loadAuthAdapter(provider, authOptions); return authDataValidator(adapter, appIds, providerOptions); - } + }; return Object.freeze({ getValidatorForProvider, setEnableAnonymousUsers - }) -} + }); +}; module.exports.loadAuthAdapter = loadAuthAdapter; diff --git a/src/Adapters/Auth/xiaomi.js b/src/Adapters/Auth/xiaomi.js new file mode 100644 index 0000000000..3ef82066a4 --- /dev/null +++ b/src/Adapters/Auth/xiaomi.js @@ -0,0 +1,65 @@ +'use strict'; + +// Helper functions for accessing the Xiao Mi Graph API. +var https = require('https'); +var Parse = require('parse/node').Parse; +var crypto = require('crypto'); + +const PRODUCTION_URL = "https://cn-api.unity.com"; +const DEBUG_URL = " https://cn-api-debug.unity.com"; + +// Returns a promise that fulfills iff this user id is valid. +function validateAuthData(authData, authOptions) { + var link = ""; + if(authData.mode == "Debug") + link += DEBUG_URL; + else if(authData.mode == "Release") + link += PRODUCTION_URL; + + link += "/v1/login-attempts/verifyLogin?userLoginToken=" + authData.login_token + "&sign=" + getMD5(authData.login_token + "&" + authOptions.client_secret); + + return graphRequest(link).then(data => { + if (data && data.success) + { + return; + } + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Xiao Mi auth is invalid for this user.'); + }); +} + +// Returns a promise that fulfills iff this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +// A promisey wrapper for FB graph requests. +function graphRequest(path) { + return new Promise(function (resolve, reject) { + https.get(path, function (res) { + var data = ''; + res.on('data', function (chunk) { + data += chunk; + }); + res.on('end', function () { + try { + data = JSON.parse(data); + } catch (e) { + return reject(e); + } + resolve(data); + }); + }).on('error', function () { + reject('Xiao Mi auth is invalid for this user.'); + }); + }); +} + +function getMD5(signData) +{ + return crypto.createHash('md5').update(signData).digest('hex'); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData +};