diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index f0cdfa2f..30410de5 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -21,6 +21,7 @@ const responses = { describe('AuthenticationProviders', function() { [ 'apple', + 'gcenter', 'facebook', 'facebookaccountkit', 'github', @@ -39,7 +40,7 @@ describe('AuthenticationProviders', function() { 'weibo', 'phantauth', 'microsoft', - ].map(function (providerName) { + ].map(function(providerName) { it('Should validate structure of ' + providerName, done => { const provider = require('../lib/Adapters/Auth/' + providerName); jequal(typeof provider.validateAuthData, 'function'); @@ -57,7 +58,8 @@ describe('AuthenticationProviders', function() { }); it(`should provide the right responses for adapter ${providerName}`, async () => { - if (providerName === 'twitter' || providerName === 'apple') { + const noResponse = ['twitter', 'apple', 'gcenter']; + if (noResponse.includes(providerName)) { return; } spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake( @@ -1175,6 +1177,67 @@ describe('apple signin auth adapter', () => { }); }); +describe('Apple Game Center Auth adapter', () => { + const gcenter = require('../lib/Adapters/Auth/gcenter'); + + it('validateAuthData should validate', async () => { + // real token is used + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + bundleId: 'cloud.xtralife.gamecenterauth', + }; + + try { + await gcenter.validateAuthData(authData); + } catch (e) { + fail(); + } + }); + + it('validateAuthData invalid signature id', async () => { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: '1234', + salt: 'DzqqrQ==', + bundleId: 'cloud.xtralife.gamecenterauth', + }; + + try { + await gcenter.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe('Apple Game Center - invalid signature'); + } + }); + + it('validateAuthData invalid public key url', async () => { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'invalid.com', + timestamp: 1565257031287, + signature: '1234', + salt: 'DzqqrQ==', + bundleId: 'cloud.xtralife.gamecenterauth', + }; + + try { + await gcenter.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe( + 'Apple Game Center - invalid publicKeyUrl: invalid.com' + ); + } + }); +}); + describe('phant auth adapter', () => { const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); @@ -1205,8 +1268,24 @@ describe('microsoft graph auth adapter', () => { spyOn(httpsRequest, 'get').and.callFake(() => { return Promise.resolve({ id: 'userId', mail: 'userMail' }); }); - await microsoft.validateAuthData( - { id: 'userId', access_token: 'the_token' } - ); + await microsoft.validateAuthData({ + id: 'userId', + access_token: 'the_token', + }); + }); + + it('should fail to validate Microsoft Graph auth with bad token', done => { + const authData = { + id: 'fake-id', + mail: 'fake@mail.com', + access_token: 'very.long.bad.token', + }; + microsoft.validateAuthData(authData).then(done.fail, err => { + expect(err.code).toBe(101); + expect(err.message).toBe( + 'Microsoft Graph auth is invalid for this user.' + ); + done(); + }); }); }); diff --git a/spec/MicrosoftAuth.spec.js b/spec/MicrosoftAuth.spec.js deleted file mode 100644 index 71d14e6b..00000000 --- a/spec/MicrosoftAuth.spec.js +++ /dev/null @@ -1,18 +0,0 @@ -const microsoft = require('../lib/Adapters/Auth/microsoft'); - -describe('Microsoft Auth', () => { - it('should fail to validate Microsoft Graph auth with bad token', done => { - const authData = { - id: 'fake-id', - mail: 'fake@mail.com', - access_token: 'very.long.bad.token', - }; - microsoft.validateAuthData(authData).then(done.fail, err => { - expect(err.code).toBe(101); - expect(err.message).toBe( - 'Microsoft Graph auth is invalid for this user.' - ); - done(); - }); - }); -}); diff --git a/src/Adapters/Auth/apple.js b/src/Adapters/Auth/apple.js index e8bed258..1d487d0c 100644 --- a/src/Adapters/Auth/apple.js +++ b/src/Adapters/Auth/apple.js @@ -1,3 +1,6 @@ +// Apple SignIn Auth +// https://developer.apple.com/documentation/signinwithapplerestapi + const Parse = require('parse/node').Parse; const httpsRequest = require('./httpsRequest'); const NodeRSA = require('node-rsa'); diff --git a/src/Adapters/Auth/gcenter.js b/src/Adapters/Auth/gcenter.js new file mode 100644 index 00000000..c6ecc50a --- /dev/null +++ b/src/Adapters/Auth/gcenter.js @@ -0,0 +1,125 @@ +/* Apple Game Center Auth +https://developer.apple.com/documentation/gamekit/gklocalplayer/1515407-generateidentityverificationsign#discussion + +const authData = { + publicKeyUrl: 'https://valid.apple.com/public/timeout.cer', + timestamp: 1460981421303, + signature: 'PoDwf39DCN464B49jJCU0d9Y0J', + salt: 'saltST==', + bundleId: 'com.valid.app' + id: 'playerId', +}; +*/ + +const { Parse } = require('parse/node'); +const crypto = require('crypto'); +const https = require('https'); +const url = require('url'); + +const cache = {}; // (publicKey -> cert) cache + +function verifyPublicKeyUrl(publicKeyUrl) { + const parsedUrl = url.parse(publicKeyUrl); + if (parsedUrl.protocol !== 'https:') { + return false; + } + const hostnameParts = parsedUrl.hostname.split('.'); + const length = hostnameParts.length; + const domainParts = hostnameParts.slice(length - 2, length); + const domain = domainParts.join('.'); + return domain === 'apple.com'; +} + +function convertX509CertToPEM(X509Cert) { + const pemPreFix = '-----BEGIN CERTIFICATE-----\n'; + const pemPostFix = '-----END CERTIFICATE-----'; + + const base64 = X509Cert; + const certBody = base64.match(new RegExp('.{0,64}', 'g')).join('\n'); + + return pemPreFix + certBody + pemPostFix; +} + +function getAppleCertificate(publicKeyUrl) { + if (!verifyPublicKeyUrl(publicKeyUrl)) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` + ); + } + if (cache[publicKeyUrl]) { + return cache[publicKeyUrl]; + } + return new Promise((resolve, reject) => { + https + .get(publicKeyUrl, res => { + let data = ''; + res.on('data', chunk => { + data += chunk.toString('base64'); + }); + res.on('end', () => { + const cert = convertX509CertToPEM(data); + if (res.headers['cache-control']) { + var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/); + if (expire) { + cache[publicKeyUrl] = cert; + // we'll expire the cache entry later, as per max-age + setTimeout(() => { + delete cache[publicKeyUrl]; + }, parseInt(expire[1], 10) * 1000); + } + } + resolve(cert); + }); + }) + .on('error', reject); + }); +} + +function convertTimestampToBigEndian(timestamp) { + const buffer = new Buffer(8); + buffer.fill(0); + + const high = ~~(timestamp / 0xffffffff); + const low = timestamp % (0xffffffff + 0x1); + + buffer.writeUInt32BE(parseInt(high, 10), 0); + buffer.writeUInt32BE(parseInt(low, 10), 4); + + return buffer; +} + +function verifySignature(publicKey, authData) { + const verifier = crypto.createVerify('sha256'); + verifier.update(authData.playerId, 'utf8'); + verifier.update(authData.bundleId, 'utf8'); + verifier.update(convertTimestampToBigEndian(authData.timestamp)); + verifier.update(authData.salt, 'base64'); + + if (!verifier.verify(publicKey, authData.signature, 'base64')) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Apple Game Center - invalid signature' + ); + } +} + +// Returns a promise that fulfills if this user id is valid. +async function validateAuthData(authData) { + if (!authData.id) { + return Promise.reject('Apple Game Center - authData id missing'); + } + authData.playerId = authData.id; + const publicKey = await getAppleCertificate(authData.publicKeyUrl); + return verifySignature(publicKey, authData); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +}; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index fa68d5ec..b6d9c911 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,6 +1,7 @@ import loadAdapter from '../AdapterLoader'; const apple = require('./apple'); +const gcenter = require('./gcenter'); const facebook = require('./facebook'); const facebookaccountkit = require('./facebookaccountkit'); const instagram = require('./instagram'); @@ -33,6 +34,7 @@ const anonymous = { const providers = { apple, + gcenter, facebook, facebookaccountkit, instagram,