Add production Google Auth Adapter instead of using the development url (#6734)

* Add the production Google Auth Adapter instead of using the development url

* Update tests to the new google auth

* lint
This commit is contained in:
SebC
2020-07-15 18:56:08 +02:00
committed by GitHub
parent 36bee12c24
commit cbf9da517b
2 changed files with 278 additions and 129 deletions

View File

@@ -43,7 +43,7 @@ describe('AuthenticationProviders', function() {
'phantauth', 'phantauth',
'microsoft', 'microsoft',
].map(function (providerName) { ].map(function (providerName) {
it('Should validate structure of ' + providerName, done => { it('Should validate structure of ' + providerName, (done) => {
const provider = require('../lib/Adapters/Auth/' + providerName); const provider = require('../lib/Adapters/Auth/' + providerName);
jequal(typeof provider.validateAuthData, 'function'); jequal(typeof provider.validateAuthData, 'function');
jequal(typeof provider.validateAppId, 'function'); jequal(typeof provider.validateAppId, 'function');
@@ -66,12 +66,12 @@ describe('AuthenticationProviders', function() {
}); });
it(`should provide the right responses for adapter ${providerName}`, async () => { 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)) { if (noResponse.includes(providerName)) {
return; return;
} }
spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake( spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake(
options => { (options) => {
if ( if (
options === options ===
'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.59&grant_type=client_credentials' 'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.59&grant_type=client_credentials'
@@ -175,7 +175,7 @@ describe('AuthenticationProviders', function() {
body: jsonBody, body: jsonBody,
}; };
return request(options) return request(options)
.then(response => { .then((response) => {
if (callback) { if (callback) {
callback(null, response, response.data); callback(null, response, response.data);
} }
@@ -184,7 +184,7 @@ describe('AuthenticationProviders', function() {
body: response.data, body: response.data,
}; };
}) })
.catch(error => { .catch((error) => {
if (callback) { if (callback) {
callback(error); 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) => { createOAuthUser((error, response, body) => {
expect(error).toBe(null); expect(error).toBe(null);
const b = body; const b = body;
@@ -203,7 +203,7 @@ describe('AuthenticationProviders', function() {
const q = new Parse.Query('_Session'); const q = new Parse.Query('_Session');
q.equalTo('sessionToken', sessionToken); q.equalTo('sessionToken', sessionToken);
q.first({ useMasterKey: true }) q.first({ useMasterKey: true })
.then(res => { .then((res) => {
if (!res) { if (!res) {
fail('should not fail fetching the session'); fail('should not fail fetching the session');
done(); 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; let objectId;
createOAuthUser((error, response, body) => { createOAuthUser((error, response, body) => {
expect(error).toBe(null); 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') Parse.User.signUp('myUser', 'password')
.then(user => { .then((user) => {
return createOAuthUserWithSessionToken(user.getSessionToken()); return createOAuthUserWithSessionToken(user.getSessionToken());
}) })
.then(() => { .then(() => {
@@ -250,7 +250,7 @@ describe('AuthenticationProviders', function() {
.then(() => { .then(() => {
return Parse.User.signUp('myUser2', 'password'); return Parse.User.signUp('myUser2', 'password');
}) })
.then(user => { .then((user) => {
return createOAuthUserWithSessionToken(user.getSessionToken()); return createOAuthUserWithSessionToken(user.getSessionToken());
}) })
.then(fail, ({ data }) => { .then(fail, ({ data }) => {
@@ -330,7 +330,7 @@ describe('AuthenticationProviders', function() {
expect(typeof authAdapter.validateAppId).toBe('function'); expect(typeof authAdapter.validateAppId).toBe('function');
} }
it('properly loads custom adapter', done => { it('properly loads custom adapter', (done) => {
const validAuthData = { const validAuthData = {
id: 'hello', id: 'hello',
token: 'world', token: 'world',
@@ -370,14 +370,14 @@ describe('AuthenticationProviders', function() {
expect(appIdSpy).not.toHaveBeenCalled(); expect(appIdSpy).not.toHaveBeenCalled();
done(); done();
}, },
err => { (err) => {
jfail(err); jfail(err);
done(); done();
} }
); );
}); });
it('properly loads custom adapter module object', done => { it('properly loads custom adapter module object', (done) => {
const authenticationHandler = authenticationLoader({ const authenticationHandler = authenticationLoader({
customAuthentication: path.resolve('./spec/support/CustomAuth.js'), customAuthentication: path.resolve('./spec/support/CustomAuth.js'),
}); });
@@ -394,14 +394,14 @@ describe('AuthenticationProviders', function() {
() => { () => {
done(); done();
}, },
err => { (err) => {
jfail(err); jfail(err);
done(); done();
} }
); );
}); });
it('properly loads custom adapter module object (again)', done => { it('properly loads custom adapter module object (again)', (done) => {
const authenticationHandler = authenticationLoader({ const authenticationHandler = authenticationLoader({
customAuthentication: { customAuthentication: {
module: path.resolve('./spec/support/CustomAuthFunction.js'), module: path.resolve('./spec/support/CustomAuthFunction.js'),
@@ -421,7 +421,7 @@ describe('AuthenticationProviders', function() {
() => { () => {
done(); done();
}, },
err => { (err) => {
jfail(err); jfail(err);
done(); done();
} }
@@ -530,7 +530,7 @@ describe('AuthenticationProviders', function() {
expect(providerOptions.appSecret).toEqual('secret'); 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 = { const options = {
facebookaccountkit: { facebookaccountkit: {
appIds: [], appIds: [],
@@ -540,13 +540,13 @@ describe('AuthenticationProviders', function() {
'facebookaccountkit', 'facebookaccountkit',
options options
); );
adapter.validateAppId(appIds).then(done.fail, err => { adapter.validateAppId(appIds).then(done.fail, (err) => {
expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
done(); 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 = { const options = {
facebookaccountkit: { facebookaccountkit: {
appIds: ['a', 'b'], appIds: ['a', 'b'],
@@ -560,14 +560,14 @@ describe('AuthenticationProviders', function() {
'facebookaccountkit', 'facebookaccountkit',
options options
); );
adapter.validateAuthData(authData).then(done.fail, err => { adapter.validateAuthData(authData).then(done.fail, (err) => {
expect(err.code).toBe(190); expect(err.code).toBe(190);
expect(err.type).toBe('OAuthException'); expect(err.type).toBe('OAuthException');
done(); 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 = { const options = {
facebookaccountkit: { facebookaccountkit: {
appIds: ['a', 'b'], appIds: ['a', 'b'],
@@ -582,7 +582,9 @@ describe('AuthenticationProviders', function() {
'facebookaccountkit', 'facebookaccountkit',
options options
); );
adapter.validateAuthData(authData, providerOptions).then(done.fail, err => { adapter
.validateAuthData(authData, providerOptions)
.then(done.fail, (err) => {
expect(err.code).toBe(190); expect(err.code).toBe(190);
expect(err.type).toBe('OAuthException'); expect(err.type).toBe('OAuthException');
done(); done();
@@ -627,66 +629,124 @@ describe('instagram auth adapter', () => {
describe('google auth adapter', () => { describe('google auth adapter', () => {
const google = require('../lib/Adapters/Auth/google'); const google = require('../lib/Adapters/Auth/google');
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); const jwt = require('jsonwebtoken');
it('should use id_token for validation is passed', async () => { it('should throw error with missing id_token', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ sub: 'userId' });
});
await google.validateAuthData({ id: 'userId', id_token: 'the_token' }, {});
});
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 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 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('should fail when the id_token is invalid', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ sub: 'badId' });
});
try { try {
await google.validateAuthData( await google.validateAuthData({}, {});
{ id: 'userId', id_token: 'the_token' },
{}
);
fail(); fail();
} catch (e) { } catch (e) {
expect(e.message).toBe('Google auth is invalid for this user.'); expect(e.message).toBe('id token is invalid for this user.');
} }
}); });
it('should fail when the access_token is invalid', async () => { it('should not decode invalid id_token', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ sub: 'badId' });
});
try { try {
await google.validateAuthData( await google.validateAuthData(
{ id: 'userId', access_token: 'the_token' }, { id: 'the_user_id', id_token: 'the_token' },
{} {}
); );
fail(); fail();
} catch (e) { } catch (e) {
expect(e.message).toBe('Google auth is invalid for this user.'); expect(e.message).toBe('provided token does not decode as JWT');
}
});
// 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('(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);
try {
await google.validateAuthData(
{ id: 'the_user_id', id_token: 'the_token' },
{ clientId: 'secret' }
);
fail();
} catch (e) {
expect(e.message).toBe(
'id token not issued by correct provider - expected: https://accounts.google.com | from: https://not.google.com'
);
}
});
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: 'invalid user', token: 'INSERT APPLE TOKEN HERE' },
{ clientId: 'INSERT CLIENT ID HERE' }
);
fail();
} catch (e) {
expect(e.message).toBe('auth data is invalid for this user.');
} }
}); });
}); });
@@ -1593,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 = { const authData = {
id: 'fake-id', id: 'fake-id',
mail: 'fake@mail.com', mail: 'fake@mail.com',
access_token: 'very.long.bad.token', 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.code).toBe(101);
expect(err.message).toBe( expect(err.message).toBe(
'Microsoft Graph auth is invalid for this user.' 'Microsoft Graph auth is invalid for this user.'

View File

@@ -1,47 +1,90 @@
"use strict";
// Helper functions for accessing the google API. // Helper functions for accessing the google API.
var Parse = require('parse/node').Parse; var Parse = require('parse/node').Parse;
const httpsRequest = require('./httpsRequest');
function validateIdToken(id, token) { const https = require('https');
return googleRequest('tokeninfo?id_token=' + token).then(response => { const jwt = require('jsonwebtoken');
if (response && (response.sub == id || response.user_id == id)) {
return; 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];
} }
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND, return new Promise((resolve, reject) => {
'Google auth is invalid for this user.' 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) { function getHeaderFromToken(token) {
return googleRequest('tokeninfo?access_token=' + token).then(response => { const decodedToken = jwt.decode(token, {complete: true});
if (response && (response.sub == id || response.user_id == id)) {
return; if (!decodedToken) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `provided token does not decode as JWT`);
} }
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND, return decodedToken.header;
'Google auth is invalid for this user.' }
);
}); 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. // Returns a promise that fulfills if this user id is valid.
function validateAuthData(authData) { function validateAuthData(authData, options) {
if (authData.id_token) { return verifyIdToken(authData, options);
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);
}
);
}
} }
// Returns a promise that fulfills if this app id is valid. // Returns a promise that fulfills if this app id is valid.
@@ -49,12 +92,58 @@ function validateAppId() {
return Promise.resolve(); return Promise.resolve();
} }
// A promisey wrapper for api requests
function googleRequest(path) {
return httpsRequest.get('https://www.googleapis.com/oauth2/v3/' + path);
}
module.exports = { module.exports = {
validateAppId: validateAppId, 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;
}