diff --git a/package-lock.json b/package-lock.json index c5cded070c..c25b540d8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4292,8 +4292,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -4314,14 +4313,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4336,20 +4333,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -4466,8 +4460,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -4479,7 +4472,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4494,7 +4486,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -4502,14 +4493,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.3.5", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4528,7 +4517,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -4609,8 +4597,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -4622,7 +4609,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -4708,8 +4694,7 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -4745,7 +4730,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4765,7 +4749,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -4809,14 +4792,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -6977,7 +6958,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -6986,8 +6966,7 @@ "yallist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", - "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", - "optional": true + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==" } } }, @@ -7511,6 +7490,14 @@ } } }, + "node-rsa": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.0.5.tgz", + "integrity": "sha512-9o51yfV167CtQANnuAf+5owNs7aIMsAKVLhNaKuRxihsUUnfoBMN5OTVOK/2mHSOWaWq9zZBiRM3bHORbTZqrg==", + "requires": { + "asn1": "^0.2.4" + } + }, "nopt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", diff --git a/package.json b/package.json index 6e6a63719c..30e00627ee 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,12 @@ "express": "4.17.1", "follow-redirects": "1.7.0", "intersect": "1.0.1", + "jsonwebtoken": "8.5.1", "lodash": "4.17.11", "lru-cache": "5.1.1", "mime": "2.4.4", "mongodb": "3.2.7", + "node-rsa": "1.0.5", "parse": "2.4.0", "pg-promise": "8.7.2", "redis": "2.8.0", diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 9214506b7b..90e45b9f55 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -17,6 +17,7 @@ const responses = { describe('AuthenticationProviders', function() { [ + 'apple-signin', 'facebook', 'facebookaccountkit', 'github', @@ -50,7 +51,7 @@ describe('AuthenticationProviders', function() { }); it(`should provide the right responses for adapter ${providerName}`, async () => { - if (providerName === 'twitter') { + if (providerName === 'twitter' || providerName === 'apple-signin') { return; } spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake( @@ -1033,3 +1034,83 @@ describe('oauth2 auth adapter', () => { } }); }); + +describe('apple signin auth adapter', () => { + const apple = require('../lib/Adapters/Auth/apple-signin'); + const jwt = require('jsonwebtoken'); + + it('should throw error with missing id_token', async () => { + try { + await apple.validateAuthData({}, { client_id: 'secret' }); + fail(); + } catch (e) { + expect(e.message).toBe('id_token is invalid for this user.'); + } + }); + + it('should not verify invalid id_token', async () => { + try { + await apple.validateAuthData( + { id_token: 'the_token' }, + { client_id: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt malformed'); + } + }); + + it('should verify id_token', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'secret', + exp: Date.now(), + }; + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await apple.validateAuthData( + { id_token: 'the_token' }, + { client_id: 'secret' } + ); + expect(result).toEqual(fakeClaim); + }); + + it('should throw error with with invalid jwt issuer', async () => { + const fakeClaim = { + iss: 'https://not.apple.com', + }; + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await apple.validateAuthData( + { id_token: 'the_token' }, + { client_id: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id_token not issued by correct OpenID provider - expected: https://appleid.apple.com | from: https://not.apple.com' + ); + } + }); + + it('should throw error with with invalid jwt client_id', async () => { + const fakeClaim = { + iss: 'https://appleid.apple.com', + aud: 'invalid_client_id', + }; + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await apple.validateAuthData( + { id_token: 'the_token' }, + { client_id: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'jwt aud parameter does not include this client - is: invalid_client_id | expected: secret' + ); + } + }); +}); diff --git a/src/Adapters/Auth/apple-signin.js b/src/Adapters/Auth/apple-signin.js new file mode 100644 index 0000000000..ae21d8c8e3 --- /dev/null +++ b/src/Adapters/Auth/apple-signin.js @@ -0,0 +1,58 @@ +const Parse = require('parse/node').Parse; +const httpsRequest = require('./httpsRequest'); +const NodeRSA = require('node-rsa'); +const jwt = require('jsonwebtoken'); + +const TOKEN_ISSUER = 'https://appleid.apple.com'; + +const getApplePublicKey = async () => { + const data = await httpsRequest.get('https://appleid.apple.com/auth/keys'); + const key = data.keys[0]; + + const pubKey = new NodeRSA(); + pubKey.importKey( + { n: Buffer.from(key.n, 'base64'), e: Buffer.from(key.e, 'base64') }, + 'components-public' + ); + return pubKey.exportKey(['public']); +}; + +const verifyIdToken = async (token, clientID) => { + if (!token) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'id_token is invalid for this user.' + ); + } + const applePublicKey = await getApplePublicKey(); + const jwtClaims = jwt.verify(token, applePublicKey, { algorithms: 'RS256' }); + + if (jwtClaims.iss !== TOKEN_ISSUER) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `id_token not issued by correct OpenID provider - expected: ${TOKEN_ISSUER} | from: ${jwtClaims.iss}` + ); + } + if (clientID !== undefined && jwtClaims.aud !== clientID) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `jwt aud parameter does not include this client - is: ${jwtClaims.aud} | expected: ${clientID}` + ); + } + return jwtClaims; +}; + +// Returns a promise that fulfills if this id_token is valid +function validateAuthData(authData, options = {}) { + return verifyIdToken(authData.id_token, options.client_id); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId: validateAppId, + validateAuthData: validateAuthData, +};