From fd4c8e5401fb7d9d985145401f6f2aa719e7ff94 Mon Sep 17 00:00:00 2001 From: SebC99 Date: Fri, 12 Jun 2020 10:18:27 +0200 Subject: [PATCH 1/3] Add the production Google Auth Adapter instead of using the development url --- src/Adapters/Auth/google.js | 173 +++++++++++++++++++++++++++--------- 1 file changed, 131 insertions(+), 42 deletions(-) diff --git a/src/Adapters/Auth/google.js b/src/Adapters/Auth/google.js index 9dacabdd62..267aebb6df 100644 --- a/src/Adapters/Auth/google.js +++ b/src/Adapters/Auth/google.js @@ -1,47 +1,90 @@ +"use strict"; + // Helper functions for accessing the google API. var Parse = require('parse/node').Parse; -const httpsRequest = require('./httpsRequest'); - -function validateIdToken(id, token) { - return googleRequest('tokeninfo?id_token=' + token).then(response => { - if (response && (response.sub == id || response.user_id == id)) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Google auth is invalid for this user.' - ); + +const https = require('https'); +const jwt = require('jsonwebtoken'); + +const TOKEN_ISSUER = 'https://accounts.google.com'; + +let cache = {}; + + +// Retrieve Google Signin Keys (with cache control) +function getGoogleKeyByKeyId(keyId) { + if (cache[keyId] && cache.expiresAt > new Date()) { + return cache[keyId]; + } + + return new Promise((resolve, reject) => { + https.get(`https://www.googleapis.com/oauth2/v3/certs`, res => { + let data = ''; + res.on('data', chunk => { + data += chunk.toString('utf8'); + }); + res.on('end', () => { + const {keys} = JSON.parse(data); + const pems = keys.reduce((pems, {n: modulus, e: exposant, kid}) => Object.assign(pems, {[kid]: rsaPublicKeyToPEM(modulus, exposant)}), {}); + + if (res.headers['cache-control']) { + var expire = res.headers['cache-control'].match(/max-age=([0-9]+)/); + + if (expire) { + cache = Object.assign({}, pems, {expiresAt: new Date((new Date()).getTime() + Number(expire[1]) * 1000)}); + } + } + + resolve(pems[keyId]); + }); + }).on('error', reject); }); } -function validateAuthToken(id, token) { - return googleRequest('tokeninfo?access_token=' + token).then(response => { - if (response && (response.sub == id || response.user_id == id)) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Google auth is invalid for this user.' - ); - }); +function getHeaderFromToken(token) { + const decodedToken = jwt.decode(token, {complete: true}); + + if (!decodedToken) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `provided token does not decode as JWT`); + } + + return decodedToken.header; } -// Returns a promise that fulfills if this user id is valid. -function validateAuthData(authData) { - if (authData.id_token) { - return validateIdToken(authData.id, authData.id_token); - } else { - return validateAuthToken(authData.id, authData.access_token).then( - () => { - // Validation with auth token worked - return; - }, - () => { - // Try with the id_token param - return validateIdToken(authData.id, authData.access_token); - } - ); +async function verifyIdToken({id_token: token, id}, {clientId}) { + if (!token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); + } + + const { kid: keyId, alg: algorithm } = getHeaderFromToken(token); + let jwtClaims; + const googleKey = await getGoogleKeyByKeyId(keyId); + + try { + jwtClaims = jwt.verify(token, googleKey, { algorithms: algorithm, audience: clientId }); + } catch (exception) { + const message = exception.message; + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`); + } + + if (jwtClaims.iss !== TOKEN_ISSUER) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token not issued by correct provider - expected: ${TOKEN_ISSUER} | from: ${jwtClaims.iss}`); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `auth data is invalid for this user.`); } + + if (clientId && jwtClaims.aud !== clientId) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token not authorized for this clientId.`); + } + + return jwtClaims; +} + +// Returns a promise that fulfills if this user id is valid. +function validateAuthData(authData, options) { + return verifyIdToken(authData, options); } // Returns a promise that fulfills if this app id is valid. @@ -49,12 +92,58 @@ function validateAppId() { return Promise.resolve(); } -// A promisey wrapper for api requests -function googleRequest(path) { - return httpsRequest.get('https://www.googleapis.com/oauth2/v3/' + path); -} - module.exports = { validateAppId: validateAppId, - validateAuthData: validateAuthData, + validateAuthData: validateAuthData }; + + +// Helpers functions to convert the RSA certs to PEM (from jwks-rsa) +function rsaPublicKeyToPEM(modulusB64, exponentB64) { + const modulus = new Buffer(modulusB64, 'base64'); + const exponent = new Buffer(exponentB64, 'base64'); + const modulusHex = prepadSigned(modulus.toString('hex')); + const exponentHex = prepadSigned(exponent.toString('hex')); + const modlen = modulusHex.length / 2; + const explen = exponentHex.length / 2; + + const encodedModlen = encodeLengthHex(modlen); + const encodedExplen = encodeLengthHex(explen); + const encodedPubkey = '30' + + encodeLengthHex(modlen + explen + encodedModlen.length / 2 + encodedExplen.length / 2 + 2) + + '02' + encodedModlen + modulusHex + + '02' + encodedExplen + exponentHex; + + const der = new Buffer(encodedPubkey, 'hex') + .toString('base64'); + + let pem = '-----BEGIN RSA PUBLIC KEY-----\n'; + pem += `${der.match(/.{1,64}/g).join('\n')}`; + pem += '\n-----END RSA PUBLIC KEY-----\n'; + return pem; +} + +function prepadSigned(hexStr) { + const msb = hexStr[0]; + if (msb < '0' || msb > '7') { + return `00${hexStr}`; + } + return hexStr; +} + +function toHex(number) { + const nstr = number.toString(16); + if (nstr.length % 2) { + return `0${nstr}`; + } + return nstr; +} + +function encodeLengthHex(n) { + if (n <= 127) { + return toHex(n); + } + const nHex = toHex(n); + const lengthOfLengthByte = 128 + nHex.length / 2; + return toHex(lengthOfLengthByte) + nHex; +} From d45d47cbb9fa87db629503f4705cfef3317e6770 Mon Sep 17 00:00:00 2001 From: SebC99 Date: Fri, 26 Jun 2020 21:04:06 +0200 Subject: [PATCH 2/3] Update tests to the new google auth --- spec/AuthenticationAdapters.spec.js | 134 ++++++++++++++++++++-------- 1 file changed, 96 insertions(+), 38 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 1dfd190e7c..e1b818772a 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -66,7 +66,7 @@ describe('AuthenticationProviders', function() { }); it(`should provide the right responses for adapter ${providerName}`, async () => { - const noResponse = ['twitter', 'apple', 'gcenter']; + const noResponse = ['twitter', 'apple', 'gcenter', 'google']; if (noResponse.includes(providerName)) { return; } @@ -627,68 +627,126 @@ describe('instagram auth adapter', () => { describe('google auth adapter', () => { const google = require('../lib/Adapters/Auth/google'); + const https = require('https'); + const jwt = require('jsonwebtoken'); const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); - it('should use id_token for validation is passed', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ sub: 'userId' }); - }); - await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); + it('should throw error with missing id_token', async () => { + try { + await google.validateAuthData({}, {}); + fail(); + } catch (e) { + expect(e.message).toBe('id token is invalid for this user.'); + } }); - it('should use id_token for validation is passed and responds with user_id', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ user_id: 'userId' }); - }); - await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); + it('should not decode invalid id_token', async () => { + try { + await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {}); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } }); - it('should use access_token for validation is passed and responds with user_id', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ user_id: 'userId' }); - }); - await google.validateAuthData( - { id: 'userId', access_token: 'the_token' }, - {} + // it('should throw error if public key used to encode token is not available', async () => { + // const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } }; + // try { + // spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + + // await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {}); + // fail(); + // } catch (e) { + // expect(e.message).toBe( + // `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}` + // ); + // } + // }); + + it('(using client id as string) should verify id_token', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } ); + expect(result).toEqual(fakeClaim); }); - it('should use access_token for validation is passed with sub', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ sub: 'userId' }); - }); - await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {}); - }); + it('(using client id as string) should throw error with with invalid jwt issuer', async () => { + const fakeClaim = { + iss: 'https://not.google.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); - it('should fail when the id_token is invalid', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ sub: 'badId' }); - }); try { await google.validateAuthData( - { id: 'userId', id_token: 'the_token' }, - {} + { id: 'the_user_id', id_token: 'the_token' }, + { clientId: 'secret' } ); fail(); } catch (e) { - expect(e.message).toBe('Google auth is invalid for this user.'); + expect(e.message).toBe( + 'id token not issued by correct provider - expected: https://accounts.google.com | from: https://not.google.com' + ); } }); - it('should fail when the access_token is invalid', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ sub: 'badId' }); - }); + xit('(using client id as string) should throw error with invalid jwt client_id', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await google.validateAuthData( + { id: 'INSERT ID HERE', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }) + + xit('should throw error with invalid user id', async () => { + const fakeClaim = { + iss: 'https://accounts.google.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + try { await google.validateAuthData( - { id: 'userId', access_token: 'the_token' }, - {} + { id: 'invalid user', token: 'INSERT APPLE TOKEN HERE' }, + { clientId: 'INSERT CLIENT ID HERE' } ); fail(); } catch (e) { - expect(e.message).toBe('Google auth is invalid for this user.'); + expect(e.message).toBe('auth data is invalid for this user.'); } }); + }); describe('google play games service auth', () => { From 4eb7b84b50678f900374f354ab968c5e05ca6c24 Mon Sep 17 00:00:00 2001 From: SebC99 Date: Fri, 26 Jun 2020 21:26:22 +0200 Subject: [PATCH 3/3] lint --- spec/AuthenticationAdapters.spec.js | 92 +++++++++++++++-------------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index e1b818772a..53b701f544 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -19,7 +19,7 @@ const responses = { microsoft: { id: 'userId', mail: 'userMail' }, }; -describe('AuthenticationProviders', function() { +describe('AuthenticationProviders', function () { [ 'apple', 'gcenter', @@ -42,8 +42,8 @@ describe('AuthenticationProviders', function() { 'weibo', 'phantauth', 'microsoft', - ].map(function(providerName) { - it('Should validate structure of ' + providerName, done => { + ].map(function (providerName) { + it('Should validate structure of ' + providerName, (done) => { const provider = require('../lib/Adapters/Auth/' + providerName); jequal(typeof provider.validateAuthData, 'function'); jequal(typeof provider.validateAppId, 'function'); @@ -71,7 +71,7 @@ describe('AuthenticationProviders', function() { return; } spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake( - options => { + (options) => { if ( options === 'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.59&grant_type=client_credentials' @@ -101,7 +101,7 @@ describe('AuthenticationProviders', function() { }); }); - const getMockMyOauthProvider = function() { + const getMockMyOauthProvider = function () { return { authData: { id: '12345', @@ -114,7 +114,7 @@ describe('AuthenticationProviders', function() { synchronizedAuthToken: null, synchronizedExpiration: null, - authenticate: function(options) { + authenticate: function (options) { if (this.shouldError) { options.error(this, 'An error occurred'); } else if (this.shouldCancel) { @@ -123,7 +123,7 @@ describe('AuthenticationProviders', function() { options.success(this, this.authData); } }, - restoreAuthentication: function(authData) { + restoreAuthentication: function (authData) { if (!authData) { this.synchronizedUserId = null; this.synchronizedAuthToken = null; @@ -135,10 +135,10 @@ describe('AuthenticationProviders', function() { this.synchronizedExpiration = authData.expiration_date; return true; }, - getAuthType: function() { + getAuthType: function () { return 'myoauth'; }, - deauthenticate: function() { + deauthenticate: function () { this.loggedOut = true; this.restoreAuthentication(null); }, @@ -146,16 +146,16 @@ describe('AuthenticationProviders', function() { }; Parse.User.extend({ - extended: function() { + extended: function () { return true; }, }); - const createOAuthUser = function(callback) { + const createOAuthUser = function (callback) { return createOAuthUserWithSessionToken(undefined, callback); }; - const createOAuthUserWithSessionToken = function(token, callback) { + const createOAuthUserWithSessionToken = function (token, callback) { const jsonBody = { authData: { myoauth: getMockMyOauthProvider().authData, @@ -175,7 +175,7 @@ describe('AuthenticationProviders', function() { body: jsonBody, }; return request(options) - .then(response => { + .then((response) => { if (callback) { callback(null, response, response.data); } @@ -184,7 +184,7 @@ describe('AuthenticationProviders', function() { body: response.data, }; }) - .catch(error => { + .catch((error) => { if (callback) { callback(error); } @@ -192,7 +192,7 @@ describe('AuthenticationProviders', function() { }); }; - it('should create user with REST API', done => { + it('should create user with REST API', (done) => { createOAuthUser((error, response, body) => { expect(error).toBe(null); const b = body; @@ -203,7 +203,7 @@ describe('AuthenticationProviders', function() { const q = new Parse.Query('_Session'); q.equalTo('sessionToken', sessionToken); q.first({ useMasterKey: true }) - .then(res => { + .then((res) => { if (!res) { fail('should not fail fetching the session'); done(); @@ -219,7 +219,7 @@ describe('AuthenticationProviders', function() { }); }); - it('should only create a single user with REST API', done => { + it('should only create a single user with REST API', (done) => { let objectId; createOAuthUser((error, response, body) => { expect(error).toBe(null); @@ -239,9 +239,9 @@ describe('AuthenticationProviders', function() { }); }); - it("should fail to link if session token don't match user", done => { + it("should fail to link if session token don't match user", (done) => { Parse.User.signUp('myUser', 'password') - .then(user => { + .then((user) => { return createOAuthUserWithSessionToken(user.getSessionToken()); }) .then(() => { @@ -250,7 +250,7 @@ describe('AuthenticationProviders', function() { .then(() => { return Parse.User.signUp('myUser2', 'password'); }) - .then(user => { + .then((user) => { return createOAuthUserWithSessionToken(user.getSessionToken()); }) .then(fail, ({ data }) => { @@ -330,16 +330,16 @@ describe('AuthenticationProviders', function() { expect(typeof authAdapter.validateAppId).toBe('function'); } - it('properly loads custom adapter', done => { + it('properly loads custom adapter', (done) => { const validAuthData = { id: 'hello', token: 'world', }; const adapter = { - validateAppId: function() { + validateAppId: function () { return Promise.resolve(); }, - validateAuthData: function(authData) { + validateAuthData: function (authData) { if ( authData.id == validAuthData.id && authData.token == validAuthData.token @@ -370,14 +370,14 @@ describe('AuthenticationProviders', function() { expect(appIdSpy).not.toHaveBeenCalled(); done(); }, - err => { + (err) => { jfail(err); done(); } ); }); - it('properly loads custom adapter module object', done => { + it('properly loads custom adapter module object', (done) => { const authenticationHandler = authenticationLoader({ customAuthentication: path.resolve('./spec/support/CustomAuth.js'), }); @@ -394,14 +394,14 @@ describe('AuthenticationProviders', function() { () => { done(); }, - err => { + (err) => { jfail(err); done(); } ); }); - it('properly loads custom adapter module object (again)', done => { + it('properly loads custom adapter module object (again)', (done) => { const authenticationHandler = authenticationLoader({ customAuthentication: { module: path.resolve('./spec/support/CustomAuthFunction.js'), @@ -421,7 +421,7 @@ describe('AuthenticationProviders', function() { () => { done(); }, - err => { + (err) => { jfail(err); done(); } @@ -530,7 +530,7 @@ describe('AuthenticationProviders', function() { expect(providerOptions.appSecret).toEqual('secret'); }); - it('should fail if Facebook appIds is not configured properly', done => { + it('should fail if Facebook appIds is not configured properly', (done) => { const options = { facebookaccountkit: { appIds: [], @@ -540,13 +540,13 @@ describe('AuthenticationProviders', function() { 'facebookaccountkit', options ); - adapter.validateAppId(appIds).then(done.fail, err => { + adapter.validateAppId(appIds).then(done.fail, (err) => { expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); done(); }); }); - it('should fail to validate Facebook accountkit auth with bad token', done => { + it('should fail to validate Facebook accountkit auth with bad token', (done) => { const options = { facebookaccountkit: { appIds: ['a', 'b'], @@ -560,14 +560,14 @@ describe('AuthenticationProviders', function() { 'facebookaccountkit', options ); - adapter.validateAuthData(authData).then(done.fail, err => { + adapter.validateAuthData(authData).then(done.fail, (err) => { expect(err.code).toBe(190); expect(err.type).toBe('OAuthException'); done(); }); }); - it('should fail to validate Facebook accountkit auth with bad token regardless of app secret proof', done => { + it('should fail to validate Facebook accountkit auth with bad token regardless of app secret proof', (done) => { const options = { facebookaccountkit: { appIds: ['a', 'b'], @@ -582,11 +582,13 @@ describe('AuthenticationProviders', function() { 'facebookaccountkit', options ); - adapter.validateAuthData(authData, providerOptions).then(done.fail, err => { - expect(err.code).toBe(190); - expect(err.type).toBe('OAuthException'); - done(); - }); + adapter + .validateAuthData(authData, providerOptions) + .then(done.fail, (err) => { + expect(err.code).toBe(190); + expect(err.type).toBe('OAuthException'); + done(); + }); }); }); @@ -627,9 +629,7 @@ describe('instagram auth adapter', () => { describe('google auth adapter', () => { const google = require('../lib/Adapters/Auth/google'); - const https = require('https'); const jwt = require('jsonwebtoken'); - const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); it('should throw error with missing id_token', async () => { try { @@ -642,7 +642,10 @@ describe('google auth adapter', () => { it('should not decode invalid id_token', async () => { try { - await google.validateAuthData({ id: 'the_user_id', id_token: 'the_token' }, {}); + await google.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + {} + ); fail(); } catch (e) { expect(e.message).toBe('provided token does not decode as JWT'); @@ -723,7 +726,7 @@ describe('google auth adapter', () => { } catch (e) { expect(e.message).toBe('jwt audience invalid. expected: secret'); } - }) + }); xit('should throw error with invalid user id', async () => { const fakeClaim = { @@ -746,7 +749,6 @@ describe('google auth adapter', () => { expect(e.message).toBe('auth data is invalid for this user.'); } }); - }); describe('google play games service auth', () => { @@ -1651,13 +1653,13 @@ describe('microsoft graph auth adapter', () => { }); }); - it('should fail to validate Microsoft Graph auth with bad token', done => { + 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 => { + microsoft.validateAuthData(authData).then(done.fail, (err) => { expect(err.code).toBe(101); expect(err.message).toBe( 'Microsoft Graph auth is invalid for this user.'