mirror of
https://github.com/parse-community/parse-server
synced 2026-08-09 13:03:18 +00:00
fix: JWT Algorithm Confusion in Google Auth Adapter ([GHSA-4q3h-vp4r-prv2](https://github.com/parse-community/parse-server/security/advisories/GHSA-4q3h-vp4r-prv2)) (#10072)
This commit is contained in:
@@ -500,19 +500,60 @@ describe('google auth adapter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// it('should throw error if public key used to encode token is not available', async () => {
|
||||
// const fakeDecodedToken = { header: { kid: '789', alg: 'RS256' } };
|
||||
// try {
|
||||
// spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
it('should reject forged alg:none JWT from advisory PoC (GHSA-4q3h-vp4r-prv2)', async () => {
|
||||
const header = Buffer.from('{"alg":"none","kid":"nonexistent-key","typ":"JWT"}').toString('base64url');
|
||||
const payload = Buffer.from('{"sub":"the_user_id","iss":"accounts.google.com","aud":"secret","exp":9999999999}').toString('base64url');
|
||||
const forgedToken = `${header}.${payload}.`;
|
||||
|
||||
// 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}`
|
||||
// );
|
||||
// }
|
||||
// });
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
|
||||
try {
|
||||
await google.validateAuthData(
|
||||
{ id: 'the_user_id', id_token: forgedToken },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail('should have rejected forged token');
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://accounts.google.com',
|
||||
aud: 'secret',
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { kid: '123', alg: 'ES256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
await google.validateAuthData(
|
||||
{ id: 'the_user_id', id_token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should throw error if Google signing key is not found', async () => {
|
||||
const fakeDecodedToken = { kid: '789', alg: 'RS256' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.rejectWith(new Error('key not found'));
|
||||
|
||||
try {
|
||||
await google.validateAuthData(
|
||||
{ id: 'the_user_id', id_token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
fail('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e.message).toBe('Unable to find matching key for Key ID: 789');
|
||||
}
|
||||
});
|
||||
|
||||
it('(using client id as string) should verify id_token (google.com)', async () => {
|
||||
const fakeClaim = {
|
||||
@@ -521,8 +562,10 @@ describe('google auth adapter', () => {
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
const result = await google.validateAuthData(
|
||||
@@ -537,8 +580,10 @@ describe('google auth adapter', () => {
|
||||
iss: 'https://not.google.com',
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
@@ -561,8 +606,10 @@ describe('google auth adapter', () => {
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
@@ -583,8 +630,10 @@ describe('google auth adapter', () => {
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
try {
|
||||
@@ -897,7 +946,27 @@ describe('apple signin auth adapter', () => {
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://appleid.apple.com',
|
||||
aud: 'secret',
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { kid: '123', alg: 'none' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
await apple.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should not verify invalid id_token', async () => {
|
||||
@@ -1236,7 +1305,27 @@ describe('facebook limited auth adapter', () => {
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(result).toEqual(fakeClaim);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should pass hardcoded RS256 algorithm to jwt.verify, not the JWT header alg (GHSA-4q3h-vp4r-prv2)', async () => {
|
||||
const fakeClaim = {
|
||||
iss: 'https://www.facebook.com',
|
||||
aud: 'secret',
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { kid: '123', alg: 'none' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
|
||||
await facebook.validateAuthData(
|
||||
{ id: 'the_user_id', token: 'the_token' },
|
||||
{ clientId: 'secret' }
|
||||
);
|
||||
expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']);
|
||||
});
|
||||
|
||||
it('should not verify invalid id_token', async () => {
|
||||
|
||||
+3
-1
@@ -673,8 +673,10 @@ describe('server', () => {
|
||||
exp: Date.now(),
|
||||
sub: 'the_user_id',
|
||||
};
|
||||
const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } };
|
||||
const fakeDecodedToken = { kid: '123', alg: 'RS256' };
|
||||
const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' };
|
||||
spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken);
|
||||
spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey);
|
||||
spyOn(jwt, 'verify').and.callFake(() => fakeClaim);
|
||||
const user = new Parse.User();
|
||||
user
|
||||
|
||||
@@ -77,7 +77,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`);
|
||||
}
|
||||
|
||||
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
|
||||
const { kid: keyId } = authUtils.getHeaderFromToken(token);
|
||||
const ONE_HOUR_IN_MS = 3600000;
|
||||
let jwtClaims;
|
||||
|
||||
@@ -89,7 +89,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa
|
||||
|
||||
try {
|
||||
jwtClaims = jwt.verify(token, signingKey, {
|
||||
algorithms: algorithm,
|
||||
algorithms: ['RS256'],
|
||||
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.');
|
||||
}
|
||||
|
||||
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
|
||||
const { kid: keyId } = authUtils.getHeaderFromToken(token);
|
||||
const ONE_HOUR_IN_MS = 3600000;
|
||||
let jwtClaims;
|
||||
|
||||
@@ -148,7 +148,7 @@ const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMa
|
||||
|
||||
try {
|
||||
jwtClaims = jwt.verify(token, signingKey, {
|
||||
algorithms: algorithm,
|
||||
algorithms: ['RS256'],
|
||||
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
+34
-105
@@ -4,6 +4,8 @@
|
||||
* @class GoogleAdapter
|
||||
* @param {Object} options - The adapter configuration options.
|
||||
* @param {string} options.clientId - Your Google application Client ID. Required for authentication.
|
||||
* @param {number} [options.cacheMaxEntries] - Maximum number of JWKS cache entries. Default: 5.
|
||||
* @param {number} [options.cacheMaxAge] - Maximum age of JWKS cache entries in ms. Default: 3600000 (1 hour).
|
||||
*
|
||||
* @description
|
||||
* ## Parse Server Configuration
|
||||
@@ -21,7 +23,6 @@
|
||||
* The adapter requires the following `authData` fields:
|
||||
* - **id**: The Google user ID.
|
||||
* - **id_token**: The Google ID token.
|
||||
* - **access_token**: The Google access token.
|
||||
*
|
||||
* ## Auth Payload
|
||||
* ### Example Auth Data Payload
|
||||
@@ -29,85 +30,67 @@
|
||||
* {
|
||||
* "google": {
|
||||
* "id": "1234567",
|
||||
* "id_token": "xxxxx.yyyyy.zzzzz",
|
||||
* "access_token": "abc123def456ghi789"
|
||||
* "id_token": "xxxxx.yyyyy.zzzzz"
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Notes
|
||||
* - Ensure your Google Client ID is configured properly in the Parse Server configuration.
|
||||
* - The `id_token` and `access_token` are validated against Google's authentication services.
|
||||
* - The `id_token` is validated against Google's authentication services.
|
||||
*
|
||||
* @see {@link https://developers.google.com/identity/sign-in/web/backend-auth Google Authentication Documentation}
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Helper functions for accessing the google API.
|
||||
var Parse = require('parse/node').Parse;
|
||||
|
||||
const https = require('https');
|
||||
const jwksClient = require('jwks-rsa');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const authUtils = require('./utils');
|
||||
|
||||
const TOKEN_ISSUER = 'accounts.google.com';
|
||||
const HTTPS_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);
|
||||
const getGoogleKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
|
||||
const client = jwksClient({
|
||||
jwksUri: 'https://www.googleapis.com/oauth2/v3/certs',
|
||||
cache: true,
|
||||
cacheMaxEntries,
|
||||
cacheMaxAge,
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyIdToken({ id_token: token, id }, { clientId }) {
|
||||
let key;
|
||||
try {
|
||||
key = await authUtils.getSigningKey(client, keyId);
|
||||
} catch {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.OBJECT_NOT_FOUND,
|
||||
`Unable to find matching key for Key ID: ${keyId}`
|
||||
);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
async function verifyIdToken({ id_token: token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) {
|
||||
if (!token) {
|
||||
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`);
|
||||
}
|
||||
|
||||
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
|
||||
const { kid: keyId } = authUtils.getHeaderFromToken(token);
|
||||
const ONE_HOUR_IN_MS = 3600000;
|
||||
let jwtClaims;
|
||||
const googleKey = await getGoogleKeyByKeyId(keyId);
|
||||
|
||||
cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS;
|
||||
cacheMaxEntries = cacheMaxEntries || 5;
|
||||
|
||||
const googleKey = await getGoogleKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge);
|
||||
const signingKey = googleKey.publicKey || googleKey.rsaPublicKey;
|
||||
|
||||
try {
|
||||
jwtClaims = jwt.verify(token, googleKey, {
|
||||
algorithms: algorithm,
|
||||
jwtClaims = jwt.verify(token, signingKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience: clientId,
|
||||
});
|
||||
} catch (exception) {
|
||||
@@ -150,57 +133,3 @@ module.exports = {
|
||||
validateAppId: validateAppId,
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user