Skip to content

Implemented auth0 adapater. #7502

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: alpha
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 179 additions & 1 deletion spec/AuthenticationAdapters.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -1024,6 +1025,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' });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer

await expectAsync(auth0.validateAuthData({}, { tenantId: 'example.eu.auth0.com', clientId: 'secret' })).toBeRejectedWith(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.'));

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 = () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could probably simplify this to:

const fakeGetSigningKeyAsyncFunction = () => ({ kid: '123', rsaPublicKey: 'the_rsa_public_key' })

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' }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a URI that is made available for development use per policy?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not, as far as I know, I just choose a value for the tests. Since the tenant is not really used in the tests, it does not really matter. Another option would be to create an auth0 account and register a tenant, but then someone from the Parse team would have to manage this account.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any benefit regarding test efficacy if we create an auth0 account? If not, I suggest we comply with RFC 2606 and replace the URLs in the tests with example.com.

);
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');
Expand Down
106 changes: 106 additions & 0 deletions src/Adapters/Auth/auth0.js
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no var

const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const util = require('util');

const getAuth0KeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge, tenantId) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some functions are arrow syntax others are regular function syntax - I would recommend consistency

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer return await asyncGetSigningKeyFunction(keyId);, no need to define key

} 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use default parameters on the object destructure?

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can use destructuring assignment here, i.e:

module.exports = {
  validateAppId,
  validateAuthData
}

validateAuthData: validateAuthData,
};
2 changes: 2 additions & 0 deletions src/Adapters/Auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const phantauth = require('./phantauth');
const microsoft = require('./microsoft');
const keycloak = require('./keycloak');
const ldap = require('./ldap');
const auth0 = require('./auth0');

const anonymous = {
validateAuthData: () => {
Expand Down Expand Up @@ -63,6 +64,7 @@ const providers = {
microsoft,
keycloak,
ldap,
auth0,
};

// Indexed auth policies
Expand Down