From 6fb05a779c7d620d4b2ed8cc74851aece520d77c Mon Sep 17 00:00:00 2001 From: Lukas Reichart Date: Thu, 19 Aug 2021 10:51:08 +0200 Subject: [PATCH] Implemented auth0 adapater. --- spec/AuthenticationAdapters.spec.js | 180 +++++++++++++++++++++++++++- src/Adapters/Auth/auth0.js | 106 ++++++++++++++++ src/Adapters/Auth/index.js | 2 + 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 src/Adapters/Auth/auth0.js diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 9c6cfc6351..2b567eed05 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -41,6 +41,7 @@ describe('AuthenticationProviders', function () { 'phantauth', 'microsoft', 'keycloak', + 'auth0', ].map(function (providerName) { it('Should validate structure of ' + providerName, done => { const provider = require('../lib/Adapters/Auth/' + providerName); @@ -62,7 +63,7 @@ describe('AuthenticationProviders', function () { }); it(`should provide the right responses for adapter ${providerName}`, async () => { - const noResponse = ['twitter', 'apple', 'gcenter', 'google', 'keycloak']; + const noResponse = ['twitter', 'apple', 'gcenter', 'google', 'keycloak', 'auth0']; if (noResponse.includes(providerName)) { return; } @@ -897,6 +898,183 @@ describe('keycloak auth adapter', () => { }); }); +describe('auth0 auth adapter', () => { + const auth0 = require('../lib/Adapters/Auth/auth0'); + const jwt = require('jsonwebtoken'); + const util = require('util'); + + it('Should throw error with missing id_token', async () => { + try { + await auth0.validateAuthData({}, { tenantId: 'example.eu.auth0.com', clientId: 'secret' }); + fail(); + } catch (e) { + expect(e.message).toBe('id token is invalid for this user.'); + } + }); + + it('Should throw error with missing tenant_id', async () => { + try { + await auth0.validateAuthData({ id_token: 'token' }, { clientId: 'secret' }); + fail(); + } catch (e) { + expect(e.message).toBe('tenant id is invalid.'); + } + }); + + it('Should throw error with missing client_id', async () => { + try { + await auth0.validateAuthData({ id_token: 'token' }, { tenantId: 'example.eu.auth0.com' }); + fail(); + } catch (e) { + expect(e.message).toBe('client id is invalid.'); + } + }); + + it('should not decode invalid id_token', async () => { + try { + await auth0.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { tenantId: 'example.eu.auth0.com', clientId: 'secret' } + ); + fail(); + } catch (e) { + 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 auth0.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { tenantId: 'example.eu.auth0.com', clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid} for auth0 tenantId example.eu.auth0.com.` + ); + } + }); + + it('should use algorithm from key header to verify id_token', async () => { + const fakeClaim = { + iss: 'https://example.eu.auth0.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 fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + + const result = await auth0.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { tenantId: 'example.eu.auth0.com', clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg); + }); + + it('should not verify invalid id_token', async () => { + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + + try { + await auth0.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { tenantId: 'example.eu.auth0.com', clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt malformed'); + } + }); + + it('should verify id_token', async () => { + const fakeClaim = { + iss: 'https://example.eu.auth0.com/', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await auth0.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { tenantId: 'example.eu.auth0.com', clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + }); + + it('should throw error with with invalid jwt issuer', async () => { + const fakeClaim = { + iss: 'https://not.example.eu.auth0.com/', + sub: 'the_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await auth0.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { tenantId: 'example.eu.auth0.com', clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct OpenID provider - expected: https://example.eu.auth0.com/ | from: https://not.example.eu.auth0.com/' + ); + } + }); + + it('should throw error with with invalid user id', async () => { + const fakeClaim = { + iss: 'https://example.eu.auth0.com/', + aud: 'invalid_client_id', + sub: 'a_different_user_id', + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await auth0.validateAuthData( + { id: 'the_user_id', id_token: 'the_token' }, + { tenantId: 'example.eu.auth0.com', clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); +}); + describe('oauth2 auth adapter', () => { const oauth2 = require('../lib/Adapters/Auth/oauth2'); const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); diff --git a/src/Adapters/Auth/auth0.js b/src/Adapters/Auth/auth0.js new file mode 100644 index 0000000000..3ae63cb775 --- /dev/null +++ b/src/Adapters/Auth/auth0.js @@ -0,0 +1,106 @@ +'use strict'; + +// tenantId: the auth0 tenant, e.g. example.eu.auth0.com (NO HTTP / HTTPS) +// clientId: the auth0 client id, can be found in the auth0 console. + +var Parse = require('parse/node').Parse; +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); +const util = require('util'); + +const getAuth0KeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge, tenantId) => { + const client = jwksClient({ + jwksUri: `https://${tenantId}/.well-known/jwks.json`, + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + const asyncGetSigningKeyFunction = util.promisify(client.getSigningKey); + + let key; + try { + key = await asyncGetSigningKeyFunction(keyId); + } catch (error) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId} for auth0 tenantId ${tenantId}.` + ); + } + return key; +}; + +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; +} + +async function verifyIdToken( + { id_token: token, id }, + { tenantId, clientId, cacheMaxEntries, cacheMaxAge } +) { + if (!token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `id token is invalid for this user.`); + } + if (!clientId) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `client id is invalid.`); + } + if (!tenantId) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `tenant id is invalid.`); + } + + const { kid: keyId, alg: algorithm } = getHeaderFromToken(token); + let jwtClaims; + const ONE_HOUR_IN_MS = 3600000; + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const auth0Key = await getAuth0KeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge, tenantId); + const signingKey = auth0Key.publicKey || auth0Key.rsaPublicKey; + + try { + jwtClaims = jwt.verify(token, signingKey, { + algorithms: algorithm, + // the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions. + audience: clientId, + }); + } catch (exception) { + const message = exception.message; + + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`); + } + + const tokenIssuer = `https://${tenantId}/`; + + if (jwtClaims.iss !== tokenIssuer) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `id token not issued by correct OpenID provider - expected: ${tokenIssuer} | from: ${jwtClaims.iss}` + ); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `auth data is invalid for this user.`); + } + 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. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +}; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 00637d1131..8d8cd34553 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -24,6 +24,7 @@ const phantauth = require('./phantauth'); const microsoft = require('./microsoft'); const keycloak = require('./keycloak'); const ldap = require('./ldap'); +const auth0 = require('./auth0'); const anonymous = { validateAuthData: () => { @@ -59,6 +60,7 @@ const providers = { microsoft, keycloak, ldap, + auth0, }; function authDataValidator(adapter, appIds, options) {