diff --git a/spec/.eslintrc.json b/spec/.eslintrc.json index 933e6ed15d..a169a11f7d 100644 --- a/spec/.eslintrc.json +++ b/spec/.eslintrc.json @@ -5,6 +5,7 @@ "globals": { "Parse": true, "reconfigureServer": true, + "mockFetch": true, "createTestUser": true, "jfail": true, "ok": true, diff --git a/spec/Adapters/Auth/BaseCodeAdapter.spec.js b/spec/Adapters/Auth/BaseCodeAdapter.spec.js new file mode 100644 index 0000000000..fef4b43306 --- /dev/null +++ b/spec/Adapters/Auth/BaseCodeAdapter.spec.js @@ -0,0 +1,182 @@ +const BaseAuthCodeAdapter = require('../../../lib/Adapters/Auth/BaseCodeAuthAdapter').default; + +describe('BaseAuthCodeAdapter', function () { + let adapter; + const adapterName = 'TestAdapter'; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }; + + class TestAuthCodeAdapter extends BaseAuthCodeAdapter { + async getUserFromAccessToken(accessToken) { + if (accessToken === 'validAccessToken') { + return { id: 'validUserId' }; + } + throw new Error('Invalid access token'); + } + + async getAccessTokenFromCode(authData) { + if (authData.code === 'validCode') { + return 'validAccessToken'; + } + throw new Error('Invalid code'); + } + } + + beforeEach(function () { + adapter = new TestAuthCodeAdapter(adapterName); + }); + + describe('validateOptions', function () { + it('should throw error if options are missing', function () { + expect(() => adapter.validateOptions(null)).toThrowError(`${adapterName} options are required.`); + }); + + it('should throw error if clientId is missing in secure mode', function () { + expect(() => + adapter.validateOptions({ clientSecret: 'validClientSecret' }) + ).toThrowError(`${adapterName} clientId is required.`); + }); + + it('should throw error if clientSecret is missing in secure mode', function () { + expect(() => + adapter.validateOptions({ clientId: 'validClientId' }) + ).toThrowError(`${adapterName} clientSecret is required.`); + }); + + it('should not throw error for valid options', function () { + expect(() => adapter.validateOptions(validOptions)).not.toThrow(); + expect(adapter.clientId).toBe('validClientId'); + expect(adapter.clientSecret).toBe('validClientSecret'); + expect(adapter.enableInsecureAuth).toBeUndefined(); + }); + + it('should allow insecure mode without clientId or clientSecret', function () { + const options = { enableInsecureAuth: true }; + expect(() => adapter.validateOptions(options)).not.toThrow(); + expect(adapter.enableInsecureAuth).toBe(true); + }); + }); + + describe('beforeFind', function () { + it('should throw error if code is missing in secure mode', async function () { + adapter.validateOptions(validOptions); + const authData = { access_token: 'validAccessToken' }; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError( + `${adapterName} code is required.` + ); + }); + + it('should throw error if access token is missing in insecure mode', async function () { + adapter.validateOptions({ enableInsecureAuth: true }); + const authData = {}; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError( + `${adapterName} auth is invalid for this user.` + ); + }); + + it('should throw error if user ID does not match in insecure mode', async function () { + adapter.validateOptions({ enableInsecureAuth: true }); + const authData = { id: 'invalidUserId', access_token: 'validAccessToken' }; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError( + `${adapterName} auth is invalid for this user.` + ); + }); + + it('should process valid secure payload and update authData', async function () { + adapter.validateOptions(validOptions); + const authData = { code: 'validCode' }; + + await adapter.beforeFind(authData); + + expect(authData.access_token).toBe('validAccessToken'); + expect(authData.id).toBe('validUserId'); + expect(authData.code).toBeUndefined(); + }); + + it('should process valid insecure payload', async function () { + adapter.validateOptions({ enableInsecureAuth: true }); + const authData = { id: 'validUserId', access_token: 'validAccessToken' }; + + await expectAsync(adapter.beforeFind(authData)).toBeResolved(); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should throw error if not implemented in base class', async function () { + const baseAdapter = new BaseAuthCodeAdapter(adapterName); + + await expectAsync(baseAdapter.getUserFromAccessToken('test')).toBeRejectedWithError( + 'getUserFromAccessToken is not implemented' + ); + }); + + it('should return valid user for valid access token', async function () { + const user = await adapter.getUserFromAccessToken('validAccessToken', {}); + expect(user).toEqual({ id: 'validUserId' }); + }); + + it('should throw error for invalid access token', async function () { + await expectAsync(adapter.getUserFromAccessToken('invalidAccessToken', {})).toBeRejectedWithError( + 'Invalid access token' + ); + }); + }); + + describe('getAccessTokenFromCode', function () { + it('should throw error if not implemented in base class', async function () { + const baseAdapter = new BaseAuthCodeAdapter(adapterName); + + await expectAsync(baseAdapter.getAccessTokenFromCode({ code: 'test' })).toBeRejectedWithError( + 'getAccessTokenFromCode is not implemented' + ); + }); + + it('should return valid access token for valid code', async function () { + const accessToken = await adapter.getAccessTokenFromCode({ code: 'validCode' }); + expect(accessToken).toBe('validAccessToken'); + }); + + it('should throw error for invalid code', async function () { + await expectAsync(adapter.getAccessTokenFromCode({ code: 'invalidCode' })).toBeRejectedWithError( + 'Invalid code' + ); + }); + }); + + describe('validateLogin', function () { + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; + const result = adapter.validateLogin(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); + + describe('validateSetUp', function () { + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; + const result = adapter.validateSetUp(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); + + describe('afterFind', function () { + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; + const result = adapter.afterFind(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); + + describe('validateUpdate', function () { + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; + const result = adapter.validateUpdate(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); +}); diff --git a/spec/Adapters/Auth/gcenter.spec.js b/spec/Adapters/Auth/gcenter.spec.js new file mode 100644 index 0000000000..45e94a527f --- /dev/null +++ b/spec/Adapters/Auth/gcenter.spec.js @@ -0,0 +1,220 @@ +const GameCenterAuth = require('../../../lib/Adapters/Auth/gcenter').default; +const { pki } = require('node-forge'); +const fs = require('fs'); +const path = require('path'); + +describe('GameCenterAuth Adapter', function () { + let adapter; + + beforeEach(function () { + adapter = new GameCenterAuth.constructor(); + + const gcProd4 = fs.readFileSync(path.resolve(__dirname, '../../support/cert/gc-prod-4.cer')); + const digicertPem = fs.readFileSync(path.resolve(__dirname, '../../support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem')).toString(); + + mockFetch([ + { + url: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + method: 'GET', + response: { + ok: true, + headers: new Map(), + arrayBuffer: () => Promise.resolve( + gcProd4.buffer.slice(gcProd4.byteOffset, gcProd4.byteOffset + gcProd4.length) + ), + }, + }, + { + url: 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem', + method: 'GET', + response: { + ok: true, + headers: new Map([['content-type', 'application/x-pem-file'], ['content-length', digicertPem.length.toString()]]), + text: () => Promise.resolve(digicertPem), + }, + } + ]); + }); + + describe('Test config failing due to missing params or wrong types', function () { + it('should throw error for invalid options', async function () { + const invalidOptions = [ + null, + undefined, + {}, + { bundleId: '' }, + { enableInsecureAuth: false }, // Missing bundleId in secure mode + ]; + + for (const options of invalidOptions) { + expect(() => adapter.validateOptions(options)).withContext(JSON.stringify(options)).toThrow() + } + }); + + it('should validate options successfully with valid parameters', function () { + const validOptions = { bundleId: 'com.valid.app', enableInsecureAuth: false }; + expect(() => adapter.validateOptions(validOptions)).not.toThrow(); + }); + }); + + describe('Test payload failing due to missing params or wrong types', function () { + it('should throw error for missing authData fields', async function () { + await expectAsync(adapter.validateAuthData({})).toBeRejectedWithError( + 'AuthData id is missing.' + ); + }); + }); + + describe('Test payload fails due to incorrect appId / certificate', function () { + it('should throw error for invalid publicKeyUrl', async function () { + const invalidPublicKeyUrl = 'https://malicious.url.com/key.cer'; + + spyOn(adapter, 'fetchCertificate').and.throwError( + new Error('Invalid publicKeyUrl') + ); + + await expectAsync( + adapter.getAppleCertificate(invalidPublicKeyUrl) + ).toBeRejectedWithError('Invalid publicKeyUrl: https://malicious.url.com/key.cer'); + }); + + it('should throw error for invalid signature verification', async function () { + const fakePublicKey = 'invalid-key'; + const fakeAuthData = { + id: '1234567', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1460981421303, + salt: 'saltST==', + signature: 'invalidSignature', + }; + + spyOn(adapter, 'getAppleCertificate').and.returnValue(Promise.resolve(fakePublicKey)); + spyOn(adapter, 'verifySignature').and.throwError('Invalid signature.'); + + await expectAsync(adapter.validateAuthData(fakeAuthData)).toBeRejectedWithError( + 'Invalid signature.' + ); + }); + }); + + describe('Test payload passing', function () { + it('should successfully process valid payload and save auth data', async function () { + const validAuthData = { + id: '1234567', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1460981421303, + salt: 'saltST==', + signature: 'validSignature', + bundleId: 'com.valid.app', + }; + + spyOn(adapter, 'getAppleCertificate').and.returnValue(Promise.resolve('validKey')); + spyOn(adapter, 'verifySignature').and.returnValue(true); + + await expectAsync(adapter.validateAuthData(validAuthData)).toBeResolved(); + }); + }); + + describe('Certificate and Signature Validation', function () { + it('should fetch and validate Apple certificate', async function () { + const certUrl = 'https://static.gc.apple.com/public-key/gc-prod-4.cer'; + const mockCertificate = 'mockCertificate'; + + spyOn(adapter, 'fetchCertificate').and.returnValue( + Promise.resolve({ certificate: mockCertificate, headers: new Map() }) + ); + spyOn(pki, 'certificateFromPem').and.returnValue({}); + + adapter.cache[certUrl] = mockCertificate; + + const cert = await adapter.getAppleCertificate(certUrl); + expect(cert).toBe(mockCertificate); + }); + + it('should verify signature successfully', async function () { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + }; + + adapter.bundleId = 'cloud.xtralife.gamecenterauth'; + adapter.enableInsecureAuth = false; + + spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue(); + + const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl); + + expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow(); + + }); + + it('should not use bundle id from authData payload in secure mode', async function () { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + bundleId: 'com.example.insecure.app', + }; + + adapter.bundleId = 'cloud.xtralife.gamecenterauth'; + adapter.enableInsecureAuth = false; + + spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue(); + + const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl); + + expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow(); + + }); + + it('should not use bundle id from authData payload in insecure mode', async function () { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + bundleId: 'com.example.insecure.app', + }; + + adapter.bundleId = 'cloud.xtralife.gamecenterauth'; + adapter.enableInsecureAuth = true; + + spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue(); + + const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl); + + expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow(); + + }); + + it('can use bundle id from authData payload in insecure mode', async function () { + const authData = { + id: 'G:1965586982', + publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', + timestamp: 1565257031287, + signature: + 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', + salt: 'DzqqrQ==', + bundleId: 'cloud.xtralife.gamecenterauth', + }; + + adapter.enableInsecureAuth = true; + + spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue(); + + const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl); + + expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow(); + + }); + }); +}); diff --git a/spec/Adapters/Auth/github.spec.js b/spec/Adapters/Auth/github.spec.js new file mode 100644 index 0000000000..c12d002ed9 --- /dev/null +++ b/spec/Adapters/Auth/github.spec.js @@ -0,0 +1,285 @@ +const GitHubAdapter = require('../../../lib/Adapters/Auth/github').default; + +describe('GitHubAdapter', function () { + let adapter; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }; + + beforeEach(function () { + adapter = new GitHubAdapter.constructor(); + adapter.validateOptions(validOptions); + }); + + describe('getAccessTokenFromCode', function () { + it('should fetch an access token successfully', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken', + }), + }, + }, + ]); + + const code = 'validCode'; + const token = await adapter.getAccessTokenFromCode(code); + + expect(token).toBe('mockAccessToken'); + }); + + it('should throw an error if the response is not ok', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + const code = 'invalidCode'; + + await expectAsync(adapter.getAccessTokenFromCode(code)).toBeRejectedWithError( + 'Failed to exchange code for token: Bad Request' + ); + }); + + it('should throw an error if the response contains an error', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Code is invalid', + }), + }, + }, + ]); + + const code = 'invalidCode'; + + await expectAsync(adapter.getAccessTokenFromCode(code)).toBeRejectedWithError('Code is invalid'); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should fetch user data successfully', async function () { + mockFetch([ + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + login: 'mockUserLogin', + }), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const user = await adapter.getUserFromAccessToken(accessToken); + + expect(user).toEqual({ id: 'mockUserId', login: 'mockUserLogin' }); + }); + + it('should throw an error if the response is not ok', async function () { + mockFetch([ + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const accessToken = 'invalidAccessToken'; + + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( + 'Failed to fetch GitHub user: Unauthorized' + ); + }); + + it('should throw an error if user data is invalid', async function () { + mockFetch([ + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({}), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( + 'Invalid GitHub user data received.' + ); + }); + }); + + describe('GitHubAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + github: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + }, + }); + }); + + it('should log in user using GitHub adapter successfully', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + login: 'mockUserLogin', + }), + }, + }, + ]); + + const authData = { code: 'validCode' }; + const user = await Parse.User.logInWith('github', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle error when GitHub returns invalid code', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { code: 'invalidCode' }; + + await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError( + 'Failed to exchange code for token: Invalid code' + ); + }); + + it('should handle error when GitHub returns invalid user data', async function () { + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { code: 'validCode' }; + + await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError( + 'Failed to fetch GitHub user: Unauthorized' + ); + }); + + it('e2e secure does not support insecure payload', async function () { + mockFetch(); + const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' }; + await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError( + 'GitHub code is required.' + ); + }); + + it('e2e insecure does support secure payload', async function () { + await reconfigureServer({ + auth: { + github: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: true, + }, + }, + }); + + mockFetch([ + { + url: 'https://github.com/login/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.github.com/user', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + login: 'mockUserLogin', + }), + }, + }, + ]); + + const authData = { code: 'validCode' }; + const user = await Parse.User.logInWith('github', { authData }); + + expect(user.id).toBeDefined(); + }); + }); +}); diff --git a/spec/Adapters/Auth/gpgames.spec.js b/spec/Adapters/Auth/gpgames.spec.js new file mode 100644 index 0000000000..8f3a71e46c --- /dev/null +++ b/spec/Adapters/Auth/gpgames.spec.js @@ -0,0 +1,356 @@ +const GooglePlayGamesServicesAdapter = require('../../../lib/Adapters/Auth/gpgames').default; + +describe('GooglePlayGamesServicesAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new GooglePlayGamesServicesAdapter.constructor(); + adapter.clientId = 'validClientId'; + adapter.clientSecret = 'validClientSecret'; + }); + + describe('getAccessTokenFromCode', function () { + it('should fetch an access token successfully', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken', + }), + }, + }, + ]); + + const code = 'validCode'; + const authData = { redirectUri: 'http://example.com' }; + const token = await adapter.getAccessTokenFromCode(code, authData); + + expect(token).toBe('mockAccessToken'); + }); + + it('should throw an error if the response is not ok', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + const code = 'invalidCode'; + const authData = { redirectUri: 'http://example.com' }; + + await expectAsync(adapter.getAccessTokenFromCode(code, authData)).toBeRejectedWithError( + 'Failed to exchange code for token: Bad Request' + ); + }); + + it('should throw an error if the response contains an error', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Code is invalid', + }), + }, + }, + ]); + + const code = 'invalidCode'; + const authData = { redirectUri: 'http://example.com' }; + + await expectAsync(adapter.getAccessTokenFromCode(code, authData)).toBeRejectedWithError( + 'Code is invalid' + ); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should fetch user data successfully', async function () { + mockFetch([ + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + playerId: 'mockUserId', + }), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const authData = { id: 'mockUserId' }; + const user = await adapter.getUserFromAccessToken(accessToken, authData); + + expect(user).toEqual({ id: 'mockUserId' }); + }); + + it('should throw an error if the response is not ok', async function () { + mockFetch([ + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const accessToken = 'invalidAccessToken'; + const authData = { id: 'mockUserId' }; + + await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError( + 'Failed to fetch Google Play Games Services user: Unauthorized' + ); + }); + + it('should throw an error if user data is invalid', async function () { + mockFetch([ + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({}), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const authData = { id: 'mockUserId' }; + + await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError( + 'Invalid Google Play Games Services user data received.' + ); + }); + + it('should throw an error if playerId does not match the provided user ID', async function () { + mockFetch([ + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + playerId: 'anotherUserId', + }), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const authData = { id: 'mockUserId' }; + + await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError( + 'Invalid Google Play Games Services user data received.' + ); + }); + }); + + describe('GooglePlayGamesServicesAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + }, + }); + }); + + it('should log in user successfully with valid code', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + playerId: 'mockUserId', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + redirectUri: 'http://example.com', + }; + + const user = await Parse.User.logInWith('gpgames', { authData }); + + expect(user.id).toBeDefined(); + expect(global.fetch).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.googleapis.com/games/v1/players/mockUserId', + jasmine.any(Object) + ); + }); + + it('should handle error when the token exchange fails', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirectUri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError( + 'Failed to exchange code for token: Invalid code' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + jasmine.any(Object) + ); + }); + + it('should handle error when user data fetch fails', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + redirectUri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError( + 'Failed to fetch Google Play Games Services user: Unauthorized' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.googleapis.com/games/v1/players/mockUserId', + jasmine.any(Object) + ); + }); + + it('should handle error when user data is invalid', async function () { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://www.googleapis.com/games/v1/players/mockUserId', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + playerId: 'anotherUserId', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + redirectUri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError( + 'Invalid Google Play Games Services user data received.' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.googleapis.com/games/v1/players/mockUserId', + jasmine.any(Object) + ); + }); + + it('should handle error when no code or access token is provided', async function () { + mockFetch(); + + const authData = { + id: 'mockUserId', + }; + + await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError( + 'gpgames code is required.' + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); + +}); + diff --git a/spec/Adapters/Auth/instagram.spec.js b/spec/Adapters/Auth/instagram.spec.js new file mode 100644 index 0000000000..441ef2b176 --- /dev/null +++ b/spec/Adapters/Auth/instagram.spec.js @@ -0,0 +1,258 @@ +const InstagramAdapter = require('../../../lib/Adapters/Auth/instagram').default; + +describe('InstagramAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new InstagramAdapter.constructor(); + adapter.clientId = 'validClientId'; + adapter.clientSecret = 'validClientSecret'; + adapter.redirectUri = 'https://example.com/callback'; + }); + + describe('getAccessTokenFromCode', function () { + it('should fetch an access token successfully', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken', + }), + }, + }, + ]); + + const authData = { code: 'validCode' }; + const token = await adapter.getAccessTokenFromCode(authData); + + expect(token).toBe('mockAccessToken'); + }); + + it('should throw an error if the response contains an error', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Code is invalid', + }), + }, + }, + ]); + + const authData = { code: 'invalidCode' }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Code is invalid' + ); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should fetch user data successfully', async function () { + mockFetch([ + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const accessToken = 'mockAccessToken'; + const authData = { id: 'mockUserId' }; + const user = await adapter.getUserFromAccessToken(accessToken, authData); + + expect(user).toEqual({ id: 'mockUserId' }); + }); + + it('should throw an error if user ID does not match authData', async function () { + mockFetch([ + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'differentUserId', + }), + }, + }, + ]); + + const accessToken = 'mockAccessToken'; + const authData = { id: 'mockUserId' }; + + await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError( + 'Instagram auth is invalid for this user.' + ); + }); + }); + + describe('InstagramAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + instagram: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + redirectUri: 'https://example.com/callback', + }, + }, + }); + }); + + it('should log in user successfully with valid code', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + }; + + const user = await Parse.User.logInWith('instagram', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle error when access token exchange fails', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + }; + + await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.') + ); + }); + + it('should handle error when user data fetch fails', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + }; + + await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.') + ); + }); + + it('should handle error when user data is invalid', async function () { + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'differentUserId', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + id: 'mockUserId', + }; + + await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWithError( + 'Instagram auth is invalid for this user.' + ); + }); + + it('should handle error when no code or access token is provided', async function () { + mockFetch(); + + const authData = { + id: 'mockUserId', + }; + + await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWithError( + 'Instagram code is required.' + ); + }); + }); + +}); diff --git a/spec/Adapters/Auth/line.spec.js b/spec/Adapters/Auth/line.spec.js new file mode 100644 index 0000000000..bde4c906b8 --- /dev/null +++ b/spec/Adapters/Auth/line.spec.js @@ -0,0 +1,309 @@ +const LineAdapter = require('../../../lib/Adapters/Auth/line').default; +describe('LineAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new LineAdapter.constructor(); + adapter.clientId = 'validClientId'; + adapter.clientSecret = 'validClientSecret'; + }); + + describe('getAccessTokenFromCode', function () { + it('should throw an error if code is missing in authData', async function () { + const authData = { redirect_uri: 'http://example.com' }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Line auth is invalid for this user.' + ); + }); + + it('should fetch an access token successfully', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'http://example.com', + }; + + const token = await adapter.getAccessTokenFromCode(authData); + + expect(token).toBe('mockAccessToken'); + }); + + it('should throw an error if response is not ok', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Failed to exchange code for token: Bad Request' + ); + }); + + it('should throw an error if response contains an error object', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + error: 'invalid_grant', + error_description: 'Code is invalid', + }), + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Code is invalid' + ); + }); + }); + + describe('getUserFromAccessToken', function () { + it('should fetch user data successfully', async function () { + mockFetch([ + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + userId: 'mockUserId', + displayName: 'mockDisplayName', + }), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + const user = await adapter.getUserFromAccessToken(accessToken); + + expect(user).toEqual({ + userId: 'mockUserId', + displayName: 'mockDisplayName', + }); + }); + + it('should throw an error if response is not ok', async function () { + mockFetch([ + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const accessToken = 'invalidAccessToken'; + + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( + 'Failed to fetch Line user: Unauthorized' + ); + }); + + it('should throw an error if user data is invalid', async function () { + mockFetch([ + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({}), + }, + }, + ]); + + const accessToken = 'validAccessToken'; + + await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError( + 'Invalid Line user data received.' + ); + }); + }); + + describe('LineAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + line: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + }, + }); + }); + + it('should log in user successfully with valid code', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + userId: 'mockUserId', + displayName: 'mockDisplayName', + }), + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'http://example.com', + }; + + const user = await Parse.User.logInWith('line', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle error when token exchange fails', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError( + 'Failed to exchange code for token: Invalid code' + ); + }); + + it('should handle error when user data fetch fails', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError( + 'Failed to fetch Line user: Unauthorized' + ); + }); + + it('should handle error when user data is invalid', async function () { + mockFetch([ + { + url: 'https://api.line.me/oauth2/v2.1/token', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.line.me/v2/profile', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({}), + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError( + 'Invalid Line user data received.' + ); + }); + + it('should handle error when no code is provided', async function () { + mockFetch(); + + const authData = { + redirect_uri: 'http://example.com', + }; + + await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError( + 'Line code is required.' + ); + }); + }); + +}); diff --git a/spec/Adapters/Auth/linkedIn.spec.js b/spec/Adapters/Auth/linkedIn.spec.js new file mode 100644 index 0000000000..f6c84a79af --- /dev/null +++ b/spec/Adapters/Auth/linkedIn.spec.js @@ -0,0 +1,312 @@ + +const LinkedInAdapter = require('../../../lib/Adapters/Auth/linkedin').default; +describe('LinkedInAdapter', function () { + let adapter; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: false, + }; + + beforeEach(function () { + adapter = new LinkedInAdapter.constructor(); + }); + + describe('Test configuration errors', function () { + it('should throw error for missing options', function () { + const invalidOptions = [null, undefined, {}, { clientId: 'validClientId' }]; + + for (const options of invalidOptions) { + expect(() => { + adapter.validateOptions(options); + }).toThrow(); + } + }); + + it('should validate options successfully with valid parameters', function () { + expect(() => { + adapter.validateOptions(validOptions); + }).not.toThrow(); + expect(adapter.clientId).toBe(validOptions.clientId); + expect(adapter.clientSecret).toBe(validOptions.clientSecret); + expect(adapter.enableInsecureAuth).toBe(validOptions.enableInsecureAuth); + }); + }); + + describe('Test beforeFind', function () { + it('should throw error for invalid payload', async function () { + adapter.enableInsecureAuth = true; + + const payloads = [{}, { access_token: null }]; + + for (const payload of payloads) { + await expectAsync(adapter.beforeFind(payload)).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn auth is invalid for this user.') + ); + } + }); + + it('should process secure payload and set auth data', async function () { + spyOn(adapter, 'getAccessTokenFromCode').and.returnValue( + Promise.resolve('validToken') + ); + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'validUserId' }) + ); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com', is_mobile_sdk: false }; + + await adapter.beforeFind(authData); + + expect(authData.access_token).toBe('validToken'); + expect(authData.id).toBe('validUserId'); + }); + + it('should validate insecure auth and match user id', async function () { + adapter.enableInsecureAuth = true; + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'validUserId' }) + ); + + const authData = { access_token: 'validToken', id: 'validUserId', is_mobile_sdk: false }; + + await expectAsync(adapter.beforeFind(authData)).toBeResolved(); + }); + + it('should throw error if insecure auth user id does not match', async function () { + adapter.enableInsecureAuth = true; + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'invalidUserId' }) + ); + + const authData = { access_token: 'validToken', id: 'validUserId', is_mobile_sdk: false }; + + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith( + new Error('LinkedIn auth is invalid for this user.') + ); + }); + }); + + describe('Test getUserFromAccessToken', function () { + it('should fetch user successfully', async function () { + global.fetch = jasmine.createSpy().and.returnValue( + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ id: 'validUserId' }), + }) + ); + + const user = await adapter.getUserFromAccessToken('validToken', false); + + expect(global.fetch).toHaveBeenCalledWith('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer validToken`, + 'x-li-format': 'json', + 'x-li-src': undefined, + }, + }); + expect(user).toEqual({ id: 'validUserId' }); + }); + + it('should throw error for invalid response', async function () { + global.fetch = jasmine.createSpy().and.returnValue( + Promise.resolve({ ok: false }) + ); + + await expectAsync(adapter.getUserFromAccessToken('invalidToken', false)).toBeRejectedWith( + new Error('LinkedIn API request failed.') + ); + }); + }); + + describe('Test getAccessTokenFromCode', function () { + it('should fetch token successfully', async function () { + global.fetch = jasmine.createSpy().and.returnValue( + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ access_token: 'validToken' }), + }) + ); + + const tokenResponse = await adapter.getAccessTokenFromCode('validCode', 'http://example.com'); + + expect(global.fetch).toHaveBeenCalledWith('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: jasmine.any(URLSearchParams), + }); + expect(tokenResponse).toEqual('validToken'); + }); + + it('should throw error for invalid response', async function () { + global.fetch = jasmine.createSpy().and.returnValue( + Promise.resolve({ ok: false }) + ); + + await expectAsync( + adapter.getAccessTokenFromCode('invalidCode', 'http://example.com') + ).toBeRejectedWith(new Error('LinkedIn API request failed.')); + }); + }); + + describe('Test validate methods', function () { + const authData = { id: 'validUserId', access_token: 'validToken' }; + + it('validateLogin should return user id', function () { + const result = adapter.validateLogin(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + + it('validateSetUp should return user id', function () { + const result = adapter.validateSetUp(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + + it('validateUpdate should return user id', function () { + const result = adapter.validateUpdate(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + + it('afterFind should return user id', function () { + const result = adapter.afterFind(authData); + expect(result).toEqual({ id: 'validUserId' }); + }); + }); + + describe('LinkedInAdapter E2E Test', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + linkedin: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + }, + }); + }); + + it('should log in user using LinkedIn adapter successfully (secure)', async function () { + mockFetch([ + { + url: 'https://www.linkedin.com/oauth/v2/accessToken', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://example.com/callback' }; + const user = await Parse.User.logInWith('linkedin', { authData }); + + expect(user.id).toBeDefined(); + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.linkedin.com/oauth/v2/accessToken', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.linkedin.com/v2/me', + jasmine.any(Object) + ); + }); + + it('should handle error when LinkedIn returns invalid user data', async function () { + mockFetch([ + { + url: 'https://www.linkedin.com/oauth/v2/accessToken', + method: 'POST', + response: { + ok: true, + json: () => + Promise.resolve({ + access_token: 'mockAccessToken123', + }), + }, + }, + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('linkedin', { authData })).toBeRejectedWithError( + 'LinkedIn API request failed.' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://www.linkedin.com/oauth/v2/accessToken', + jasmine.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.linkedin.com/v2/me', + jasmine.any(Object) + ); + }); + + it('secure does not support insecure payload if not enabled', async function () { + mockFetch(); + const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' }; + await expectAsync(Parse.User.logInWith('linkedin', { authData })).toBeRejectedWithError( + 'LinkedIn code is required.' + ); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('insecure mode supports insecure payload if enabled', async function () { + await reconfigureServer({ + auth: { + linkedin: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: true, + }, + }, + }); + + mockFetch([ + { + url: 'https://api.linkedin.com/v2/me', + method: 'GET', + response: { + ok: true, + json: () => + Promise.resolve({ + id: 'mockUserId', + }), + }, + }, + ]); + + const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' }; + const user = await Parse.User.logInWith('linkedin', { authData }); + + expect(user.id).toBeDefined(); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.linkedin.com/v2/me', + jasmine.any(Object) + ); + }); + }); +}); diff --git a/spec/Adapters/Auth/microsoft.spec.js b/spec/Adapters/Auth/microsoft.spec.js new file mode 100644 index 0000000000..c5cf58b807 --- /dev/null +++ b/spec/Adapters/Auth/microsoft.spec.js @@ -0,0 +1,307 @@ +const MicrosoftAdapter = require('../../../lib/Adapters/Auth/microsoft').default; + +describe('MicrosoftAdapter', function () { + let adapter; + const validOptions = { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: false, + }; + + beforeEach(function () { + adapter = new MicrosoftAdapter.constructor(); + }); + + describe('Test configuration errors', function () { + it('should throw error for missing options', function () { + const invalidOptions = [null, undefined, {}, { clientId: 'validClientId' }]; + + for (const options of invalidOptions) { + expect(() => { + adapter.validateOptions(options); + }).toThrow(); + } + }); + + it('should validate options successfully with valid parameters', function () { + expect(() => { + adapter.validateOptions(validOptions); + }).not.toThrow(); + expect(adapter.clientId).toBe(validOptions.clientId); + expect(adapter.clientSecret).toBe(validOptions.clientSecret); + expect(adapter.enableInsecureAuth).toBe(validOptions.enableInsecureAuth); + }); + }); + + describe('Test getUserFromAccessToken', function () { + it('should fetch user successfully', async function () { + mockFetch([ + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ id: 'validUserId' }), + }, + }, + ]); + + const user = await adapter.getUserFromAccessToken('validToken'); + + expect(global.fetch).toHaveBeenCalledWith('https://graph.microsoft.com/v1.0/me', { + headers: { + Authorization: 'Bearer validToken', + }, + method: 'GET', + }); + expect(user).toEqual({ id: 'validUserId' }); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { ok: false }, + }, + ]); + + await expectAsync(adapter.getUserFromAccessToken('invalidToken')).toBeRejectedWith( + new Error('Microsoft API request failed.') + ); + }); + }); + + describe('Test getAccessTokenFromCode', function () { + it('should fetch token successfully', async function () { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validToken' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com' }; + const token = await adapter.getAccessTokenFromCode(authData); + + expect(global.fetch).toHaveBeenCalledWith('https://login.microsoftonline.com/common/oauth2/v2.0/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: jasmine.any(URLSearchParams), + }); + expect(token).toEqual('validToken'); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { ok: false }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com' }; + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith( + new Error('Microsoft API request failed.') + ); + }); + }); + + describe('Test secure authentication flow', function () { + it('should exchange code for access token and fetch user data', async function () { + spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(Promise.resolve('validToken')); + spyOn(adapter, 'getUserFromAccessToken').and.returnValue(Promise.resolve({ id: 'validUserId' })); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com' }; + await adapter.beforeFind(authData); + + expect(authData.access_token).toBe('validToken'); + expect(authData.id).toBe('validUserId'); + }); + + it('should throw error if user data cannot be fetched', async function () { + spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(Promise.resolve('validToken')); + spyOn(adapter, 'getUserFromAccessToken').and.throwError('Microsoft API request failed.'); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com' }; + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith( + new Error('Microsoft API request failed.') + ); + }); + }); + + describe('Test insecure authentication flow', function () { + beforeEach(function () { + adapter.enableInsecureAuth = true; + }); + + it('should validate insecure auth and match user id', async function () { + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'validUserId' }) + ); + + const authData = { access_token: 'validToken', id: 'validUserId' }; + await expectAsync(adapter.beforeFind(authData)).toBeResolved(); + }); + + it('should throw error if insecure auth user id does not match', async function () { + spyOn(adapter, 'getUserFromAccessToken').and.returnValue( + Promise.resolve({ id: 'invalidUserId' }) + ); + + const authData = { access_token: 'validToken', id: 'validUserId' }; + await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith( + new Error('Microsoft auth is invalid for this user.') + ); + }); + }); + + describe('MicrosoftAdapter E2E Tests', () => { + beforeEach(async () => { + // Simulate reconfiguring the server with Microsoft auth options + await reconfigureServer({ + auth: { + microsoft: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: false, + }, + }, + }); + }); + + it('should authenticate user successfully using MicrosoftAdapter', async () => { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken' }), + }, + }, + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ id: 'user123' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + const user = await Parse.User.logInWith('microsoft', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle invalid code error gracefully', async () => { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { ok: false, statusText: 'Invalid code' }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError( + 'Microsoft API request failed.' + ); + }); + + it('should handle error when fetching user data fails', async () => { + mockFetch([ + { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken' }), + }, + }, + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { ok: false, statusText: 'Unauthorized' }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError( + 'Microsoft API request failed.' + ); + }); + + it('should allow insecure auth when enabled', async () => { + + mockFetch([ + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ + id: 'user123', + }), + }, + }, + ]) + + await reconfigureServer({ + auth: { + microsoft: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: true, + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + const user = await Parse.User.logInWith('microsoft', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should reject insecure auth when user id does not match', async () => { + + mockFetch([ + { + url: 'https://graph.microsoft.com/v1.0/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ + id: 'incorrectUser', + }), + }, + }, + ]) + + await reconfigureServer({ + auth: { + microsoft: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + enableInsecureAuth: true, + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'incorrectUserId' }; + await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError( + 'Microsoft auth is invalid for this user.' + ); + }); + }); + +}); diff --git a/spec/Adapters/Auth/oauth2.spec.js b/spec/Adapters/Auth/oauth2.spec.js new file mode 100644 index 0000000000..4dff1219ee --- /dev/null +++ b/spec/Adapters/Auth/oauth2.spec.js @@ -0,0 +1,305 @@ +const OAuth2Adapter = require('../../../lib/Adapters/Auth/oauth2').default; + +describe('OAuth2Adapter', () => { + let adapter; + + const validOptions = { + tokenIntrospectionEndpointUrl: 'https://provider.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['valid-app-id'], + authorizationHeader: 'Bearer validAuthToken', + }; + + beforeEach(() => { + adapter = new OAuth2Adapter.constructor(); + adapter.validateOptions(validOptions); + }); + + describe('validateAppId', () => { + it('should validate app ID successfully', async () => { + const authData = { access_token: 'validAccessToken' }; + const mockResponse = { + [validOptions.appidField]: 'valid-app-id', + }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAppId(validOptions.appIds, authData, validOptions) + ).toBeResolved(); + }); + + it('should throw an error if app ID is invalid', async () => { + const authData = { access_token: 'validAccessToken' }; + const mockResponse = { + [validOptions.appidField]: 'invalid-app-id', + }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAppId(validOptions.appIds, authData, validOptions) + ).toBeRejectedWithError('OAuth2: Invalid app ID.'); + }); + }); + + describe('validateAuthData', () => { + it('should validate auth data successfully', async () => { + const authData = { id: 'user-id', access_token: 'validAccessToken' }; + const mockResponse = { + active: true, + [validOptions.useridField]: 'user-id', + }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAuthData(authData, null, validOptions) + ).toBeResolvedTo({}); + }); + + it('should throw an error if the token is inactive', async () => { + const authData = { id: 'user-id', access_token: 'validAccessToken' }; + const mockResponse = { active: false }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAuthData(authData, null, validOptions) + ).toBeRejectedWith(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')); + }); + + it('should throw an error if user ID does not match', async () => { + const authData = { id: 'user-id', access_token: 'validAccessToken' }; + const mockResponse = { + active: true, + [validOptions.useridField]: 'different-user-id', + }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + await expectAsync( + adapter.validateAuthData(authData, null, validOptions) + ).toBeRejectedWithError('OAuth2 access token is invalid for this user.'); + }); + }); + + describe('requestTokenInfo', () => { + it('should fetch token info successfully', async () => { + const mockResponse = { active: true }; + + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const result = await adapter.requestTokenInfo( + 'validAccessToken', + validOptions + ); + + expect(result).toEqual(mockResponse); + }); + + it('should throw an error if the introspection endpoint URL is missing', async () => { + const options = { ...validOptions, tokenIntrospectionEndpointUrl: null }; + + expect( + () => adapter.validateOptions(options) + ).toThrow(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.')); + }); + + it('should throw an error if the response is not ok', async () => { + mockFetch([ + { + url: validOptions.tokenIntrospectionEndpointUrl, + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + await expectAsync( + adapter.requestTokenInfo('invalidAccessToken') + ).toBeRejectedWithError('OAuth2 token introspection request failed.'); + }); + }); + + describe('OAuth2Adapter E2E Tests', () => { + beforeEach(async () => { + // Simulate reconfiguring the server with OAuth2 auth options + await reconfigureServer({ + auth: { + mockOauth: { + tokenIntrospectionEndpointUrl: 'https://provider.com/introspect', + useridField: 'sub', + appidField: 'aud', + appIds: ['valid-app-id'], + authorizationHeader: 'Bearer validAuthToken', + oauth2: true + }, + }, + }); + }); + + it('should validate and authenticate user successfully', async () => { + mockFetch([ + { + url: 'https://provider.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'user123', + aud: 'valid-app-id', + }), + }, + }, + ]); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + const user = await Parse.User.logInWith('mockOauth', { authData }); + + expect(user.id).toBeDefined(); + expect(user.get('authData').mockOauth.id).toEqual('user123'); + }); + + it('should reject authentication for inactive token', async () => { + mockFetch([ + { + url: 'https://provider.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ active: false, aud: ['valid-app-id'] }), + }, + }, + ]); + + const authData = { access_token: 'inactiveToken', id: 'user123' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.') + ); + }); + + it('should reject authentication for mismatched user ID', async () => { + mockFetch([ + { + url: 'https://provider.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'different-user', + aud: 'valid-app-id', + }), + }, + }, + ]); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.') + ); + }); + + it('should reject authentication for invalid app ID', async () => { + mockFetch([ + { + url: 'https://provider.com/introspect', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ + active: true, + sub: 'user123', + aud: 'invalid-app-id', + }), + }, + }, + ]); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWithError( + 'OAuth2: Invalid app ID.' + ); + }); + + it('should handle error when token introspection endpoint is missing', async () => { + await reconfigureServer({ + auth: { + mockOauth: { + tokenIntrospectionEndpointUrl: null, + useridField: 'sub', + appidField: 'aud', + appIds: ['valid-app-id'], + authorizationHeader: 'Bearer validAuthToken', + oauth2: true + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith( + new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.') + ); + }); + }); + +}); diff --git a/spec/Adapters/Auth/qq.spec.js b/spec/Adapters/Auth/qq.spec.js new file mode 100644 index 0000000000..1e67e18941 --- /dev/null +++ b/spec/Adapters/Auth/qq.spec.js @@ -0,0 +1,252 @@ +const QqAdapter = require('../../../lib/Adapters/Auth/qq').default; + +describe('QqAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new QqAdapter.constructor(); + }); + + describe('getUserFromAccessToken', () => { + it('should fetch user data successfully', async () => { + const mockResponse = `callback({"client_id":"validAppId","openid":"user123"})`; + + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: true, + text: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const result = await adapter.getUserFromAccessToken('validAccessToken'); + + expect(result).toEqual({ client_id: 'validAppId', openid: 'user123' }); + }); + + it('should throw an error if the API request fails', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + await expectAsync( + adapter.getUserFromAccessToken('invalidAccessToken') + ).toBeRejectedWithError('qq API request failed.'); + }); + }); + + describe('getAccessTokenFromCode', () => { + it('should fetch access token successfully', async () => { + const mockResponse = `callback({"access_token":"validAccessToken","expires_in":3600,"refresh_token":"refreshToken"})`; + + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: true, + text: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const result = await adapter.getAccessTokenFromCode({ + code: 'validCode', + redirect_uri: 'https://your-redirect-uri.com/callback', + }); + + expect(result).toBe('validAccessToken'); + }); + + it('should throw an error if the API request fails', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + await expectAsync( + adapter.getAccessTokenFromCode({ + code: 'invalidCode', + redirect_uri: 'https://your-redirect-uri.com/callback', + }) + ).toBeRejectedWithError('qq API request failed.'); + }); + }); + + describe('parseResponseData', () => { + it('should parse valid callback response data', () => { + const response = `callback({"key":"value"})`; + const result = adapter.parseResponseData(response); + + expect(result).toEqual({ key: 'value' }); + }); + + it('should throw an error if the response data is invalid', () => { + const response = 'invalid response'; + + expect(() => adapter.parseResponseData(response)).toThrowError( + 'qq auth is invalid for this user.' + ); + }); + }); + + describe('QqAdapter E2E Test', () => { + beforeEach(async () => { + await reconfigureServer({ + auth: { + qq: { + clientId: 'validAppId', + clientSecret: 'validAppSecret', + }, + }, + }); + }); + + it('should log in user using Qq adapter successfully', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"access_token":"mockAccessToken","expires_in":3600})` + ), + }, + }, + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"client_id":"validAppId","openid":"user123"})` + ), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' }; + const user = await Parse.User.logInWith('qq', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle error when Qq returns invalid code', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: false, + statusText: 'Invalid code', + }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'https://your-redirect-uri.com/callback' }; + + await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError( + 'qq API request failed.' + ); + }); + + it('should handle error when Qq returns invalid user data', async () => { + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"access_token":"mockAccessToken","expires_in":3600})` + ), + }, + }, + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' }; + + await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError( + 'qq API request failed.' + ); + }); + + it('e2e secure does not support insecure payload', async () => { + mockFetch(); + const authData = { id: 'mockUserId', access_token: 'mockAccessToken' }; + await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError( + 'qq code is required.' + ); + }); + + it('e2e insecure does support secure payload', async () => { + await reconfigureServer({ + auth: { + qq: { + appId: 'validAppId', + appSecret: 'validAppSecret', + enableInsecureAuth: true, + }, + }, + }); + + mockFetch([ + { + url: 'https://graph.qq.com/oauth2.0/token', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"access_token":"mockAccessToken","expires_in":3600})` + ), + }, + }, + { + url: 'https://graph.qq.com/oauth2.0/me', + method: 'GET', + response: { + ok: true, + text: () => + Promise.resolve( + `callback({"client_id":"validAppId","openid":"user123"})` + ), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' }; + const user = await Parse.User.logInWith('qq', { authData }); + + expect(user.id).toBeDefined(); + }); + }); +}); diff --git a/spec/Adapters/Auth/spotify.spec.js b/spec/Adapters/Auth/spotify.spec.js new file mode 100644 index 0000000000..b3c6a5ef6f --- /dev/null +++ b/spec/Adapters/Auth/spotify.spec.js @@ -0,0 +1,113 @@ +const SpotifyAdapter = require('../../../lib/Adapters/Auth/spotify').default; + +describe('SpotifyAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new SpotifyAdapter.constructor(); + }); + + describe('getUserFromAccessToken', () => { + it('should fetch user data successfully', async () => { + const mockResponse = { + id: 'spotifyUser123', + }; + + mockFetch([ + { + url: 'https://api.spotify.com/v1/me', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const result = await adapter.getUserFromAccessToken('validAccessToken'); + + expect(result).toEqual({ id: 'spotifyUser123' }); + }); + + it('should throw an error if the API request fails', async () => { + mockFetch([ + { + url: 'https://api.spotify.com/v1/me', + method: 'GET', + response: { + ok: false, + statusText: 'Unauthorized', + }, + }, + ]); + + await expectAsync(adapter.getUserFromAccessToken('invalidAccessToken')).toBeRejectedWithError( + 'Spotify API request failed.' + ); + }); + }); + + describe('getAccessTokenFromCode', () => { + it('should fetch access token successfully', async () => { + const mockResponse = { + access_token: 'validAccessToken', + expires_in: 3600, + refresh_token: 'refreshToken', + }; + + mockFetch([ + { + url: 'https://accounts.spotify.com/api/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve(mockResponse), + }, + }, + ]); + + const authData = { + code: 'validCode', + redirect_uri: 'https://your-redirect-uri.com/callback', + code_verifier: 'validCodeVerifier', + }; + + const result = await adapter.getAccessTokenFromCode(authData); + + expect(result).toEqual(mockResponse); + }); + + it('should throw an error if authData is missing required fields', async () => { + const authData = { + redirect_uri: 'https://your-redirect-uri.com/callback', + }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Spotify auth configuration authData.code and/or authData.redirect_uri and/or authData.code_verifier.' + ); + }); + + it('should throw an error if the API request fails', async () => { + mockFetch([ + { + url: 'https://accounts.spotify.com/api/token', + method: 'POST', + response: { + ok: false, + statusText: 'Bad Request', + }, + }, + ]); + + const authData = { + code: 'invalidCode', + redirect_uri: 'https://your-redirect-uri.com/callback', + code_verifier: 'invalidCodeVerifier', + }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError( + 'Spotify API request failed.' + ); + }); + }); +}); diff --git a/spec/Adapters/Auth/twitter.spec.js b/spec/Adapters/Auth/twitter.spec.js new file mode 100644 index 0000000000..2869ff4121 --- /dev/null +++ b/spec/Adapters/Auth/twitter.spec.js @@ -0,0 +1,120 @@ +const TwitterAuthAdapter = require('../../../lib/Adapters/Auth/twitter').default; + +describe('TwitterAuthAdapter', function () { + let adapter; + const validOptions = { + consumer_key: 'validConsumerKey', + consumer_secret: 'validConsumerSecret', + }; + + beforeEach(function () { + adapter = new TwitterAuthAdapter.constructor(); + }); + + describe('Test configuration errors', function () { + it('should throw an error when options are missing', function () { + expect(() => adapter.validateOptions()).toThrowError('Twitter auth options are required.'); + }); + + it('should throw an error when consumer_key and consumer_secret are missing for secure auth', function () { + const options = { enableInsecureAuth: false }; + expect(() => adapter.validateOptions(options)).toThrowError( + 'Consumer key and secret are required for secure Twitter auth.' + ); + }); + + it('should not throw an error when valid options are provided', function () { + expect(() => adapter.validateOptions(validOptions)).not.toThrow(); + }); + }); + + describe('Validate Insecure Auth', function () { + it('should throw an error if oauth_token or oauth_token_secret are missing', async function () { + const authData = { oauth_token: 'validToken' }; // Missing oauth_token_secret + await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeRejectedWithError( + 'Twitter insecure auth requires oauth_token and oauth_token_secret.' + ); + }); + + it('should validate insecure auth successfully when data matches', async function () { + spyOn(adapter, 'request').and.returnValue( + Promise.resolve({ + json: () => Promise.resolve({ id: 'validUserId' }), + }) + ); + + const authData = { + id: 'validUserId', + oauth_token: 'validToken', + oauth_token_secret: 'validSecret', + }; + await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeResolved(); + }); + + it('should throw an error when user ID does not match', async function () { + spyOn(adapter, 'request').and.returnValue( + Promise.resolve({ + json: () => Promise.resolve({ id: 'invalidUserId' }), + }) + ); + + const authData = { + id: 'validUserId', + oauth_token: 'validToken', + oauth_token_secret: 'validSecret', + }; + await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeRejectedWithError( + 'Twitter auth is invalid for this user.' + ); + }); + }); + + describe('End-to-End Tests', function () { + beforeEach(async function () { + await reconfigureServer({ + auth: { + twitter: validOptions, + } + }) + }); + + it('should authenticate user successfully using validateAuthData', async function () { + spyOn(adapter, 'exchangeAccessToken').and.returnValue( + Promise.resolve({ oauth_token: 'validToken', user_id: 'validUserId' }) + ); + + const authData = { + oauth_token: 'validToken', + oauth_verifier: 'validVerifier', + }; + await expectAsync(adapter.validateAuthData(authData, validOptions)).toBeResolved(); + expect(authData.id).toBe('validUserId'); + expect(authData.auth_token).toBe('validToken'); + }); + + it('should handle multiple configurations and validate successfully', async function () { + const authData = { + consumer_key: 'validConsumerKey', + oauth_token: 'validToken', + oauth_token_secret: 'validSecret', + }; + + const optionsArray = [ + { consumer_key: 'invalidKey', consumer_secret: 'invalidSecret' }, + validOptions, + ]; + + const selectedOption = adapter.handleMultipleConfigurations(authData, optionsArray); + expect(selectedOption).toEqual(validOptions); + }); + + it('should throw an error when no matching configuration is found', function () { + const authData = { consumer_key: 'missingKey' }; + const optionsArray = [validOptions]; + + expect(() => adapter.handleMultipleConfigurations(authData, optionsArray)).toThrowError( + 'Twitter auth is invalid for this user.' + ); + }); + }); +}); diff --git a/spec/Adapters/Auth/wechat.spec.js b/spec/Adapters/Auth/wechat.spec.js new file mode 100644 index 0000000000..b82e3e877a --- /dev/null +++ b/spec/Adapters/Auth/wechat.spec.js @@ -0,0 +1,234 @@ +const WeChatAdapter = require('../../../lib/Adapters/Auth/wechat').default; + +describe('WeChatAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new WeChatAdapter.constructor(); + }); + + describe('Test getUserFromAccessToken', function () { + it('should fetch user successfully', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ errcode: 0, id: 'validUserId' }), + }, + }, + ]); + + const user = await adapter.getUserFromAccessToken('validToken', { id: 'validOpenId' }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId' + ); + expect(user).toEqual({ errcode: 0, id: 'validUserId' }); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=invalidToken&openid=undefined', + method: 'GET', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40013, errmsg: 'Invalid token' }), + }, + }, + ]); + + await expectAsync(adapter.getUserFromAccessToken('invalidToken', 'invalidOpenId')).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + }); + + describe('Test getAccessTokenFromCode', function () { + it('should fetch access token successfully', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validToken', errcode: 0 }), + }, + }, + ]); + + adapter.validateOptions({ clientId: 'validAppId', clientSecret: 'validAppSecret' }); + const authData = { code: 'validCode' }; + const token = await adapter.getAccessTokenFromCode(authData); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code' + ); + expect(token).toEqual('validToken'); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=invalidCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40029, errmsg: 'Invalid code' }), + }, + }, + ]); + adapter.validateOptions({ clientId: 'validAppId', clientSecret: 'validAppSecret' }); + + const authData = { code: 'invalidCode' }; + + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + }); + + describe('WeChatAdapter E2E Tests', function () { + beforeEach(async () => { + await reconfigureServer({ + auth: { + wechat: { + clientId: 'validAppId', + clientSecret: 'validAppSecret', + enableInsecureAuth: false, + }, + }, + }); + }); + + it('should authenticate user successfully using WeChatAdapter', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken', openid: 'user123', errcode: 0 }), + }, + }, + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ errcode: 0, id: 'user123' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + const user = await Parse.User.logInWith('wechat', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle invalid code error gracefully', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=invalidCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40029, errmsg: 'Invalid code' }), + }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + + it('should handle error when fetching user data fails', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken', openid: 'user123', errcode: 0 }), + }, + }, + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123', + method: 'GET', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40013, errmsg: 'Invalid token' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + + await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + + it('should allow insecure auth when enabled', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ errcode: 0, id: 'user123' }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + wechat: { + appId: 'validAppId', + appSecret: 'validAppSecret', + enableInsecureAuth: true, + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'user123' }; + const user = await Parse.User.logInWith('wechat', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should reject insecure auth when user id does not match', async function () { + mockFetch([ + { + url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=incorrectUserId', + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ errcode: 0, id: 'incorrectUser' }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + wechat: { + appId: 'validAppId', + appSecret: 'validAppSecret', + enableInsecureAuth: true, + }, + }, + }); + + const authData = { access_token: 'validAccessToken', id: 'incorrectUserId' }; + await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' }) + ); + }); + }); +}); diff --git a/spec/Adapters/Auth/weibo.spec.js b/spec/Adapters/Auth/weibo.spec.js new file mode 100644 index 0000000000..685739e663 --- /dev/null +++ b/spec/Adapters/Auth/weibo.spec.js @@ -0,0 +1,204 @@ +const WeiboAdapter = require('../../../lib/Adapters/Auth/weibo').default; + +describe('WeiboAdapter', function () { + let adapter; + + beforeEach(function () { + adapter = new WeiboAdapter.constructor(); + }); + + describe('Test configuration errors', function () { + it('should throw error if code or redirect_uri is missing', async function () { + const invalidAuthData = [ + {}, + { code: 'validCode' }, + { redirect_uri: 'http://example.com/callback' }, + ]; + + for (const authData of invalidAuthData) { + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith( + jasmine.objectContaining({ + message: 'Weibo auth requires code and redirect_uri to be sent.', + }) + ); + } + }); + }); + + describe('Test getUserFromAccessToken', function () { + it('should fetch user successfully', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/get_token_info', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ uid: 'validUserId' }), + }, + }, + ]); + + const authData = { id: 'validUserId' }; + const user = await adapter.getUserFromAccessToken('validToken', authData); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.weibo.com/oauth2/get_token_info', + jasmine.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + expect(user).toEqual({ id: 'validUserId' }); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/get_token_info', + method: 'POST', + response: { + ok: false, + json: () => Promise.resolve({}), + }, + }, + ]); + + const authData = { id: 'invalidUserId' }; + await expectAsync(adapter.getUserFromAccessToken('invalidToken', authData)).toBeRejectedWith( + jasmine.objectContaining({ + message: 'Weibo auth is invalid for this user.', + }) + ); + }); + }); + + describe('Test getAccessTokenFromCode', function () { + it('should fetch access token successfully', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validToken', uid: 'validUserId' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + const token = await adapter.getAccessTokenFromCode(authData); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.weibo.com/oauth2/access_token', + jasmine.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + expect(token).toEqual('validToken'); + }); + + it('should throw error for invalid response', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40029 }), + }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' }; + await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith( + jasmine.objectContaining({ + message: 'Weibo auth is invalid for this user.', + }) + ); + }); + }); + + describe('WeiboAdapter E2E Tests', function () { + beforeEach(async () => { + await reconfigureServer({ + auth: { + weibo: { + clientId: 'validAppId', + clientSecret: 'validAppSecret', + }, + } + }); + }); + + it('should authenticate user successfully using WeiboAdapter', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken', uid: 'user123' }), + }, + }, + { + url: 'https://api.weibo.com/oauth2/get_token_info', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ uid: 'user123' }), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + const user = await Parse.User.logInWith('weibo', { authData }); + + expect(user.id).toBeDefined(); + }); + + it('should handle invalid code error gracefully', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: false, + json: () => Promise.resolve({ errcode: 40029 }), + }, + }, + ]); + + const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' }; + await expectAsync(Parse.User.logInWith('weibo', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'Weibo auth is invalid for this user.' }) + ); + }); + + it('should handle error when fetching user data fails', async function () { + mockFetch([ + { + url: 'https://api.weibo.com/oauth2/access_token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'validAccessToken', uid: 'user123' }), + }, + }, + { + url: 'https://api.weibo.com/oauth2/get_token_info', + method: 'POST', + response: { + ok: false, + json: () => Promise.resolve({}), + }, + }, + ]); + + const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' }; + await expectAsync(Parse.User.logInWith('weibo', { authData })).toBeRejectedWith( + jasmine.objectContaining({ message: 'Weibo auth is invalid for this user.' }) + ); + }); + }); +}); diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 32fcdca891..a2defde3e5 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -3,99 +3,8 @@ const Config = require('../lib/Config'); const defaultColumns = require('../lib/Controllers/SchemaController').defaultColumns; const authenticationLoader = require('../lib/Adapters/Auth'); const path = require('path'); -const responses = { - gpgames: { playerId: 'userId' }, - instagram: { id: 'userId' }, - janrainengage: { stat: 'ok', profile: { identifier: 'userId' } }, - janraincapture: { stat: 'ok', result: 'userId' }, - line: { userId: 'userId' }, - vkontakte: { response: [{ id: 'userId' }] }, - google: { sub: 'userId' }, - wechat: { errcode: 0 }, - weibo: { uid: 'userId' }, - qq: 'callback( {"openid":"userId"} );', // yes it's like that, run eval in the client :P - phantauth: { sub: 'userId' }, - microsoft: { id: 'userId', mail: 'userMail' }, -}; describe('AuthenticationProviders', function () { - [ - 'apple', - 'gcenter', - 'gpgames', - 'facebook', - 'github', - 'instagram', - 'google', - 'linkedin', - 'meetup', - 'twitter', - 'janrainengage', - 'janraincapture', - 'line', - 'vkontakte', - 'qq', - 'spotify', - 'wechat', - 'weibo', - 'phantauth', - 'microsoft', - 'keycloak', - ].map(function (providerName) { - it('Should validate structure of ' + providerName, done => { - const provider = require('../lib/Adapters/Auth/' + providerName); - jequal(typeof provider.validateAuthData, 'function'); - jequal(typeof provider.validateAppId, 'function'); - const validateAuthDataPromise = provider.validateAuthData({}, {}); - const validateAppIdPromise = provider.validateAppId('app', 'key', {}); - jequal(validateAuthDataPromise.constructor, Promise.prototype.constructor); - jequal(validateAppIdPromise.constructor, Promise.prototype.constructor); - validateAuthDataPromise.then( - () => {}, - () => {} - ); - validateAppIdPromise.then( - () => {}, - () => {} - ); - done(); - }); - - it(`should provide the right responses for adapter ${providerName}`, async () => { - const noResponse = ['twitter', 'apple', 'gcenter', 'google', 'keycloak']; - if (noResponse.includes(providerName)) { - return; - } - spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake(options => { - if ( - options === - 'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.123&grant_type=client_credentials' || - options === - 'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.124&grant_type=client_credentials' - ) { - return { - access_token: 'access_token', - }; - } - return Promise.resolve(responses[providerName] || { id: 'userId' }); - }); - spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'request').and.callFake(() => { - return Promise.resolve(responses[providerName] || { id: 'userId' }); - }); - const provider = require('../lib/Adapters/Auth/' + providerName); - let params = {}; - if (providerName === 'vkontakte') { - params = { - appIds: 'appId', - appSecret: 'appSecret', - }; - await provider.validateAuthData({ id: 'userId' }, params); - params.appVersion = '5.123'; - } - await provider.validateAuthData({ id: 'userId' }, params); - }); - }); - const getMockMyOauthProvider = function () { return { authData: { @@ -568,46 +477,6 @@ describe('AuthenticationProviders', function () { }); }); -describe('instagram auth adapter', () => { - const instagram = require('../lib/Adapters/Auth/instagram'); - const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); - - it('should use default api', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ data: { id: 'userId' } }); - }); - await instagram.validateAuthData({ id: 'userId', access_token: 'the_token' }, {}); - expect(httpsRequest.get).toHaveBeenCalledWith( - 'https://graph.instagram.com/me?fields=id&access_token=the_token' - ); - }); - it('response object without data child', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ id: 'userId' }); - }); - await instagram.validateAuthData({ id: 'userId', access_token: 'the_token' }, {}); - expect(httpsRequest.get).toHaveBeenCalledWith( - 'https://graph.instagram.com/me?fields=id&access_token=the_token' - ); - }); - it('should pass in api url', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ data: { id: 'userId' } }); - }); - await instagram.validateAuthData( - { - id: 'userId', - access_token: 'the_token', - apiURL: 'https://new-api.instagram.com/v1/', - }, - {} - ); - expect(httpsRequest.get).toHaveBeenCalledWith( - 'https://new-api.instagram.com/v1/me?fields=id&access_token=the_token' - ); - }); -}); - describe('google auth adapter', () => { const google = require('../lib/Adapters/Auth/google'); const jwt = require('jsonwebtoken'); @@ -730,35 +599,6 @@ describe('google auth adapter', () => { }); }); -describe('google play games service auth', () => { - const gpgames = require('../lib/Adapters/Auth/gpgames'); - const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); - - it('validateAuthData should pass validation', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ playerId: 'userId' }); - }); - await gpgames.validateAuthData({ - id: 'userId', - access_token: 'access_token', - }); - }); - - it('validateAuthData should throw error', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ playerId: 'invalid' }); - }); - try { - await gpgames.validateAuthData({ - id: 'userId', - access_token: 'access_token', - }); - } catch (e) { - expect(e.message).toBe('Google Play Games Services - authData is invalid for this user.'); - } - }); -}); - describe('keycloak auth adapter', () => { const keycloak = require('../lib/Adapters/Auth/keycloak'); const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); @@ -987,433 +827,6 @@ describe('keycloak auth adapter', () => { }); }); -describe('oauth2 auth adapter', () => { - const oauth2 = require('../lib/Adapters/Auth/oauth2'); - const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); - - it('properly loads OAuth2 adapter via the "oauth2" option', () => { - const options = { - oauth2Authentication: { - oauth2: true, - }, - }; - const loadedAuthAdapter = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); - expect(loadedAuthAdapter.adapter).toEqual(oauth2); - }); - - it('properly loads OAuth2 adapter with options', () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - useridField: 'sub', - appidField: 'appId', - appIds: ['a', 'b'], - authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', - debug: true, - }, - }; - const loadedAuthAdapter = authenticationLoader.loadAuthAdapter('oauth2Authentication', options); - const appIds = loadedAuthAdapter.appIds; - const providerOptions = loadedAuthAdapter.providerOptions; - expect(providerOptions.tokenIntrospectionEndpointUrl).toEqual('https://example.com/introspect'); - expect(providerOptions.useridField).toEqual('sub'); - expect(providerOptions.appidField).toEqual('appId'); - expect(appIds).toEqual(['a', 'b']); - expect(providerOptions.authorizationHeader).toEqual('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); - expect(providerOptions.debug).toEqual(true); - }); - - it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - appIds: ['a', 'b'], - appidField: 'appId', - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - expect(e.message).toBe( - 'OAuth2 token introspection endpoint URL is missing from configuration!' - ); - } - }); - - it('validateAppId appidField optional', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - // Should not reach here - fail(e); - } - }); - - it('validateAppId should fail without appIds', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - appidField: 'appId', - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - expect(e.message).toBe( - 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).' - ); - } - }); - - it('validateAppId should fail empty appIds', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - appidField: 'appId', - appIds: [], - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - expect(e.message).toBe( - 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).' - ); - } - }); - - it('validateAppId invalid accessToken', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - appidField: 'appId', - appIds: ['a', 'b'], - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - spyOn(httpsRequest, 'request').and.callFake(() => { - return Promise.resolve({}); - }); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - expect(e.message).toBe('OAuth2 access token is invalid for this user.'); - } - }); - - it('validateAppId invalid accessToken appId', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - appidField: 'appId', - appIds: ['a', 'b'], - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - spyOn(httpsRequest, 'request').and.callFake(() => { - return Promise.resolve({ active: true }); - }); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - expect(e.message).toBe( - "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration." - ); - } - }); - - it('validateAppId valid accessToken appId', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - appidField: 'appId', - appIds: ['a', 'b'], - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - spyOn(httpsRequest, 'request').and.callFake(() => { - return Promise.resolve({ - active: true, - appId: 'a', - }); - }); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - // Should not enter here - fail(e); - } - }); - - it('validateAppId valid accessToken appId array', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - appidField: 'appId', - appIds: ['a', 'b'], - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - spyOn(httpsRequest, 'request').and.callFake(() => { - return Promise.resolve({ - active: true, - appId: ['a'], - }); - }); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - // Should not enter here - fail(e); - } - }); - - it('validateAppId valid accessToken invalid appId', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - appidField: 'appId', - appIds: ['a', 'b'], - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - spyOn(httpsRequest, 'request').and.callFake(() => { - return Promise.resolve({ - active: true, - appId: 'unknown', - }); - }); - try { - await adapter.validateAppId(appIds, authData, providerOptions); - } catch (e) { - expect(e.message).toBe( - "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration." - ); - } - }); - - it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - try { - await adapter.validateAuthData(authData, providerOptions); - } catch (e) { - expect(e.message).toBe( - 'OAuth2 token introspection endpoint URL is missing from configuration!' - ); - } - }); - - it('validateAuthData invalid accessToken', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - useridField: 'sub', - appidField: 'appId', - appIds: ['a', 'b'], - authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - spyOn(httpsRequest, 'request').and.callFake(() => { - return Promise.resolve({}); - }); - try { - await adapter.validateAuthData(authData, providerOptions); - } catch (e) { - expect(e.message).toBe('OAuth2 access token is invalid for this user.'); - } - expect(httpsRequest.request).toHaveBeenCalledWith( - { - hostname: 'example.com', - path: '/introspect', - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': 15, - Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', - }, - }, - 'token=sometoken' - ); - }); - - it('validateAuthData valid accessToken', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - useridField: 'sub', - appidField: 'appId', - appIds: ['a', 'b'], - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - spyOn(httpsRequest, 'request').and.callFake(() => { - return Promise.resolve({ - active: true, - sub: 'fakeid', - }); - }); - try { - await adapter.validateAuthData(authData, providerOptions); - } catch (e) { - // Should not enter here - fail(e); - } - expect(httpsRequest.request).toHaveBeenCalledWith( - { - hostname: 'example.com', - path: '/introspect', - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': 15, - }, - }, - 'token=sometoken' - ); - }); - - it('validateAuthData valid accessToken without useridField', async () => { - const options = { - oauth2Authentication: { - oauth2: true, - tokenIntrospectionEndpointUrl: 'https://example.com/introspect', - appidField: 'appId', - appIds: ['a', 'b'], - }, - }; - const authData = { - id: 'fakeid', - access_token: 'sometoken', - }; - const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( - 'oauth2Authentication', - options - ); - spyOn(httpsRequest, 'request').and.callFake(() => { - return Promise.resolve({ - active: true, - sub: 'fakeid', - }); - }); - try { - await adapter.validateAuthData(authData, providerOptions); - } catch (e) { - // Should not enter here - fail(e); - } - }); -}); - describe('apple signin auth adapter', () => { const apple = require('../lib/Adapters/Auth/apple'); const jwt = require('jsonwebtoken'); @@ -1722,206 +1135,17 @@ describe('apple signin auth adapter', () => { }); }); -describe('Apple Game Center Auth adapter', () => { - const gcenter = require('../lib/Adapters/Auth/gcenter'); - const fs = require('fs'); - const testCert = fs.readFileSync(__dirname + '/support/cert/game_center.pem'); - const testCert2 = fs.readFileSync(__dirname + '/support/cert/game_center.pem'); - - it('can load adapter', async () => { - const options = { - gcenter: { - rootCertificateUrl: - 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem', - }, - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'gcenter', - options - ); - await adapter.validateAppId( - appIds, - { publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' }, - providerOptions - ); - }); - - it('validateAuthData should validate', async () => { - const options = { - gcenter: { - rootCertificateUrl: - 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem', - }, - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'gcenter', - options - ); - await adapter.validateAppId( - appIds, - { publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' }, - providerOptions - ); - // real token is used - const authData = { - id: 'G:1965586982', - publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer', - timestamp: 1565257031287, - signature: - 'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==', - salt: 'DzqqrQ==', - bundleId: 'cloud.xtralife.gamecenterauth', - }; - gcenter.cache['https://static.gc.apple.com/public-key/gc-prod-4.cer'] = testCert; - await gcenter.validateAuthData(authData); - }); - - it('validateAuthData invalid signature id', async () => { - gcenter.cache['https://static.gc.apple.com/public-key/gc-prod-4.cer'] = testCert; - gcenter.cache['https://static.gc.apple.com/public-key/gc-prod-6.cer'] = testCert2; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'gcenter', - {} - ); - await adapter.validateAppId( - appIds, - { publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' }, - providerOptions - ); - const authData = { - id: 'G:1965586982', - publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-6.cer', - timestamp: 1565257031287, - signature: '1234', - salt: 'DzqqrQ==', - bundleId: 'com.example.com', - }; - await expectAsync(gcenter.validateAuthData(authData)).toBeRejectedWith( - new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Apple Game Center - invalid signature') - ); - }); - - it('validateAuthData invalid public key http url', async () => { - const options = { - gcenter: { - rootCertificateUrl: - 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem', - }, - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'gcenter', - options - ); - await adapter.validateAppId( - appIds, - { publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' }, - providerOptions - ); - const publicKeyUrls = [ - 'example.com', - 'http://static.gc.apple.com/public-key/gc-prod-4.cer', - 'https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg', - 'https://example.com/ \\.apple.com/public_key.cer', - 'https://example.com/ &.apple.com/public_key.cer', - ]; - await Promise.all( - publicKeyUrls.map(publicKeyUrl => - expectAsync( - gcenter.validateAuthData({ - id: 'G:1965586982', - timestamp: 1565257031287, - publicKeyUrl, - signature: '1234', - salt: 'DzqqrQ==', - bundleId: 'com.example.com', - }) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.SCRIPT_FAILED, - `Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` - ) - ) - ) - ); - }); - - it('should not validate Symantec Cert', async () => { - const options = { - gcenter: { - rootCertificateUrl: - 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem', - }, - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'gcenter', - options - ); - await adapter.validateAppId( - appIds, - { publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' }, - providerOptions - ); - expect(() => - gcenter.verifyPublicKeyIssuer( - testCert, - 'https://static.gc.apple.com/public-key/gc-prod-4.cer' - ) - ); - }); - - it('adapter should load default cert', async () => { - const options = { - gcenter: {}, - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'gcenter', - options - ); - await adapter.validateAppId( - appIds, - { publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' }, - providerOptions - ); - const previous = new Date(); - await adapter.validateAppId( - appIds, - { publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' }, - providerOptions - ); - - const duration = new Date().getTime() - previous.getTime(); - expect(duration <= 1).toBe(true); - }); - - it('adapter should throw', async () => { - const options = { - gcenter: { - rootCertificateUrl: 'https://example.com', - }, - }; - const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter( - 'gcenter', - options - ); - await expectAsync( - adapter.validateAppId( - appIds, - { publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' }, - providerOptions - ) - ).toBeRejectedWith( - new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.' - ) - ); - }); -}); - describe('phant auth adapter', () => { const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); it('validateAuthData should throw for invalid auth', async () => { + await reconfigureServer({ + auth: { + phantauth: { + enableInsecureAuth: true, + } + } + }) const authData = { id: 'fakeid', access_token: 'sometoken', @@ -1938,34 +1162,6 @@ describe('phant auth adapter', () => { }); }); -describe('microsoft graph auth adapter', () => { - const microsoft = require('../lib/Adapters/Auth/microsoft'); - const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); - - it('should use access_token for validation is passed and responds with id and mail', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ id: 'userId', mail: 'userMail' }); - }); - await microsoft.validateAuthData({ - id: 'userId', - access_token: 'the_token', - }); - }); - - it('should fail to validate Microsoft Graph auth with bad token', done => { - const authData = { - id: 'fake-id', - mail: 'fake@mail.com', - access_token: 'very.long.bad.token', - }; - microsoft.validateAuthData(authData).then(done.fail, err => { - expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND); - expect(err.message).toBe('Microsoft Graph auth is invalid for this user.'); - done(); - }); - }); -}); - describe('facebook limited auth adapter', () => { const facebook = require('../lib/Adapters/Auth/facebook'); const jwt = require('jsonwebtoken'); diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index d5aa8a5898..7301ab54c1 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -355,16 +355,16 @@ describe('Auth Adapter features', () => { const authData = user.get('authData').modernAdapter3; expect(authData).toEqual({ foo: 'bar' }); for (const call of afterSpy.calls.all()) { - const args = call.args[0]; + const args = call.args[2]; if (args.user) { user._objCount = args.user._objCount; break; } } expect(afterSpy).toHaveBeenCalledWith( - { ip: '127.0.0.1', user, master: false }, { id: 'modernAdapter3Data' }, - undefined + undefined, + { ip: '127.0.0.1', user, master: false }, ); expect(spy).toHaveBeenCalled(); }); diff --git a/spec/SecurityCheckGroups.spec.js b/spec/SecurityCheckGroups.spec.js index 43d523c214..7faca01898 100644 --- a/spec/SecurityCheckGroups.spec.js +++ b/spec/SecurityCheckGroups.spec.js @@ -32,6 +32,7 @@ describe('Security Check Groups', () => { config.masterKey = 'aMoreSecur3Passwor7!'; config.security.enableCheckLog = false; config.allowClientClassCreation = false; + config.enableInsecureAuthAdapters = false; await reconfigureServer(config); const group = new CheckGroupServerConfig(); @@ -39,6 +40,7 @@ describe('Security Check Groups', () => { expect(group.checks()[0].checkState()).toBe(CheckState.success); expect(group.checks()[1].checkState()).toBe(CheckState.success); expect(group.checks()[2].checkState()).toBe(CheckState.success); + expect(group.checks()[4].checkState()).toBe(CheckState.success); }); it('checks fail correctly', async () => { @@ -52,6 +54,7 @@ describe('Security Check Groups', () => { expect(group.checks()[0].checkState()).toBe(CheckState.fail); expect(group.checks()[1].checkState()).toBe(CheckState.fail); expect(group.checks()[2].checkState()).toBe(CheckState.fail); + expect(group.checks()[4].checkState()).toBe(CheckState.fail); }); }); diff --git a/spec/TwitterAuth.spec.js b/spec/TwitterAuth.spec.js deleted file mode 100644 index 8f2bc31e84..0000000000 --- a/spec/TwitterAuth.spec.js +++ /dev/null @@ -1,97 +0,0 @@ -const twitter = require('../lib/Adapters/Auth/twitter'); - -describe('Twitter Auth', () => { - it('should use the proper configuration', () => { - // Multiple options, consumer_key found - expect( - twitter.handleMultipleConfigurations( - { - consumer_key: 'hello', - }, - [ - { - consumer_key: 'hello', - }, - { - consumer_key: 'world', - }, - ] - ).consumer_key - ).toEqual('hello'); - - // Multiple options, consumer_key not found - expect(function () { - twitter.handleMultipleConfigurations( - { - consumer_key: 'some', - }, - [ - { - consumer_key: 'hello', - }, - { - consumer_key: 'world', - }, - ] - ); - }).toThrow(); - - // Multiple options, consumer_key not found - expect(function () { - twitter.handleMultipleConfigurations( - { - auth_token: 'token', - }, - [ - { - consumer_key: 'hello', - }, - { - consumer_key: 'world', - }, - ] - ); - }).toThrow(); - - // Single configuration and consumer_key set - expect( - twitter.handleMultipleConfigurations( - { - consumer_key: 'hello', - }, - { - consumer_key: 'hello', - } - ).consumer_key - ).toEqual('hello'); - - // General case, only 1 config, no consumer_key set - expect( - twitter.handleMultipleConfigurations( - { - auth_token: 'token', - }, - { - consumer_key: 'hello', - } - ).consumer_key - ).toEqual('hello'); - }); - - it('Should fail with missing options', done => { - try { - twitter.validateAuthData( - { - consumer_key: 'key', - consumer_secret: 'secret', - auth_token: 'token', - auth_token_secret: 'secret', - }, - undefined - ); - } catch (error) { - jequal(error.message, 'Twitter auth configuration missing'); - done(); - } - }); -}); diff --git a/spec/helper.js b/spec/helper.js index 7093cfcc4c..5be30998fc 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -424,6 +424,25 @@ function mockShortLivedAuth() { return auth; } +function mockFetch(mockResponses) { + global.fetch = jasmine.createSpy('fetch').and.callFake((url, options = { }) => { + options.method ||= 'GET'; + const mockResponse = mockResponses.find( + (mock) => mock.url === url && mock.method === options.method + ); + + if (mockResponse) { + return Promise.resolve(mockResponse.response); + } + + return Promise.resolve({ + ok: false, + statusText: 'Unknown URL or method', + }); + }); +} + + // This is polluting, but, it makes it way easier to directly port old tests. global.Parse = Parse; global.TestObject = TestObject; @@ -439,6 +458,7 @@ global.arrayContains = arrayContains; global.jequal = jequal; global.range = range; global.reconfigureServer = reconfigureServer; +global.mockFetch = mockFetch; global.defaultConfiguration = defaultConfiguration; global.mockCustomAuthenticator = mockCustomAuthenticator; global.mockFacebookAuthenticator = mockFacebookAuthenticator; diff --git a/spec/support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem b/spec/support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem new file mode 100644 index 0000000000..640c15243d --- /dev/null +++ b/spec/support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem @@ -0,0 +1,38 @@ +-----BEGIN CERTIFICATE----- +MIIGsDCCBJigAwIBAgIQCK1AsmDSnEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBi +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 +d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg +RzQwHhcNMjEwNDI5MDAwMDAwWhcNMzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJV +UzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRy +dXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIIC +IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1 +M4zrPYGXcMW7xIUmMJ+kjmjYXPXrNCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZ +wZHMgQM+TXAkZLON4gh9NH1MgFcSa0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI +8IrgnQnAZaf6mIBJNYc9URnokCF4RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGi +TUyCEUhSaN4QvRRXXegYE2XFf7JPhSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLm +ysL0p6MDDnSlrzm2q2AS4+jWufcx4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3S +vUQakhCBj7A7CdfHmzJawv9qYFSLScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tv +k2E0XLyTRSiDNipmKF+wc86LJiUGsoPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+ +960IHnWmZcy740hQ83eRGv7bUKJGyGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3s +MJN2FKZbS110YU0/EpF23r9Yy3IQKUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FK +PkBHX8mBUHOFECMhWWCKZFTBzCEa6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1H +s/q27IwyCQLMbDwMVhECAwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAw +HQYDVR0OBBYEFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LS +cV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEF +BQcDAzB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp +Z2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu +Y29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYy +aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5j +cmwwHAYDVR0gBBUwEzAHBgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQAD +ggIBADojRD2NCHbuj7w6mdNW4AIapfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L +/Z6jfCbVN7w6XUhtldU/SfQnuxaBRVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHV +UHmImoqKwba9oUgYftzYgBoRGRjNYZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rd +KOtfJqGVWEjVGv7XJz/9kNF2ht0csGBc8w2o7uCJob054ThO2m67Np375SFTWsPK +6Wrxoj7bQ7gzyE84FJKZ9d3OVG3ZXQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43N +b3Y3LIU/Gs4m6Ri+kAewQ3+ViCCCcPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4Z +XDlx4b6cpwoG1iZnt5LmTl/eeqxJzy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvm +oLr9Oj9FpsToFpFSi0HASIRLlk2rREDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8 +y4+ICw2/O/TOHnuO77Xry7fwdxPm5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMM +B0ug0wcCampAMEhLNKhRILutG4UI4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+F +SCH5Vzu0nAPthkX0tGFuv2jiJmCG6sivqf6UHedjGzqGVnhO +-----END CERTIFICATE----- diff --git a/spec/support/cert/gc-prod-4.cer b/spec/support/cert/gc-prod-4.cer new file mode 100644 index 0000000000..873d6f31f6 Binary files /dev/null and b/spec/support/cert/gc-prod-4.cer differ diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json index 84d7629c1b..1fbab72636 100644 --- a/spec/support/jasmine.json +++ b/spec/support/jasmine.json @@ -1,6 +1,6 @@ { "spec_dir": "spec", - "spec_files": ["*spec.js"], + "spec_files": ["**/*.[sS]pec.js"], "helpers": ["helper.js"], "random": true } diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index e739df3f54..afc05d0bb2 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -40,11 +40,11 @@ export class AuthAdapter { * Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login) * otherwise you should implement validateSetup, validateLogin and validateUpdate * @param {Object} authData The client provided authData - * @param {Parse.Cloud.TriggerRequest} request * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request * @returns {Promise} */ - validateAuthData(authData, request, options) { + validateAuthData(authData, options, request) { return Promise.resolve({}); } @@ -52,11 +52,11 @@ export class AuthAdapter { * Triggered when user provide for the first time this auth provider * could be a register or the user adding a new auth service * @param {Object} authData The client provided authData - * @param {Parse.Cloud.TriggerRequest} request * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request * @returns {Promise} */ - validateSetUp(authData, req, options) { + validateSetUp(authData, options, req) { return Promise.resolve({}); } @@ -64,11 +64,11 @@ export class AuthAdapter { * Triggered when user provide authData related to this provider * The user is not logged in and has already set this provider before * @param {Object} authData The client provided authData - * @param {Parse.Cloud.TriggerRequest} request * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request * @returns {Promise} */ - validateLogin(authData, req, options) { + validateLogin(authData, options, req) { return Promise.resolve({}); } @@ -80,10 +80,18 @@ export class AuthAdapter { * @param {Parse.Cloud.TriggerRequest} request * @returns {Promise} */ - validateUpdate(authData, req, options) { + validateUpdate(authData, options, req) { return Promise.resolve({}); } + /** + * Triggered when user is looked up by authData with this provider. Override the `id` field if needed. + * @param {Object} authData The client provided authData + */ + beforeFind(authData) { + + } + /** * Triggered in pre authentication process if needed (like webauthn, SMS OTP) * @param {Object} challengeData Data provided by the client @@ -100,9 +108,10 @@ export class AuthAdapter { * Triggered when auth data is fetched * @param {Object} authData authData * @param {Object} options additional adapter options + * @param {Parse.Cloud.TriggerRequest} request * @returns {Promise} Any overrides required to authData */ - afterFind(authData, options) { + afterFind(authData, options, request) { return Promise.resolve({}); } diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js new file mode 100644 index 0000000000..696e4ee71b --- /dev/null +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -0,0 +1,112 @@ +// abstract class for auth code adapters +import AuthAdapter from './AuthAdapter'; +export default class BaseAuthCodeAdapter extends AuthAdapter { + constructor(adapterName) { + super(); + this.adapterName = adapterName; + } + validateOptions(options) { + + if (!options) { + throw new Error(`${this.adapterName} options are required.`); + } + + this.enableInsecureAuth = options.enableInsecureAuth; + if (this.enableInsecureAuth) { + return; + } + + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + + if (!this.clientId) { + throw new Error(`${this.adapterName} clientId is required.`); + } + + if (!this.clientSecret) { + throw new Error(`${this.adapterName} clientSecret is required.`); + } + } + + async beforeFind(authData) { + if (this.enableInsecureAuth && !authData?.code) { + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); + } + + const user = await this.getUserFromAccessToken(authData.access_token, authData); + + if (user.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); + } + + return; + } + + if (!authData?.code) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); + } + + const access_token = await this.getAccessTokenFromCode(authData); + const user = await this.getUserFromAccessToken(access_token, authData); + + if (authData.id && user.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); + } + + authData.access_token = access_token; + authData.id = user.id; + + delete authData.code; + delete authData.redirect_uri; + + } + + async getUserFromAccessToken() { + // abstract method + throw new Error('getUserFromAccessToken is not implemented'); + } + + async getAccessTokenFromCode() { + // abstract method + throw new Error('getAccessTokenFromCode is not implemented'); + } + + validateLogin(authData) { + // User validation is already done in beforeFind + return { + id: authData.id, + } + } + + validateSetUp(authData) { + // User validation is already done in beforeFind + return { + id: authData.id, + } + } + + afterFind(authData) { + return { + id: authData.id, + } + } + + validateUpdate(authData) { + // User validation is already done in beforeFind + return { + id: authData.id, + } + + } + + parseResponseData(data) { + const startPos = data.indexOf('('); + const endPos = data.indexOf(')'); + if (startPos === -1 || endPos === -1) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); + } + const jsonData = data.substring(startPos + 1, endPos); + return JSON.parse(jsonData); + } +} diff --git a/src/Adapters/Auth/apple.js b/src/Adapters/Auth/apple.js index 4fd1153b75..24502f4a55 100644 --- a/src/Adapters/Auth/apple.js +++ b/src/Adapters/Auth/apple.js @@ -1,3 +1,47 @@ +/** + * Parse Server authentication adapter for Apple. + * + * @class AppleAdapter + * @param {Object} options - Configuration options for the adapter. + * @param {string} options.clientId - Your Apple App ID. + * + * @param {Object} authData - The authentication data provided by the client. + * @param {string} authData.id - The user ID obtained from Apple. + * @param {string} authData.token - The token obtained from Apple. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Apple authentication, use the following structure: + * ```json + * { + * "auth": { + * "apple": { + * "clientId": "12345" + * } + * } + * } + * ``` + * + * ## Expected `authData` from the Client + * The adapter expects the client to provide the following `authData` payload: + * - `authData.id` (**string**, required): The user ID obtained from Apple. + * - `authData.token` (**string**, required): The token obtained from Apple. + * + * Parse Server stores the required authentication data in the database. + * + * ### Example AuthData from Apple + * ```json + * { + * "apple": { + * "id": "1234567", + * "token": "xxxxx.yyyyy.zzzzz" + * } + * } + * ``` + * + * @see {@link https://developer.apple.com/documentation/signinwithapplerestapi Sign in with Apple REST API Documentation} + */ + // Apple SignIn Auth // https://developer.apple.com/documentation/signinwithapplerestapi diff --git a/src/Adapters/Auth/facebook.js b/src/Adapters/Auth/facebook.js index 858e9579c6..273004ad62 100644 --- a/src/Adapters/Auth/facebook.js +++ b/src/Adapters/Auth/facebook.js @@ -1,3 +1,63 @@ +/** + * Parse Server authentication adapter for Facebook. + * + * @class FacebookAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.appSecret - Your Facebook App Secret. Required for secure authentication. + * @param {string[]} options.appIds - An array of Facebook App IDs. Required for validating the app. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Facebook authentication, use the following structure: + * ```json + * { + * "auth": { + * "facebook": { + * "appSecret": "your-app-secret", + * "appIds": ["your-app-id"] + * } + * } + * } + * ``` + * + * The adapter supports the following authentication methods: + * - **Standard Login**: Requires `id` and `access_token`. + * - **Limited Login**: Requires `id` and `token`. + * + * ## Auth Payloads + * ### Standard Login Payload + * ```json + * { + * "facebook": { + * "id": "1234567", + * "access_token": "abc123def456ghi789" + * } + * } + * ``` + * + * ### Limited Login Payload + * ```json + * { + * "facebook": { + * "id": "1234567", + * "token": "xxxxx.yyyyy.zzzzz" + * } + * } + * ``` + * + * ## Notes + * - **Standard Login**: Use `id` and `access_token` for full functionality. + * - **Limited Login**: Use `id` and `token` (JWT) when tracking is opted out (e.g., via Apple's App Tracking Transparency). + * - Supported Parse Server versions: + * - `>= 6.5.6 < 7` + * - `>= 7.0.1` + * + * Secure authentication is recommended to ensure proper data protection and compliance with Facebook's guidelines. + * + * @see {@link https://developers.facebook.com/docs/facebook-login/limited-login/ Facebook Limited Login} + * @see {@link https://developers.facebook.com/docs/facebook-login/facebook-login-for-business/ Facebook Login for Business} + */ + // Helper functions for accessing the Facebook Graph API. const Parse = require('parse/node').Parse; const crypto = require('crypto'); diff --git a/src/Adapters/Auth/gcenter.js b/src/Adapters/Auth/gcenter.js index f70c254188..d53643df8b 100644 --- a/src/Adapters/Auth/gcenter.js +++ b/src/Adapters/Auth/gcenter.js @@ -1,195 +1,239 @@ -/* Apple Game Center Auth -https://developer.apple.com/documentation/gamekit/gklocalplayer/1515407-generateidentityverificationsign#discussion - -const authData = { - publicKeyUrl: 'https://valid.apple.com/public/timeout.cer', - timestamp: 1460981421303, - signature: 'PoDwf39DCN464B49jJCU0d9Y0J', - salt: 'saltST==', - bundleId: 'com.valid.app' - id: 'playerId', -}; -*/ - -const { Parse } = require('parse/node'); -const crypto = require('crypto'); -const https = require('https'); -const { pki } = require('node-forge'); -const ca = { cert: null, url: null }; -const cache = {}; // (publicKey -> cert) cache - -function verifyPublicKeyUrl(publicKeyUrl) { - try { - const regex = /^https:\/\/(?:[-_A-Za-z0-9]+\.){0,}apple\.com\/.*\.cer$/; - return regex.test(publicKeyUrl); - } catch (error) { - return false; +/** + * Parse Server authentication adapter for Apple Game Center. + * + * @class AppleGameCenterAdapter + * @param {Object} options - Configuration options for the adapter. + * @param {string} options.bundleId - Your Apple Game Center bundle ID. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @param {Object} authData - The authentication data provided by the client. + * @param {string} authData.id - The user ID obtained from Apple Game Center. + * @param {string} authData.publicKeyUrl - The public key URL obtained from Apple Game Center. + * @param {string} authData.timestamp - The timestamp obtained from Apple Game Center. + * @param {string} authData.signature - The signature obtained from Apple Game Center. + * @param {string} authData.salt - The salt obtained from Apple Game Center. + * @param {string} [authData.bundleId] - **[DEPRECATED]** The bundle ID obtained from Apple Game Center (required for insecure authentication). + * + * @description + * ## Parse Server Configuration + * The following `authData` fields are required: + * `id`, `publicKeyUrl`, `timestamp`, `signature`, and `salt`. These fields are validated against the configured `bundleId` for additional security. + * + * To configure Parse Server for Apple Game Center authentication, use the following structure: + * ```json + * { + * "auth": { + * "gcenter": { + * "bundleId": "com.valid.app" + * } + * } + * ``` + * + * ## Insecure Authentication (Not Recommended) + * The following `authData` fields are required for insecure authentication: + * `id`, `publicKeyUrl`, `timestamp`, `signature`, `salt`, and `bundleId` (**[DEPRECATED]**). This flow is insecure and poses potential security risks. + * + * To configure Parse Server for insecure authentication, use the following structure: + * ```json + * { + * "auth": { + * "gcenter": { + * "enableInsecureAuth": true + * } + * } + * ``` + * + * ### Deprecation Notice + * The `enableInsecureAuth` option and `authData.bundleId` parameter are deprecated and may be removed in future releases. Use secure authentication with the `bundleId` configured in the `options` object instead. + * + * + * @example Secure Authentication Example + * // Example authData for secure authentication: + * const authData = { + * gcenter: { + * id: "1234567", + * publicKeyUrl: "https://valid.apple.com/public/timeout.cer", + * timestamp: 1460981421303, + * salt: "saltST==", + * signature: "PoDwf39DCN464B49jJCU0d9Y0J" + * } + * }; + * + * @example Insecure Authentication Example (Not Recommended) + * // Example authData for insecure authentication: + * const authData = { + * gcenter: { + * id: "1234567", + * publicKeyUrl: "https://valid.apple.com/public/timeout.cer", + * timestamp: 1460981421303, + * salt: "saltST==", + * signature: "PoDwf39DCN464B49jJCU0d9Y0J", + * bundleId: "com.valid.app" // Deprecated. + * } + * }; + * + * @see {@link https://developer.apple.com/documentation/gamekit/gklocalplayer/3516283-fetchitems Apple Game Center Documentation} + */ +/* global BigInt */ + +import crypto from 'crypto'; +import { asn1, pki } from 'node-forge'; +import AuthAdapter from './AuthAdapter'; +class GameCenterAuth extends AuthAdapter { + constructor() { + super(); + this.ca = { cert: null, url: null }; + this.cache = {}; + this.bundleId = ''; } -} -function convertX509CertToPEM(X509Cert) { - const pemPreFix = '-----BEGIN CERTIFICATE-----\n'; - const pemPostFix = '-----END CERTIFICATE-----'; + validateOptions(options) { + if (!options) { + throw new Error('Game center auth options are required.'); + } - const base64 = X509Cert; - const certBody = base64.match(new RegExp('.{0,64}', 'g')).join('\n'); + if (!this.loadingPromise) { + this.loadingPromise = this.loadCertificate(options); + } - return pemPreFix + certBody + pemPostFix; -} + this.enableInsecureAuth = options.enableInsecureAuth; + this.bundleId = options.bundleId; -async function getAppleCertificate(publicKeyUrl) { - if (!verifyPublicKeyUrl(publicKeyUrl)) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - `Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` - ); - } - if (cache[publicKeyUrl]) { - return cache[publicKeyUrl]; - } - const url = new URL(publicKeyUrl); - const headOptions = { - hostname: url.hostname, - path: url.pathname, - method: 'HEAD', - }; - const cert_headers = await new Promise((resolve, reject) => - https.get(headOptions, res => resolve(res.headers)).on('error', reject) - ); - const validContentTypes = ['application/x-x509-ca-cert', 'application/pkix-cert']; - if ( - !validContentTypes.includes(cert_headers['content-type']) || - cert_headers['content-length'] == null || - cert_headers['content-length'] > 10000 - ) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - `Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` - ); + if (!this.enableInsecureAuth && !this.bundleId) { + throw new Error('bundleId is required for secure auth.'); + } } - const { certificate, headers } = await getCertificate(publicKeyUrl); - if (headers['cache-control']) { - const expire = headers['cache-control'].match(/max-age=([0-9]+)/); - if (expire) { - cache[publicKeyUrl] = certificate; - // we'll expire the cache entry later, as per max-age - setTimeout(() => { - delete cache[publicKeyUrl]; - }, parseInt(expire[1], 10) * 1000); + + async loadCertificate(options) { + const rootCertificateUrl = + options.rootCertificateUrl || + 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem'; + + if (this.ca.url === rootCertificateUrl) { + return rootCertificateUrl; } + + const { certificate, headers } = await this.fetchCertificate(rootCertificateUrl); + + if ( + headers.get('content-type') !== 'application/x-pem-file' || + !headers.get('content-length') || + parseInt(headers.get('content-length'), 10) > 10000 + ) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid rootCertificateURL.'); + } + + this.ca.cert = pki.certificateFromPem(certificate); + this.ca.url = rootCertificateUrl; + + return rootCertificateUrl; } - return verifyPublicKeyIssuer(certificate, publicKeyUrl); -} -function getCertificate(url, buffer) { - return new Promise((resolve, reject) => { - https - .get(url, res => { - const data = []; - res.on('data', chunk => { - data.push(chunk); - }); - res.on('end', () => { - if (buffer) { - resolve({ certificate: Buffer.concat(data), headers: res.headers }); - return; - } - let cert = ''; - for (const chunk of data) { - cert += chunk.toString('base64'); - } - const certificate = convertX509CertToPEM(cert); - resolve({ certificate, headers: res.headers }); - }); - }) - .on('error', reject); - }); -} + verifyPublicKeyUrl(publicKeyUrl) { + const regex = /^https:\/\/(?:[-_A-Za-z0-9]+\.){0,}apple\.com\/.*\.cer$/; + return regex.test(publicKeyUrl); + } -function convertTimestampToBigEndian(timestamp) { - const buffer = Buffer.alloc(8); + async fetchCertificate(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch certificate: ${url}`); + } - const high = ~~(timestamp / 0xffffffff); - const low = timestamp % (0xffffffff + 0x1); + const contentType = response.headers.get('content-type'); + const isPem = contentType?.includes('application/x-pem-file'); - buffer.writeUInt32BE(parseInt(high, 10), 0); - buffer.writeUInt32BE(parseInt(low, 10), 4); + if (isPem) { + const certificate = await response.text(); + return { certificate, headers: response.headers }; + } - return buffer; -} + const data = await response.arrayBuffer(); + const binaryData = Buffer.from(data); -function verifySignature(publicKey, authData) { - const verifier = crypto.createVerify('sha256'); - verifier.update(authData.playerId, 'utf8'); - verifier.update(authData.bundleId, 'utf8'); - verifier.update(convertTimestampToBigEndian(authData.timestamp)); - verifier.update(authData.salt, 'base64'); + const asn1Cert = asn1.fromDer(binaryData.toString('binary')); + const forgeCert = pki.certificateFromAsn1(asn1Cert); + const certificate = pki.certificateToPem(forgeCert); - if (!verifier.verify(publicKey, authData.signature, 'base64')) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Apple Game Center - invalid signature'); + return { certificate, headers: response.headers }; } -} -function verifyPublicKeyIssuer(cert, publicKeyUrl) { - const publicKeyCert = pki.certificateFromPem(cert); - if (!ca.cert) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.' - ); + async getAppleCertificate(publicKeyUrl) { + if (!this.verifyPublicKeyUrl(publicKeyUrl)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `Invalid publicKeyUrl: ${publicKeyUrl}`); + } + + if (this.cache[publicKeyUrl]) { + return this.cache[publicKeyUrl]; + } + + const { certificate, headers } = await this.fetchCertificate(publicKeyUrl); + const cacheControl = headers.get('cache-control'); + const expire = cacheControl?.match(/max-age=([0-9]+)/); + + this.verifyPublicKeyIssuer(certificate, publicKeyUrl); + + if (expire) { + this.cache[publicKeyUrl] = certificate; + setTimeout(() => delete this.cache[publicKeyUrl], parseInt(expire[1], 10) * 1000); + } + + return certificate; } - try { - if (!ca.cert.verify(publicKeyCert)) { + + verifyPublicKeyIssuer(cert, publicKeyUrl) { + const publicKeyCert = pki.certificateFromPem(cert); + + if (!this.ca.cert) { throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, - `Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` + 'Root certificate is invalid or missing.' ); } - } catch (e) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - `Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` - ); - } - return cert; -} -// Returns a promise that fulfills if this user id is valid. -async function validateAuthData(authData) { - if (!authData.id) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Apple Game Center - authData id missing'); + if (!this.ca.cert.verify(publicKeyCert)) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `Invalid publicKeyUrl: ${publicKeyUrl}`); + } } - authData.playerId = authData.id; - const publicKey = await getAppleCertificate(authData.publicKeyUrl); - return verifySignature(publicKey, authData); -} -// Returns a promise that fulfills if this app id is valid. -async function validateAppId(appIds, authData, options = {}) { - if (!options.rootCertificateUrl) { - options.rootCertificateUrl = - 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem'; + verifySignature(publicKey, authData) { + const bundleId = this.bundleId || (this.enableInsecureAuth && authData.bundleId); + + const verifier = crypto.createVerify('sha256'); + verifier.update(Buffer.from(authData.id, 'utf8')); + verifier.update(Buffer.from(bundleId, 'utf8')); + verifier.update(this.convertTimestampToBigEndian(authData.timestamp)); + verifier.update(Buffer.from(authData.salt, 'base64')); + + if (!verifier.verify(publicKey, authData.signature, 'base64')) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid signature.'); + } } - if (ca.url === options.rootCertificateUrl) { - return; + + async validateAuthData(authData) { + + const requiredKeys = ['id', 'publicKeyUrl', 'timestamp', 'signature', 'salt']; + if (this.enableInsecureAuth) { + requiredKeys.push('bundleId'); + } + + for (const key of requiredKeys) { + if (!authData[key]) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `AuthData ${key} is missing.`); + } + } + + await this.loadingPromise; + + const publicKey = await this.getAppleCertificate(authData.publicKeyUrl); + this.verifySignature(publicKey, authData); } - const { certificate, headers } = await getCertificate(options.rootCertificateUrl, true); - if ( - headers['content-type'] !== 'application/x-pem-file' || - headers['content-length'] == null || - headers['content-length'] > 10000 - ) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.' - ); + + convertTimestampToBigEndian(timestamp) { + const buffer = Buffer.alloc(8); + buffer.writeBigUInt64BE(BigInt(timestamp)); + return buffer; } - ca.cert = pki.certificateFromPem(certificate); - ca.url = options.rootCertificateUrl; } -module.exports = { - validateAppId, - validateAuthData, - cache, -}; +export default new GameCenterAuth(); diff --git a/src/Adapters/Auth/github.js b/src/Adapters/Auth/github.js index 75233d53fd..7aa842f03b 100644 --- a/src/Adapters/Auth/github.js +++ b/src/Adapters/Auth/github.js @@ -1,35 +1,127 @@ -// Helper functions for accessing the github API. -var Parse = require('parse/node').Parse; -const httpsRequest = require('./httpsRequest'); - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request('user', authData.access_token).then(data => { - if (data && data.id == authData.id) { - return; +/** + * Parse Server authentication adapter for GitHub. + * @class GitHubAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - The GitHub App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - The GitHub App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @param {Object} authData - The authentication data provided by the client. + * @param {string} authData.code - The authorization code from GitHub. Required for secure authentication. + * @param {string} [authData.id] - **[DEPRECATED]** The GitHub user ID (required for insecure authentication). + * @param {string} [authData.access_token] - **[DEPRECATED]** The GitHub access token (required for insecure authentication). + * + * @description + * ## Parse Server Configuration + * * To configure Parse Server for GitHub authentication, use the following structure: + * ```json + * { + * "auth": { + * "github": { + * "clientId": "12345", + * "clientSecret": "abcde" + * } + * } + * ``` + * + * The GitHub adapter exchanges the `authData.code` provided by the client for an access token using GitHub's OAuth API. The following `authData` field is required: + * - `code` + * + * ## Insecure Authentication (Not Recommended) + * Insecure authentication uses the `authData.id` and `authData.access_token` provided by the client. This flow is insecure, deprecated, and poses potential security risks. The following `authData` fields are required: + * - `id` (**[DEPRECATED]**): The GitHub user ID. + * - `access_token` (**[DEPRECATED]**): The GitHub access token. + * To configure Parse Server for insecure authentication, use the following structure: + * ```json + * { + * "auth": { + * "github": { + * "enableInsecureAuth": true + * } + * } + * ``` + * + * ### Deprecation Notice + * The `enableInsecureAuth` option and insecure `authData` fields (`id`, `access_token`) are deprecated and will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`. + * + * @example Secure Authentication Example + * // Example authData for secure authentication: + * const authData = { + * github: { + * code: "abc123def456ghi789" + * } + * }; + * + * @example Insecure Authentication Example (Not Recommended) + * // Example authData for insecure authentication: + * const authData = { + * github: { + * id: "1234567", + * access_token: "abc123def456ghi789" // Deprecated. + * } + * }; + * + * @note `enableInsecureAuth` will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`. + * @note Secure authentication exchanges the `code` provided by the client for an access token using GitHub's OAuth API. + * + * @see {@link https://docs.github.com/en/developers/apps/authorizing-oauth-apps GitHub OAuth Documentation} + */ + +import BaseCodeAuthAdapter from './BaseCodeAuthAdapter'; +class GitHubAdapter extends BaseCodeAuthAdapter { + constructor() { + super('GitHub'); + } + async getAccessTokenFromCode(authData) { + const tokenUrl = 'https://github.com/login/oauth/access_token'; + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + code: authData.code, + }), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `Failed to exchange code for token: ${response.statusText}`); } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Github auth is invalid for this user.'); - }); -} -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} + const data = await response.json(); + if (data.error) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, data.error_description || data.error); + } + + return data.access_token; + } + + async getUserFromAccessToken(accessToken) { + const userApiUrl = 'https://api.github.com/user'; + const response = await fetch(userApiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `Failed to fetch GitHub user: ${response.statusText}`); + } + + const userData = await response.json(); + if (!userData.id || !userData.login) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Invalid GitHub user data received.'); + } + + return userData; + } -// A promisey wrapper for api requests -function request(path, access_token) { - return httpsRequest.get({ - host: 'api.github.com', - path: '/' + path, - headers: { - Authorization: 'bearer ' + access_token, - 'User-Agent': 'parse-server', - }, - }); } -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData, -}; +export default new GitHubAdapter(); + diff --git a/src/Adapters/Auth/google.js b/src/Adapters/Auth/google.js index 755eb3c673..d7f90956d9 100644 --- a/src/Adapters/Auth/google.js +++ b/src/Adapters/Auth/google.js @@ -1,3 +1,47 @@ +/** + * Parse Server authentication adapter for Google. + * + * @class GoogleAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Google application Client ID. Required for authentication. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Google authentication, use the following structure: + * ```json + * { + * "auth": { + * "google": { + * "clientId": "your-client-id" + * } + * } + * } + * ``` + * + * 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 + * ```json + * { + * "google": { + * "id": "1234567", + * "id_token": "xxxxx.yyyyy.zzzzz", + * "access_token": "abc123def456ghi789" + * } + * } + * ``` + * + * ## 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. + * + * @see {@link https://developers.google.com/identity/sign-in/web/backend-auth Google Authentication Documentation} + */ + 'use strict'; // Helper functions for accessing the google API. diff --git a/src/Adapters/Auth/gpgames.js b/src/Adapters/Auth/gpgames.js index 4462a7897d..01b1cec7cf 100644 --- a/src/Adapters/Auth/gpgames.js +++ b/src/Adapters/Auth/gpgames.js @@ -1,33 +1,139 @@ -/* Google Play Game Services -https://developers.google.com/games/services/web/api/players/get - -const authData = { - id: 'playerId', - access_token: 'token', -}; -*/ -const { Parse } = require('parse/node'); -const httpsRequest = require('./httpsRequest'); - -// Returns a promise that fulfills if this user id is valid. -async function validateAuthData(authData) { - const response = await httpsRequest.get( - `https://www.googleapis.com/games/v1/players/${authData.id}?access_token=${authData.access_token}` - ); - if (!(response && response.playerId === authData.id)) { - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Google Play Games Services - authData is invalid for this user.' - ); +/** + * Parse Server authentication adapter for Google Play Games Services. + * + * @class GooglePlayGamesServicesAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Google Play Games Services App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your Google Play Games Services App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Google Play Games Services authentication, use the following structure: + * ```json + * { + * "auth": { + * "gpgames": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "gpgames": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "gpgames": { + * "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + * "redirect_uri": "https://example.com/callback" + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "gpgames": { + * "id": "123456789", + * "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - `enableInsecureAuth` is **not recommended** and may be removed in future versions. Use secure authentication with `code` and `redirect_uri`. + * - Secure authentication exchanges the `code` provided by the client for an access token using Google Play Games Services' OAuth API. + * + * @see {@link https://developers.google.com/games/services/console/enabling Google Play Games Services Authentication Documentation} + */ + +import BaseCodeAuthAdapter from './BaseCodeAuthAdapter'; +class GooglePlayGamesServicesAdapter extends BaseCodeAuthAdapter { + constructor() { + super("gpgames"); + } + + async getAccessTokenFromCode(authData) { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + code: authData.code, + redirect_uri: authData.redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!response.ok) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `Failed to exchange code for token: ${response.statusText}` + ); + } + + const data = await response.json(); + if (data.error) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + data.error_description || data.error + ); + } + + return data.access_token; + } + + async getUserFromAccessToken(accessToken, authData) { + const userApiUrl = `https://www.googleapis.com/games/v1/players/${authData.id}`; + const response = await fetch(userApiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `Failed to fetch Google Play Games Services user: ${response.statusText}` + ); + } + + const userData = await response.json(); + if (!userData.playerId || userData.playerId !== authData.id) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + 'Invalid Google Play Games Services user data received.' + ); + } + + return { + id: userData.playerId + }; } -} -// Returns a promise that fulfills if this app id is valid. -function validateAppId() { - return Promise.resolve(); } -module.exports = { - validateAppId, - validateAuthData, -}; +export default new GooglePlayGamesServicesAdapter(); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 6a550c2aa4..7f5581da49 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -3,30 +3,31 @@ import Parse from 'parse/node'; import AuthAdapter from './AuthAdapter'; const apple = require('./apple'); -const gcenter = require('./gcenter'); -const gpgames = require('./gpgames'); +const digits = require('./twitter'); // digits tokens are validated by twitter const facebook = require('./facebook'); -const instagram = require('./instagram'); -const linkedin = require('./linkedin'); -const meetup = require('./meetup'); -import mfa from './mfa'; +import gcenter from './gcenter'; +import github from './github'; const google = require('./google'); -const github = require('./github'); -const twitter = require('./twitter'); -const spotify = require('./spotify'); -const digits = require('./twitter'); // digits tokens are validated by twitter -const janrainengage = require('./janrainengage'); +import gpgames from './gpgames'; +import instagram from './instagram'; const janraincapture = require('./janraincapture'); -const line = require('./line'); -const vkontakte = require('./vkontakte'); -const qq = require('./qq'); -const wechat = require('./wechat'); -const weibo = require('./weibo'); -const oauth2 = require('./oauth2'); -const phantauth = require('./phantauth'); -const microsoft = require('./microsoft'); +const janrainengage = require('./janrainengage'); const keycloak = require('./keycloak'); const ldap = require('./ldap'); +import line from './line'; +import linkedin from './linkedin'; +const meetup = require('./meetup'); +import mfa from './mfa'; +import microsoft from './microsoft'; +import oauth2 from './oauth2'; +const phantauth = require('./phantauth'); +import qq from './qq'; +import spotify from './spotify'; +import twitter from './twitter'; +const vkontakte = require('./vkontakte'); +import wechat from './wechat'; +import weibo from './weibo'; + const anonymous = { validateAuthData: () => { @@ -241,9 +242,9 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { }; const result = afterFind.call( adapter, - requestObject, authData[provider], - providerOptions + providerOptions, + requestObject, ); if (result) { authData[provider] = result; diff --git a/src/Adapters/Auth/instagram.js b/src/Adapters/Auth/instagram.js index 521796de63..55cb357f6a 100644 --- a/src/Adapters/Auth/instagram.js +++ b/src/Adapters/Auth/instagram.js @@ -1,27 +1,121 @@ -// Helper functions for accessing the instagram API. -var Parse = require('parse/node').Parse; -const httpsRequest = require('./httpsRequest'); -const defaultURL = 'https://graph.instagram.com/'; - -// Returns a promise that fulfills if this user id is valid. -function validateAuthData(authData) { - const apiURL = authData.apiURL || defaultURL; - const path = `${apiURL}me?fields=id&access_token=${authData.access_token}`; - return httpsRequest.get(path).then(response => { - const user = response.data ? response.data : response; - if (user && user.id == authData.id) { - return; +/** + * Parse Server authentication adapter for Instagram. + * + * @class InstagramAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Instagram App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your Instagram App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Instagram authentication, use the following structure: + * ```json + * { + * "auth": { + * "instagram": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "instagram": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Deprecated)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "instagram": { + * "code": "lmn789opq012rst345uvw", + * "redirect_uri": "https://example.com/callback" + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Deprecated) + * ```json + * { + * "instagram": { + * "id": "1234567", + * "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc" + * } + * } + * ``` + * + * ## Notes + * - `enableInsecureAuth` is **deprecated** and will be removed in future versions. Use secure authentication with `code` and `redirect_uri`. + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Instagram's OAuth flow. + * + * @see {@link https://developers.facebook.com/docs/instagram-basic-display-api/getting-started Instagram Basic Display API - Getting Started} + */ + + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class InstagramAdapter extends BaseAuthCodeAdapter { + constructor() { + super('Instagram'); + } + + async getAccessTokenFromCode(authData) { + const response = await fetch('https://api.instagram.com/oauth/access_token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + redirect_uri: this.redirectUri, + code: authData.code + }) + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.'); + } + + const data = await response.json(); + if (data.error) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, data.error_description || data.error); + } + + return data.access_token; + } + + async getUserFromAccessToken(accessToken, authData) { + const defaultURL = 'https://graph.instagram.com/'; + const apiURL = authData.apiURL || defaultURL; + const path = `${apiURL}me?fields=id&access_token=${accessToken}`; + + const response = await fetch(path); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.'); + } + + const user = await response.json(); + if (user?.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram auth is invalid for this user.'); + } + + return { + id: user.id, } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram auth is invalid for this user.'); - }); -} -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); + } } -module.exports = { - validateAppId, - validateAuthData, -}; +export default new InstagramAdapter(); diff --git a/src/Adapters/Auth/janraincapture.js b/src/Adapters/Auth/janraincapture.js index 01670e84aa..ca55df7da8 100644 --- a/src/Adapters/Auth/janraincapture.js +++ b/src/Adapters/Auth/janraincapture.js @@ -1,3 +1,48 @@ +/** + * Parse Server authentication adapter for Janrain Capture API. + * + * @class JanrainCapture + * @param {Object} options - The adapter configuration options. + * @param {String} options.janrain_capture_host - The Janrain Capture API host. + * + * @param {Object} authData - The authentication data provided by the client. + * @param {String} authData.id - The Janrain Capture user ID. + * @param {String} authData.access_token - The Janrain Capture access token. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Janrain Capture authentication, use the following structure: + * ```json + * { + * "auth": { + * "janrain": { + * "janrain_capture_host": "your-janrain-capture-host" + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - `id`: The Janrain Capture user ID. + * - `access_token`: An authorized Janrain Capture access token for the user. + * + * ## Auth Payload Example + * ```json + * { + * "janrain": { + * "id": "user's Janrain Capture ID as a string", + * "access_token": "an authorized Janrain Capture access token for the user" + * } + * } + * ``` + * + * ## Notes + * Parse Server validates the provided `authData` using the Janrain Capture API. + * + * @see {@link https://docs.janrain.com/api/registration/entity/#entity Janrain Capture API Documentation} + */ + + // Helper functions for accessing the Janrain Capture API. var Parse = require('parse/node').Parse; var querystring = require('querystring'); diff --git a/src/Adapters/Auth/janrainengage.js b/src/Adapters/Auth/janrainengage.js index 6e1589e724..782cbb121a 100644 --- a/src/Adapters/Auth/janrainengage.js +++ b/src/Adapters/Auth/janrainengage.js @@ -2,9 +2,18 @@ var httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; var querystring = require('querystring'); +import Config from '../../Config'; +import Deprecator from '../../Deprecator/Deprecator'; // Returns a promise that fulfills iff this user id is valid. function validateAuthData(authData, options) { + const config = Config.get(Parse.applicationId); + + Deprecator.logRuntimeDeprecation({ usage: 'janrainengage adapter' }); + if (!config?.auth?.janrainengage?.enableInsecureAuth || !config.enableInsecureAuthAdapters) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'janrainengage adapter only works with enableInsecureAuth: true'); + } + return apiRequest(options.api_key, authData.auth_token).then(data => { //successful response will have a "stat" (status) of 'ok' and a profile node with an identifier //see: http://developers.janrain.com/overview/social-login/identity-providers/user-profile-data/#normalized-user-profile-data diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js index fd72e58e85..457faeeaed 100644 --- a/src/Adapters/Auth/keycloak.js +++ b/src/Adapters/Auth/keycloak.js @@ -1,37 +1,70 @@ -/* - # Parse Server Keycloak Authentication - - ## Keycloak `authData` - - ``` - { - "keycloak": { - "access_token": "access token you got from keycloak JS client authentication", - "id": "the id retrieved from client authentication in Keycloak", - "roles": ["the roles retrieved from client authentication in Keycloak"], - "groups": ["the groups retrieved from client authentication in Keycloak"] - } - } - ``` - - The authentication module will test if the authData is the same as the - userinfo oauth call, comparing the attributes - - Copy the JSON config file generated on Keycloak (https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter) - and paste it inside of a folder (Ex.: `auth/keycloak.json`) in your server. - - The options passed to Parse server: - - ``` - { - auth: { - keycloak: { - config: require(`./auth/keycloak.json`) - } - } - } - ``` -*/ +/** + * Parse Server authentication adapter for Keycloak. + * + * @class KeycloakAdapter + * @param {Object} options - The adapter configuration options. + * @param {Object} options.config - The Keycloak configuration object, typically loaded from a JSON file. + * @param {String} options.config.auth-server-url - The Keycloak authentication server URL. + * @param {String} options.config.realm - The Keycloak realm name. + * @param {String} options.config.client-id - The Keycloak client ID. + * + * @param {Object} authData - The authentication data provided by the client. + * @param {String} authData.access_token - The Keycloak access token retrieved during client authentication. + * @param {String} authData.id - The user ID retrieved from Keycloak during client authentication. + * @param {Array} [authData.roles] - The roles assigned to the user in Keycloak (optional). + * @param {Array} [authData.groups] - The groups assigned to the user in Keycloak (optional). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Keycloak authentication, use the following structure: + * ```javascript + * { + * "auth": { + * "keycloak": { + * "config": require('./auth/keycloak.json') + * } + * } + * } + * ``` + * Ensure the `keycloak.json` configuration file is generated from Keycloak's setup guide and includes: + * - `auth-server-url`: The Keycloak authentication server URL. + * - `realm`: The Keycloak realm name. + * - `client-id`: The Keycloak client ID. + * + * ## Auth Data + * The adapter requires the following `authData` fields: + * - `access_token`: The Keycloak access token retrieved during client authentication. + * - `id`: The user ID retrieved from Keycloak during client authentication. + * - `roles` (optional): The roles assigned to the user in Keycloak. + * - `groups` (optional): The groups assigned to the user in Keycloak. + * + * ## Auth Payload Example + * ### Example Auth Data + * ```json + * { + * "keycloak": { + * "access_token": "an authorized Keycloak access token for the user", + * "id": "user's Keycloak ID as a string", + * "roles": ["admin", "user"], + * "groups": ["group1", "group2"] + * } + * } + * ``` + * + * ## Notes + * - Parse Server validates the provided `authData` by making a `userinfo` call to Keycloak and ensures the attributes match those returned by Keycloak. + * + * ## Keycloak Configuration + * To configure Keycloak, copy the JSON configuration file generated from Keycloak's setup guide: + * - [Keycloak Securing Apps Documentation](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter) + * + * Place the configuration file on your server, for example: + * - `auth/keycloak.json` + * + * For more information on Keycloak authentication, see: + * - [Securing Apps Documentation](https://www.keycloak.org/docs/latest/securing_apps/) + * - [Server Administration Documentation](https://www.keycloak.org/docs/latest/server_admin/) + */ const { Parse } = require('parse/node'); const httpsRequest = require('./httpsRequest'); diff --git a/src/Adapters/Auth/ldap.js b/src/Adapters/Auth/ldap.js index 8ea735698f..5f6a88a7b5 100644 --- a/src/Adapters/Auth/ldap.js +++ b/src/Adapters/Auth/ldap.js @@ -1,3 +1,78 @@ +/** + * Parse Server authentication adapter for LDAP. + * + * @class LDAP + * @param {Object} options - The adapter configuration options. + * @param {String} options.url - The LDAP server URL. Must start with `ldap://` or `ldaps://`. + * @param {String} options.suffix - The LDAP suffix for user distinguished names (DN). + * @param {String} [options.dn] - The distinguished name (DN) template for user authentication. Replace `{{id}}` with the username. + * @param {Object} [options.tlsOptions] - Options for LDAPS TLS connections. + * @param {String} [options.groupCn] - The common name (CN) of the group to verify user membership. + * @param {String} [options.groupFilter] - The LDAP search filter for groups, with `{{id}}` replaced by the username. + * + * @param {Object} authData - The authentication data provided by the client. + * @param {String} authData.id - The user's LDAP username. + * @param {String} authData.password - The user's LDAP password. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for LDAP authentication, use the following structure: + * ```javascript + * { + * auth: { + * ldap: { + * url: 'ldaps://ldap.example.com', + * suffix: 'ou=users,dc=example,dc=com', + * groupCn: 'admins', + * groupFilter: '(memberUid={{id}})', + * tlsOptions: { + * rejectUnauthorized: false + * } + * } + * } + * } + * ``` + * + * ## Authentication Process + * 1. Validates the provided `authData` using an LDAP bind operation. + * 2. Optionally, verifies that the user belongs to a specific group by performing an LDAP search using the provided `groupCn` or `groupFilter`. + * + * ## Auth Payload + * The adapter requires the following `authData` fields: + * - `id`: The user's LDAP username. + * - `password`: The user's LDAP password. + * + * ### Example Auth Payload + * ```json + * { + * "ldap": { + * "id": "jdoe", + * "password": "password123" + * } + * } + * ``` + * + * @example Configuration Example + * // Example Parse Server configuration: + * const config = { + * auth: { + * ldap: { + * url: 'ldaps://ldap.example.com', + * suffix: 'ou=users,dc=example,dc=com', + * groupCn: 'admins', + * groupFilter: '(memberUid={{id}})', + * tlsOptions: { + * rejectUnauthorized: false + * } + * } + * } + * }; + * + * @see {@link https://ldap.com/ LDAP Basics} + * @see {@link https://ldap.com/ldap-filters/ LDAP Filters} + */ + + const ldapjs = require('ldapjs'); const Parse = require('parse/node').Parse; diff --git a/src/Adapters/Auth/line.js b/src/Adapters/Auth/line.js index d773323f70..7551db817d 100644 --- a/src/Adapters/Auth/line.js +++ b/src/Adapters/Auth/line.js @@ -1,36 +1,143 @@ -// Helper functions for accessing the line API. -var Parse = require('parse/node').Parse; -const httpsRequest = require('./httpsRequest'); - -// Returns a promise that fulfills if this user id is valid. -function validateAuthData(authData) { - return request('profile', authData.access_token).then(response => { - if (response && response.userId && response.userId === authData.id) { - return; +/** + * Parse Server authentication adapter for Line. + * + * @class LineAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Line App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your Line App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Line authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "line": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "line": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "line": { + * "code": "xxxxxxxxx", + * "redirect_uri": "https://example.com/callback" + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "line": { + * "id": "1234567", + * "access_token": "xxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - `enableInsecureAuth` is **not recommended** and will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`. + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Line's OAuth flow. + * + * @see {@link https://developers.line.biz/en/docs/line-login/integrate-line-login/ Line Login Documentation} + */ + +import BaseCodeAuthAdapter from './BaseCodeAuthAdapter'; + +class LineAdapter extends BaseCodeAuthAdapter { + constructor() { + super('Line'); + } + + async getAccessTokenFromCode(authData) { + if (!authData.code) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Line auth is invalid for this user.' + ); } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Line auth is invalid for this user.'); - }); -} -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} + const tokenUrl = 'https://api.line.me/oauth2/v2.1/token'; + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + redirect_uri: authData.redirect_uri, + code: authData.code, + }), + }); + + if (!response.ok) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Failed to exchange code for token: ${response.statusText}` + ); + } + + const data = await response.json(); + if (data.error) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + data.error_description || data.error + ); + } + + return data.access_token; + } + + async getUserFromAccessToken(accessToken) { + const userApiUrl = 'https://api.line.me/v2/profile'; + const response = await fetch(userApiUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Failed to fetch Line user: ${response.statusText}` + ); + } + + const userData = await response.json(); + if (!userData?.userId) { + throw new Parse.Error( + Parse.Error.VALIDATION_ERROR, + 'Invalid Line user data received.' + ); + } -// A promisey wrapper for api requests -function request(path, access_token) { - var options = { - host: 'api.line.me', - path: '/v2/' + path, - method: 'GET', - headers: { - Authorization: 'Bearer ' + access_token, - }, - }; - return httpsRequest.get(options); + return userData; + } } -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData, -}; +export default new LineAdapter(); diff --git a/src/Adapters/Auth/linkedin.js b/src/Adapters/Auth/linkedin.js index 4faa2eb2a9..2d74166783 100644 --- a/src/Adapters/Auth/linkedin.js +++ b/src/Adapters/Auth/linkedin.js @@ -1,40 +1,115 @@ -// Helper functions for accessing the linkedin API. -var Parse = require('parse/node').Parse; -const httpsRequest = require('./httpsRequest'); +/** + * Parse Server authentication adapter for LinkedIn. + * + * @class LinkedInAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your LinkedIn App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your LinkedIn App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for LinkedIn authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "linkedin": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "linkedin": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`, and optionally `is_mobile_sdk`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`, and optionally `is_mobile_sdk`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "linkedin": { + * "code": "lmn789opq012rst345uvw", + * "redirect_uri": "https://your-redirect-uri.com/callback", + * "is_mobile_sdk": true + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "linkedin": { + * "id": "7654321", + * "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc", + * "is_mobile_sdk": true + * } + * } + * ``` + * + * ## Notes + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using LinkedIn's OAuth API. + * - Insecure authentication validates the user ID and access token directly, bypassing OAuth flows. This method is **not recommended** and may introduce security vulnerabilities. + * - `enableInsecureAuth` is **deprecated** and may be removed in future versions. + * + * @see {@link https://learn.microsoft.com/en-us/linkedin/shared/authentication/authentication LinkedIn Authentication Documentation} + */ -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request('me', authData.access_token, authData.is_mobile_sdk).then(data => { - if (data && data.id == authData.id) { - return; +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class LinkedInAdapter extends BaseAuthCodeAdapter { + constructor() { + super('LinkedIn'); + } + async getUserFromAccessToken(access_token, authData) { + const response = await fetch('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer ${access_token}`, + 'x-li-format': 'json', + 'x-li-src': authData?.is_mobile_sdk ? 'msdk' : undefined, + }, + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn API request failed.'); } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Linkedin auth is invalid for this user.'); - }); -} -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} + return response.json(); + } + + async getAccessTokenFromCode(authData) { + const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authData.code, + redirect_uri: authData.redirect_uri, + client_id: this.clientId, + client_secret: this.clientSecret, + }), + }); -// A promisey wrapper for api requests -function request(path, access_token, is_mobile_sdk) { - var headers = { - Authorization: 'Bearer ' + access_token, - 'x-li-format': 'json', - }; + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn API request failed.'); + } - if (is_mobile_sdk) { - headers['x-li-src'] = 'msdk'; + const json = await response.json(); + return json.access_token; } - return httpsRequest.get({ - host: 'api.linkedin.com', - path: '/v2/' + path, - headers: headers, - }); } -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData, -}; +export default new LinkedInAdapter(); diff --git a/src/Adapters/Auth/meetup.js b/src/Adapters/Auth/meetup.js index 93dc1d48ad..33ec63d36e 100644 --- a/src/Adapters/Auth/meetup.js +++ b/src/Adapters/Auth/meetup.js @@ -1,15 +1,24 @@ // Helper functions for accessing the meetup API. var Parse = require('parse/node').Parse; const httpsRequest = require('./httpsRequest'); +import Config from '../../Config'; +import Deprecator from '../../Deprecator/Deprecator'; // Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request('member/self', authData.access_token).then(data => { - if (data && data.id == authData.id) { - return; - } +async function validateAuthData(authData) { + const config = Config.get(Parse.applicationId); + const meetupConfig = config.auth.meetup; + + Deprecator.logRuntimeDeprecation({ usage: 'meetup adapter' }); + + if (!meetupConfig?.enableInsecureAuth) { + throw new Parse.Error('Meetup only works with enableInsecureAuth: true'); + } + + const data = await request('member/self', authData.access_token); + if (data?.id !== authData.id) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Meetup auth is invalid for this user.'); - }); + } } // Returns a promise that fulfills iff this app id is valid. diff --git a/src/Adapters/Auth/mfa.js b/src/Adapters/Auth/mfa.js index a88eda99e7..df2fa73d02 100644 --- a/src/Adapters/Auth/mfa.js +++ b/src/Adapters/Auth/mfa.js @@ -1,3 +1,81 @@ +/** + * Parse Server authentication adapter for Multi-Factor Authentication (MFA). + * + * @class MFAAdapter + * @param {Object} options - The adapter options. + * @param {Array} options.options - Supported MFA methods. Must include `"SMS"` or `"TOTP"`. + * @param {Number} [options.digits=6] - The number of digits for the one-time password (OTP). Must be between 4 and 10. + * @param {Number} [options.period=30] - The validity period of the OTP in seconds. Must be greater than 10. + * @param {String} [options.algorithm="SHA1"] - The algorithm used for TOTP generation. Defaults to `"SHA1"`. + * @param {Function} [options.sendSMS] - A callback function for sending SMS OTPs. Required if `"SMS"` is included in `options`. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for MFA, use the following structure: + * ```javascript + * { + * auth: { + * mfa: { + * options: ["SMS", "TOTP"], + * digits: 6, + * period: 30, + * algorithm: "SHA1", + * sendSMS: (token, mobile) => { + * // Send the SMS using your preferred SMS provider. + * console.log(`Sending SMS to ${mobile} with token: ${token}`); + * } + * } + * } + * } + * ``` + * + * ## MFA Methods + * - **SMS**: + * - Requires a valid mobile number. + * - Sends a one-time password (OTP) via SMS for login or verification. + * - Uses the `sendSMS` callback for sending the OTP. + * + * - **TOTP**: + * - Requires a secret key for setup. + * - Validates the user's OTP against a time-based one-time password (TOTP) generated using the secret key. + * - Supports configurable digits, period, and algorithm for TOTP generation. + * + * ## MFA Payload + * The adapter requires the following `authData` fields: + * - **For SMS-based MFA**: + * - `mobile`: The user's mobile number (required for setup). + * - `token`: The OTP provided by the user for login or verification. + * - **For TOTP-based MFA**: + * - `secret`: The TOTP secret key for the user (required for setup). + * - `token`: The OTP provided by the user for login or verification. + * + * ## Example Payloads + * ### SMS Setup Payload + * ```json + * { + * "mobile": "+1234567890" + * } + * ``` + * + * ### TOTP Setup Payload + * ```json + * { + * "secret": "BASE32ENCODEDSECRET", + * "token": "123456" + * } + * ``` + * + * ### Login Payload + * ```json + * { + * "token": "123456" + * } + * ``` + * + * @see {@link https://en.wikipedia.org/wiki/Time-based_One-Time_Password_algorithm Time-based One-Time Password Algorithm (TOTP)} + * @see {@link https://tools.ietf.org/html/rfc6238 RFC 6238: TOTP: Time-Based One-Time Password Algorithm} + */ + import { TOTP, Secret } from 'otpauth'; import { randomString } from '../../cryptoUtils'; import AuthAdapter from './AuthAdapter'; @@ -113,7 +191,7 @@ class MFAAdapter extends AuthAdapter { } throw 'Invalid MFA data'; } - afterFind(req, authData) { + afterFind(authData, options, req) { if (req.master) { return; } diff --git a/src/Adapters/Auth/microsoft.js b/src/Adapters/Auth/microsoft.js index 9f4f5c4ea4..a2e17ef4a5 100644 --- a/src/Adapters/Auth/microsoft.js +++ b/src/Adapters/Auth/microsoft.js @@ -1,37 +1,109 @@ -// Helper functions for accessing the microsoft graph API. -var Parse = require('parse/node').Parse; -const httpsRequest = require('./httpsRequest'); +/** + * Parse Server authentication adapter for Microsoft. + * + * @class MicrosoftAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Microsoft App Client ID. Required for secure authentication. + * @param {string} options.clientSecret - Your Microsoft App Client Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Microsoft authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "microsoft": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "microsoft": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "microsoft": { + * "code": "lmn789opq012rst345uvw", + * "redirect_uri": "https://your-redirect-uri.com/callback" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "microsoft": { + * "id": "7654321", + * "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc" + * } + * } + * ``` + * + * ## Notes + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Microsoft's OAuth API. + * - **Insecure authentication** validates the user ID and access token directly, bypassing OAuth flows (not recommended). This method is deprecated and may be removed in future versions. + * + * @see {@link https://docs.microsoft.com/en-us/graph/auth/auth-concepts Microsoft Authentication Documentation} + */ -// Returns a promise that fulfills if this user mail is valid. -function validateAuthData(authData) { - return request('me', authData.access_token).then(response => { - if (response && response.id && response.id == authData.id) { - return; +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class MicrosoftAdapter extends BaseAuthCodeAdapter { + constructor() { + super('Microsoft'); + } + async getUserFromAccessToken(access_token) { + const userResponse = await fetch('https://graph.microsoft.com/v1.0/me', { + headers: { + Authorization: 'Bearer ' + access_token, + }, + }); + + if (!userResponse.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Microsoft API request failed.'); } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Microsoft Graph auth is invalid for this user.' - ); - }); -} -// Returns a promise that fulfills if this app id is valid. -function validateAppId() { - return Promise.resolve(); -} + return userResponse.json(); + } + + async getAccessTokenFromCode(authData) { + const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + redirect_uri: authData.redirect_uri, + code: authData.code, + }), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Microsoft API request failed.'); + } -// A promisey wrapper for api requests -function request(path, access_token) { - return httpsRequest.get({ - host: 'graph.microsoft.com', - path: '/v1.0/' + path, - headers: { - Authorization: 'Bearer ' + access_token, - }, - }); + const json = await response.json(); + return json.access_token; + } } -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData, -}; +export default new MicrosoftAdapter(); diff --git a/src/Adapters/Auth/oauth2.js b/src/Adapters/Auth/oauth2.js index ba1fe7bc4f..1498f8bf4e 100644 --- a/src/Adapters/Auth/oauth2.js +++ b/src/Adapters/Auth/oauth2.js @@ -1,137 +1,121 @@ -/* - * This auth adapter is based on the OAuth 2.0 Token Introspection specification. - * See RFC 7662 for details (https://tools.ietf.org/html/rfc7662). - * It's purpose is to validate OAuth2 access tokens using the OAuth2 provider's - * token introspection endpoint (if implemented by the provider). +/** + * Parse Server authentication adapter for OAuth2 Token Introspection. * - * The adapter accepts the following config parameters: + * @class OAuth2Adapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.tokenIntrospectionEndpointUrl - The URL of the token introspection endpoint. Required. + * @param {boolean} options.oauth2 - Indicates that the request should be handled by the OAuth2 adapter. Required. + * @param {string} [options.useridField] - The field in the introspection response that contains the user ID. Optional. + * @param {string} [options.appidField] - The field in the introspection response that contains the app ID. Optional. + * @param {string[]} [options.appIds] - List of allowed app IDs. Required if `appidField` is defined. + * @param {string} [options.authorizationHeader] - The Authorization header value for the introspection request. Optional. * - * 1. "tokenIntrospectionEndpointUrl" (string, required) - * The URL of the token introspection endpoint of the OAuth2 provider that - * issued the access token to the client that is to be validated. - * - * 2. "useridField" (string, optional) - * The name of the field in the token introspection response that contains - * the userid. If specified, it will be used to verify the value of the "id" - * field in the "authData" JSON that is coming from the client. - * This can be the "aud" (i.e. audience), the "sub" (i.e. subject) or the - * "username" field in the introspection response, but since only the - * "active" field is required and all other reponse fields are optional - * in the RFC, it has to be optional in this adapter as well. - * Default: - (undefined) - * - * 3. "appidField" (string, optional) - * The name of the field in the token introspection response that contains - * the appId of the client. If specified, it will be used to verify it's - * value against the set of appIds in the adapter config. The concept of - * appIds comes from the two major social login providers - * (Google and Facebook). They have not yet implemented the token - * introspection endpoint, but the concept can be valid for any OAuth2 - * provider. - * Default: - (undefined) - * - * 4. "appIds" (array of strings, required if appidField is defined) - * A set of appIds that are used to restrict accepted access tokens based - * on a specific field's value in the token introspection response. - * Default: - (undefined) - * - * 5. "authorizationHeader" (string, optional) - * The value of the "Authorization" HTTP header in requests sent to the - * introspection endpoint. It must contain the raw value. - * Thus if HTTP Basic authorization is to be used, it must contain the - * "Basic" string, followed by whitespace, then by the base64 encoded - * version of the concatenated + ":" + string. - * Eg. "Basic dXNlcm5hbWU6cGFzc3dvcmQ=" + * @description + * ## Parse Server Configuration + * To configure Parse Server for OAuth2 Token Introspection, use the following structure: + * ```json + * { + * "auth": { + * "oauth2Provider": { + * "tokenIntrospectionEndpointUrl": "https://provider.com/introspect", + * "useridField": "sub", + * "appidField": "aud", + * "appIds": ["my-app-id"], + * "authorizationHeader": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=", + * "oauth2": true + * } + * } + * } + * ``` * - * The adapter expects requests with the following authData JSON: + * The adapter requires the following `authData` fields: + * - `id`: The user ID provided by the client. + * - `access_token`: The access token provided by the client. * + * ## Auth Payload + * ### Example Auth Payload + * ```json * { - * "someadapter": { - * "id": "user's OAuth2 provider-specific id as a string", - * "access_token": "an authorized OAuth2 access token for the user", + * "oauth2": { + * "id": "user-id", + * "access_token": "access-token" * } * } + * ``` + * + * ## Notes + * - `tokenIntrospectionEndpointUrl` is mandatory and should point to a valid OAuth2 provider's introspection endpoint. + * - If `appidField` is defined, `appIds` must also be specified to validate the app ID in the introspection response. + * - `authorizationHeader` can be used to authenticate requests to the token introspection endpoint. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc7662 OAuth 2.0 Token Introspection Specification} */ -const Parse = require('parse/node').Parse; -const querystring = require('querystring'); -const httpsRequest = require('./httpsRequest'); - -const INVALID_ACCESS = 'OAuth2 access token is invalid for this user.'; -const INVALID_ACCESS_APPID = - "OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration."; -const MISSING_APPIDS = - 'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'; -const MISSING_URL = 'OAuth2 token introspection endpoint URL is missing from configuration!'; - -// Returns a promise that fulfills if this user id is valid. -function validateAuthData(authData, options) { - return requestTokenInfo(options, authData.access_token).then(response => { - if ( - !response || - !response.active || - (options.useridField && authData.id !== response[options.useridField]) - ) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS); + +import AuthAdapter from './AuthAdapter'; + +class OAuth2Adapter extends AuthAdapter { + validateOptions(options) { + super.validateOptions(options); + + if (!options.tokenIntrospectionEndpointUrl) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.'); + } + if (options.appidField && !options.appIds?.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 configuration is missing app IDs.'); } - }); -} -function validateAppId(appIds, authData, options) { - if (!options || !options.appidField) { - return Promise.resolve(); + this.tokenIntrospectionEndpointUrl = options.tokenIntrospectionEndpointUrl; + this.useridField = options.useridField; + this.appidField = options.appidField; + this.appIds = options.appIds; + this.authorizationHeader = options.authorizationHeader; } - if (!appIds || appIds.length === 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, MISSING_APPIDS); - } - return requestTokenInfo(options, authData.access_token).then(response => { - if (!response || !response.active) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS); + + async validateAppId(authData) { + if (!this.appidField) { + return; } - const appidField = options.appidField; - if (!response[appidField]) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS_APPID); + + const response = await this.requestTokenInfo(authData.access_token); + + const appIdFieldValue = response[this.appidField]; + const isValidAppId = Array.isArray(appIdFieldValue) + ? appIdFieldValue.some(appId => this.appIds.includes(appId)) + : this.appIds.includes(appIdFieldValue); + + if (!isValidAppId) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2: Invalid app ID.'); } - const responseValue = response[appidField]; - if (!Array.isArray(responseValue) && appIds.includes(responseValue)) { - return; - } else if ( - Array.isArray(responseValue) && - responseValue.some(appId => appIds.includes(appId)) - ) { - return; - } else { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS_APPID); + } + + async validateAuthData(authData) { + const response = await this.requestTokenInfo(authData.access_token); + + if (!response.active || (this.useridField && authData.id !== response[this.useridField])) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.'); } - }); -} -// A promise wrapper for requests to the OAuth2 token introspection endpoint. -function requestTokenInfo(options, access_token) { - if (!options || !options.tokenIntrospectionEndpointUrl) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, MISSING_URL); + return {}; } - const parsedUrl = new URL(options.tokenIntrospectionEndpointUrl); - const postData = querystring.stringify({ - token: access_token, - }); - const headers = { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(postData), - }; - if (options.authorizationHeader) { - headers['Authorization'] = options.authorizationHeader; + + async requestTokenInfo(accessToken) { + const response = await fetch(this.tokenIntrospectionEndpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(this.authorizationHeader && { Authorization: this.authorizationHeader }) + }, + body: new URLSearchParams({ token: accessToken }) + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection request failed.'); + } + + return response.json(); } - const postOptions = { - hostname: parsedUrl.hostname, - path: parsedUrl.pathname, - method: 'POST', - headers: headers, - }; - return httpsRequest.request(postOptions, postData); } -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData, -}; +export default new OAuth2Adapter(); + diff --git a/src/Adapters/Auth/phantauth.js b/src/Adapters/Auth/phantauth.js index a7fba68dc5..d9145c84ca 100644 --- a/src/Adapters/Auth/phantauth.js +++ b/src/Adapters/Auth/phantauth.js @@ -7,15 +7,24 @@ const { Parse } = require('parse/node'); const httpsRequest = require('./httpsRequest'); +import Config from '../../Config'; +import Deprecator from '../../Deprecator/Deprecator'; // Returns a promise that fulfills if this user id is valid. -function validateAuthData(authData) { - return request('auth/userinfo', authData.access_token).then(data => { - if (data && data.sub == authData.id) { - return; - } +async function validateAuthData(authData) { + const config = Config.get(Parse.applicationId); + + Deprecator.logRuntimeDeprecation({ usage: 'phantauth adapter' }); + + const phantauthConfig = config.auth.phantauth; + if (!phantauthConfig?.enableInsecureAuth) { + throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'PhantAuth only works with enableInsecureAuth: true'); + } + + const data = await request('auth/userinfo', authData.access_token); + if (data?.sub !== authData.id) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'PhantAuth auth is invalid for this user.'); - }); + } } // Returns a promise that fulfills if this app id is valid. diff --git a/src/Adapters/Auth/qq.js b/src/Adapters/Auth/qq.js index dddc7cc7a3..873e9071b8 100644 --- a/src/Adapters/Auth/qq.js +++ b/src/Adapters/Auth/qq.js @@ -1,41 +1,112 @@ -// Helper functions for accessing the qq Graph API. -const httpsRequest = require('./httpsRequest'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return graphRequest('me?access_token=' + authData.access_token).then(function (data) { - if (data && data.openid == authData.id) { - return; +/** + * Parse Server authentication adapter for QQ. + * + * @class QqAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your QQ App ID. Required for secure authentication. + * @param {string} options.clientSecret - Your QQ App Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for QQ authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "qq": { + * "clientId": "your-app-id", + * "clientSecret": "your-app-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "qq": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "qq": { + * "code": "abcd1234", + * "redirect_uri": "https://your-redirect-uri.com/callback" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "qq": { + * "id": "1234567", + * "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using QQ's OAuth API. + * - **Insecure authentication** validates the `id` and `access_token` directly, bypassing OAuth flows. This approach is not recommended and may be deprecated in future versions. + * + * @see {@link https://wiki.connect.qq.com/ QQ Authentication Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class QqAdapter extends BaseAuthCodeAdapter { + constructor() { + super('qq'); + } + + async getUserFromAccessToken(access_token) { + const response = await fetch('https://graph.qq.com/oauth2.0/me', { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq API request failed.'); } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq auth is invalid for this user.'); - }); -} -// Returns a promise that fulfills if this app id is valid. -function validateAppId() { - return Promise.resolve(); -} + const data = await response.text(); + return this.parseResponseData(data); + } -// A promisey wrapper for qq graph requests. -function graphRequest(path) { - return httpsRequest.get('https://graph.qq.com/oauth2.0/' + path, true).then(data => { - return parseResponseData(data); - }); -} + async getAccessTokenFromCode(authData) { + const response = await fetch('https://graph.qq.com/oauth2.0/token', { + method: 'GET', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: authData.redirect_uri, + code: authData.code, + }).toString(), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq API request failed.'); + } -function parseResponseData(data) { - const starPos = data.indexOf('('); - const endPos = data.indexOf(')'); - if (starPos == -1 || endPos == -1) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq auth is invalid for this user.'); + const text = await response.text(); + const data = this.parseResponseData(text); + return data.access_token; } - data = data.substring(starPos + 1, endPos - 1); - return JSON.parse(data); } -module.exports = { - validateAppId, - validateAuthData, - parseResponseData, -}; +export default new QqAdapter(); diff --git a/src/Adapters/Auth/spotify.js b/src/Adapters/Auth/spotify.js index 604868d078..c3304c6348 100644 --- a/src/Adapters/Auth/spotify.js +++ b/src/Adapters/Auth/spotify.js @@ -1,44 +1,118 @@ -// Helper functions for accessing the Spotify API. -const httpsRequest = require('./httpsRequest'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return request('me', authData.access_token).then(data => { - if (data && data.id == authData.id) { - return; - } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify auth is invalid for this user.'); - }); -} +/** + * Parse Server authentication adapter for Spotify. + * + * @class SpotifyAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.clientId - Your Spotify application's Client ID. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Spotify authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "spotify": { + * "clientId": "your-client-id" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "spotify": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`, and `code_verifier`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "spotify": { + * "code": "abc123def456ghi789", + * "redirect_uri": "https://example.com/callback", + * "code_verifier": "secure-code-verifier" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "spotify": { + * "id": "1234567", + * "access_token": "abc123def456ghi789" + * } + * } + * ``` + * + * ## Notes + * - `enableInsecureAuth` is **not recommended** and bypasses secure flows by validating the user ID and access token directly. This method is not suitable for production environments and may be removed in future versions. + * - Secure authentication exchanges the `code` provided by the client for an access token using Spotify's OAuth API. This method ensures greater security and is the recommended approach. + * + * @see {@link https://developer.spotify.com/documentation/web-api/tutorials/getting-started Spotify OAuth Documentation} + */ -// Returns a promise that fulfills if this app id is valid. -async function validateAppId(appIds, authData) { - const access_token = authData.access_token; - if (!Array.isArray(appIds)) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'appIds must be an array.'); +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +class SpotifyAdapter extends BaseAuthCodeAdapter { + constructor() { + super('spotify'); } - if (!appIds.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify auth is not configured.'); - } - const data = await request('me', access_token); - if (!data || !appIds.includes(data.id)) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify auth is invalid for this user.'); + + async getUserFromAccessToken(access_token) { + const response = await fetch('https://api.spotify.com/v1/me', { + headers: { + Authorization: 'Bearer ' + access_token, + }, + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify API request failed.'); + } + + const user = await response.json(); + return { + id: user.id, + }; } -} -// A promisey wrapper for Spotify API requests. -function request(path, access_token) { - return httpsRequest.get({ - host: 'api.spotify.com', - path: '/v1/' + path, - headers: { - Authorization: 'Bearer ' + access_token, - }, - }); + async getAccessTokenFromCode(authData) { + if (!authData.code || !authData.redirect_uri || !authData.code_verifier) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Spotify auth configuration authData.code and/or authData.redirect_uri and/or authData.code_verifier.' + ); + } + + const response = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authData.code, + redirect_uri: authData.redirect_uri, + code_verifier: authData.code_verifier, + client_id: this.clientId, + }), + }); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify API request failed.'); + } + + return response.json(); + } } -module.exports = { - validateAppId: validateAppId, - validateAuthData: validateAuthData, -}; +export default new SpotifyAdapter(); diff --git a/src/Adapters/Auth/twitter.js b/src/Adapters/Auth/twitter.js index eac83cbed4..9a6881bd24 100644 --- a/src/Adapters/Auth/twitter.js +++ b/src/Adapters/Auth/twitter.js @@ -1,51 +1,244 @@ -// Helper functions for accessing the twitter API. -var OAuth = require('./OAuth1Client'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData, options) { - if (!options) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Twitter auth configuration missing'); - } - options = handleMultipleConfigurations(authData, options); - var client = new OAuth(options); - client.host = 'api.twitter.com'; - client.auth_token = authData.auth_token; - client.auth_token_secret = authData.auth_token_secret; - - return client.get('/1.1/account/verify_credentials.json').then(data => { - if (data && data.id_str == '' + authData.id) { +/** + * Parse Server authentication adapter for Twitter. + * + * @class TwitterAdapter + * @param {Object} options - The adapter configuration options. + * @param {string} options.consumerKey - The Twitter App Consumer Key. Required for secure authentication. + * @param {string} options.consumerSecret - The Twitter App Consumer Secret. Required for secure authentication. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Twitter authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "twitter": { + * "consumerKey": "your-consumer-key", + * "consumerSecret": "your-consumer-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "twitter": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `oauth_token`, `oauth_verifier`. + * - **Insecure Authentication (Not Recommended)**: `id`, `oauth_token`, `oauth_token_secret`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "twitter": { + * "oauth_token": "1234567890-abc123def456", + * "oauth_verifier": "abc123def456" + * } + * } + * ``` + * + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "twitter": { + * "id": "1234567890", + * "oauth_token": "1234567890-abc123def456", + * "oauth_token_secret": "1234567890-abc123def456" + * } + * } + * ``` + * + * ## Notes + * - **Deprecation Notice**: `enableInsecureAuth` and insecure fields (`id`, `oauth_token_secret`) are **deprecated** and may be removed in future versions. Use secure authentication with `consumerKey` and `consumerSecret`. + * - Secure authentication exchanges the `oauth_token` and `oauth_verifier` provided by the client for an access token using Twitter's OAuth API. + * + * @see {@link https://developer.twitter.com/en/docs/authentication/oauth-1-0a Twitter OAuth Documentation} + */ + +import Config from '../../Config'; +import querystring from 'querystring'; +import AuthAdapter from './AuthAdapter'; + +class TwitterAuthAdapter extends AuthAdapter { + validateOptions(options) { + if (!options) { + throw new Error('Twitter auth options are required.'); + } + + this.enableInsecureAuth = options.enableInsecureAuth; + + if (!this.enableInsecureAuth && (!options.consumer_key || !options.consumer_secret)) { + throw new Error('Consumer key and secret are required for secure Twitter auth.'); + } + } + + async validateAuthData(authData, options) { + const config = Config.get(Parse.applicationId); + const twitterConfig = config.auth.twitter; + + if (this.enableInsecureAuth && twitterConfig && config.enableInsecureAuthAdapters) { + return this.validateInsecureAuth(authData, options); + } + + if (!options.consumer_key || !options.consumer_secret) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth configuration missing consumer_key and/or consumer_secret.' + ); + } + + const accessTokenData = await this.exchangeAccessToken(authData); + + if (accessTokenData?.oauth_token && accessTokenData?.user_id) { + authData.id = accessTokenData.user_id; + authData.auth_token = accessTokenData.oauth_token; return; } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); - }); -} -// Returns a promise that fulfills iff this app id is valid. -function validateAppId() { - return Promise.resolve(); -} + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); + } + + async validateInsecureAuth(authData, options) { + if (!authData.oauth_token || !authData.oauth_token_secret) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter insecure auth requires oauth_token and oauth_token_secret.' + ); + } + + options = this.handleMultipleConfigurations(authData, options); + + const data = await this.request(authData, options); + const parsedData = await data.json(); + + if (parsedData?.id === authData.id) { + return; + } + + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); + } -function handleMultipleConfigurations(authData, options) { - if (Array.isArray(options)) { - const consumer_key = authData.consumer_key; - if (!consumer_key) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); + async exchangeAccessToken(authData) { + const accessTokenRequestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: querystring.stringify({ + oauth_token: authData.oauth_token, + oauth_verifier: authData.oauth_verifier, + }), + }; + + const response = await fetch('https://api.twitter.com/oauth/access_token', accessTokenRequestOptions); + if (!response.ok) { + throw new Error('Failed to exchange access token.'); } - options = options.filter(option => { - return option.consumer_key == consumer_key; + + return response.json(); + } + + handleMultipleConfigurations(authData, options) { + if (Array.isArray(options)) { + const consumer_key = authData.consumer_key; + + if (!consumer_key) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); + } + + options = options.filter(option => option.consumer_key === consumer_key); + + if (options.length === 0) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Twitter auth is invalid for this user.' + ); + } + + return options[0]; + } + + return options; + } + + async request(authData, options) { + const { consumer_key, consumer_secret } = options; + + const oauth = { + consumer_key, + consumer_secret, + auth_token: authData.oauth_token, + auth_token_secret: authData.oauth_token_secret, + }; + + const url = new URL('https://api.twitter.com/2/users/me'); + + const response = await fetch(url, { + headers: { + Authorization: 'Bearer ' + oauth.auth_token, + }, + body: JSON.stringify(oauth), }); - if (options.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); + if (!response.ok) { + throw new Error('Failed to fetch user data.'); + } + + return response; + } + + async beforeFind(authData) { + if (this.enableInsecureAuth && !authData?.code) { + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); + } + + const user = await this.getUserFromAccessToken(authData.access_token, authData); + + if (user.id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); + } + + return; + } + + if (!authData?.code) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Twitter code is required.'); } - options = options[0]; + + const access_token = await this.exchangeAccessToken(authData); + const user = await this.getUserFromAccessToken(access_token, authData); + + + authData.access_token = access_token; + authData.id = user.id; + + delete authData.code; + delete authData.redirect_uri; + } + + validateAppId() { + return Promise.resolve(); } - return options; } -module.exports = { - validateAppId, - validateAuthData, - handleMultipleConfigurations, -}; +export default new TwitterAuthAdapter(); diff --git a/src/Adapters/Auth/vkontakte.js b/src/Adapters/Auth/vkontakte.js index 46fd1248ae..3b5b7a9bac 100644 --- a/src/Adapters/Auth/vkontakte.js +++ b/src/Adapters/Auth/vkontakte.js @@ -4,28 +4,32 @@ const httpsRequest = require('./httpsRequest'); var Parse = require('parse/node').Parse; +import Config from '../../Config'; +import Deprecator from '../../Deprecator/Deprecator'; // Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData, params) { - return vkOAuth2Request(params).then(function (response) { - if (response && response.access_token) { - return request( - 'api.vk.com', - 'method/users.get?access_token=' + authData.access_token + '&v=' + params.apiVersion - ).then(function (response) { - if ( - response && - response.response && - response.response.length && - response.response[0].id == authData.id - ) { - return; - } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk auth is invalid for this user.'); - }); - } +async function validateAuthData(authData, params) { + const config = Config.get(Parse.applicationId); + Deprecator.logRuntimeDeprecation({ usage: 'vkontakte adapter' }); + + const vkConfig = config.auth.vkontakte; + if (!vkConfig?.enableInsecureAuth || !config.enableInsecureAuthAdapters) { + throw new Parse.Error('Vk only works with enableInsecureAuth: true'); + } + + const response = await vkOAuth2Request(params); + if (!response?.access_token) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk appIds or appSecret is incorrect.'); - }); + } + + const vkUser = await request( + 'api.vk.com', + `method/users.get?access_token=${authData.access_token}&v=${params.apiVersion}` + ); + + if (!vkUser?.response?.length || vkUser.response[0].id !== authData.id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk auth is invalid for this user.'); + } } function vkOAuth2Request(params) { diff --git a/src/Adapters/Auth/wechat.js b/src/Adapters/Auth/wechat.js index 82ddb851ef..d9c196f5a4 100644 --- a/src/Adapters/Auth/wechat.js +++ b/src/Adapters/Auth/wechat.js @@ -1,30 +1,120 @@ -// Helper functions for accessing the WeChat Graph API. -const httpsRequest = require('./httpsRequest'); -var Parse = require('parse/node').Parse; - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return graphRequest('auth?access_token=' + authData.access_token + '&openid=' + authData.id).then( - function (data) { - if (data.errcode == 0) { - return; - } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'wechat auth is invalid for this user.'); +/** + * Parse Server authentication adapter for WeChat. + * + * @class WeChatAdapter + * @param {Object} options - The adapter options object. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * @param {string} options.clientId - Your WeChat App ID. + * @param {string} options.clientSecret - Your WeChat App Secret. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for WeChat authentication, use the following structure: + * ### Secure Configuration (Recommended) + * ```json + * { + * "auth": { + * "wechat": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "wechat": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **With `enableInsecureAuth` (Not Recommended)**: `id`, `access_token`. + * - **Without `enableInsecureAuth`**: `code`. + * + * ## Auth Payloads + * ### Secure Authentication Payload (Recommended) + * ```json + * { + * "wechat": { + * "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "wechat": { + * "id": "1234567", + * "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - With `enableInsecureAuth`, the adapter directly validates the `id` and `access_token` sent by the client. + * - Without `enableInsecureAuth`, the adapter uses the `code` provided by the client to exchange for an access token via WeChat's OAuth API. + * - The `enableInsecureAuth` flag is **deprecated** and may be removed in future versions. Use secure authentication with the `code` field instead. + * + * @example Auth Data Example + * // Example authData provided by the client: + * const authData = { + * wechat: { + * code: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * }; + * + * @see {@link https://developers.weixin.qq.com/doc/offiaccount/en/OA_Web_Apps/Wechat_webpage_authorization.html WeChat Authentication Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; + +class WeChatAdapter extends BaseAuthCodeAdapter { + constructor() { + super('WeChat'); + } + + async getUserFromAccessToken(access_token, authData) { + const response = await fetch( + `https://api.weixin.qq.com/sns/auth?access_token=${access_token}&openid=${authData.id}` + ); + + const data = await response.json(); + + if (!response.ok || data.errcode !== 0) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth is invalid for this user.'); } - ); -} -// Returns a promise that fulfills if this app id is valid. -function validateAppId() { - return Promise.resolve(); -} + return data; + } + + async getAccessTokenFromCode(authData) { + if (!authData.code) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth requires a code to be sent.'); + } + + const appId = this.clientId; + const appSecret = this.clientSecret + + + const response = await fetch( + `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${authData.code}&grant_type=authorization_code` + ); + + const data = await response.json(); + + if (!response.ok || data.errcode) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth is invalid for this user.'); + } + + authData.id = data.openid; -// A promisey wrapper for WeChat graph requests. -function graphRequest(path) { - return httpsRequest.get('https://api.weixin.qq.com/sns/' + path); + return data.access_token; + } } -module.exports = { - validateAppId, - validateAuthData, -}; +export default new WeChatAdapter(); diff --git a/src/Adapters/Auth/weibo.js b/src/Adapters/Auth/weibo.js index a29c3872df..86a761c653 100644 --- a/src/Adapters/Auth/weibo.js +++ b/src/Adapters/Auth/weibo.js @@ -1,41 +1,149 @@ -// Helper functions for accessing the weibo Graph API. -var httpsRequest = require('./httpsRequest'); -var Parse = require('parse/node').Parse; -var querystring = require('querystring'); - -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData) { - return graphRequest(authData.access_token).then(function (data) { - if (data && data.uid == authData.id) { - return; +/** + * Parse Server authentication adapter for Weibo. + * + * @class WeiboAdapter + * @param {Object} options - The adapter configuration options. + * @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended). + * @param {string} options.clientId - Your Weibo client ID. + * @param {string} options.clientSecret - Your Weibo client secret. + * + * @description + * ## Parse Server Configuration + * To configure Parse Server for Weibo authentication, use the following structure: + * ### Secure Configuration + * ```json + * { + * "auth": { + * "weibo": { + * "clientId": "your-client-id", + * "clientSecret": "your-client-secret" + * } + * } + * } + * ``` + * ### Insecure Configuration (Not Recommended) + * ```json + * { + * "auth": { + * "weibo": { + * "enableInsecureAuth": true + * } + * } + * } + * ``` + * + * The adapter requires the following `authData` fields: + * - **Secure Authentication**: `code`, `redirect_uri`. + * - **Insecure Authentication (Not Recommended)**: `id`, `access_token`. + * + * ## Auth Payloads + * ### Secure Authentication Payload + * ```json + * { + * "weibo": { + * "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + * "redirect_uri": "https://example.com/callback" + * } + * } + * ``` + * ### Insecure Authentication Payload (Not Recommended) + * ```json + * { + * "weibo": { + * "id": "1234567", + * "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * } + * ``` + * + * ## Notes + * - **Insecure Authentication**: When `enableInsecureAuth` is enabled, the adapter directly validates the `id` and `access_token` provided by the client. + * - **Secure Authentication**: When `enableInsecureAuth` is disabled, the adapter exchanges the `code` and `redirect_uri` for an access token using Weibo's OAuth API. + * - `enableInsecureAuth` is **deprecated** and may be removed in future versions. Use secure authentication with `code` and `redirect_uri`. + * + * @example Auth Data Example (Secure) + * const authData = { + * weibo: { + * code: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + * redirect_uri: "https://example.com/callback" + * } + * }; + * + * @example Auth Data Example (Insecure - Not Recommended) + * const authData = { + * weibo: { + * id: "1234567", + * access_token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + * } + * }; + * + * @see {@link https://open.weibo.com/wiki/Oauth2/access_token Weibo Authentication Documentation} + */ + +import BaseAuthCodeAdapter from './BaseCodeAuthAdapter'; +import querystring from 'querystring'; + +class WeiboAdapter extends BaseAuthCodeAdapter { + constructor() { + super('Weibo'); + } + + async getUserFromAccessToken(access_token) { + const postData = querystring.stringify({ + access_token: access_token, + }); + + const response = await fetch('https://api.weibo.com/oauth2/get_token_info', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: postData, + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Weibo auth is invalid for this user.'); } - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'weibo auth is invalid for this user.'); - }); -} -// Returns a promise that fulfills if this app id is valid. -function validateAppId() { - return Promise.resolve(); -} + return { + id: data.uid, + } + } + + async getAccessTokenFromCode(authData) { + if (!authData?.code || !authData?.redirect_uri) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Weibo auth requires code and redirect_uri to be sent.' + ); + } + + const postData = querystring.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + code: authData.code, + redirect_uri: authData.redirect_uri, + }); + + const response = await fetch('https://api.weibo.com/oauth2/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: postData, + }); + + const data = await response.json(); + + if (!response.ok || data.errcode) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Weibo auth is invalid for this user.'); + } -// A promisey wrapper for weibo graph requests. -function graphRequest(access_token) { - var postData = querystring.stringify({ - access_token: access_token, - }); - var options = { - hostname: 'api.weibo.com', - path: '/oauth2/get_token_info', - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(postData), - }, - }; - return httpsRequest.request(options, postData); + return data.access_token; + } } -module.exports = { - validateAppId, - validateAuthData, -}; +export default new WeiboAdapter(); diff --git a/src/Auth.js b/src/Auth.js index dbb34f9a62..9bda227651 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -411,26 +411,35 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer }); }; -const findUsersWithAuthData = (config, authData) => { +const findUsersWithAuthData = async (config, authData, beforeFind) => { const providers = Object.keys(authData); - const query = providers - .reduce((memo, provider) => { - if (!authData[provider] || (authData && !authData[provider].id)) { - return memo; + + const queries = await Promise.all( + providers.map(async provider => { + const providerAuthData = authData[provider]; + + const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; + if (beforeFind && typeof adapter?.beforeFind === 'function') { + await adapter.beforeFind(providerAuthData); } - const queryKey = `authData.${provider}.id`; - const query = {}; - query[queryKey] = authData[provider].id; - memo.push(query); - return memo; - }, []) - .filter(q => { - return typeof q !== 'undefined'; - }); - return query.length > 0 - ? config.database.find('_User', { $or: query }, { limit: 2 }) - : Promise.resolve([]); + if (!providerAuthData?.id) { + return null; + } + + return { [`authData.${provider}.id`]: providerAuthData.id }; + }) + ); + + // Filter out null queries + const validQueries = queries.filter(query => query !== null); + + if (!validQueries.length) { + return []; + } + + // Perform database query + return config.database.find('_User', { $or: validQueries }, { limit: 2 }); }; const hasMutatedAuthData = (authData, userAuthData) => { @@ -533,7 +542,7 @@ const handleAuthDataValidation = async (authData, req, foundUser) => { acc.authData[provider] = null; continue; } - const { validator } = req.config.authDataManager.getValidatorForProvider(provider); + const { validator } = req.config.authDataManager.getValidatorForProvider(provider) || {}; const authProvider = (req.config.auth || {})[provider] || {}; if (!validator || authProvider.enabled === false) { throw new Parse.Error( diff --git a/src/Config.js b/src/Config.js index c4884434ca..a9d5b5a1be 100644 --- a/src/Config.js +++ b/src/Config.js @@ -20,6 +20,7 @@ import { SecurityOptions, } from './Options/Definitions'; import ParseServer from './cloud-code/Parse.Server'; +import Deprecator from './Deprecator/Deprecator'; function removeTrailingSlash(str) { if (!str) { @@ -84,6 +85,7 @@ export class Config { pages, security, enforcePrivateUsers, + enableInsecureAuthAdapters, schema, requestKeywordDenylist, allowExpiredAuthDataToken, @@ -129,6 +131,7 @@ export class Config { this.validateSecurityOptions(security); this.validateSchemaOptions(schema); this.validateEnforcePrivateUsers(enforcePrivateUsers); + this.validateEnableInsecureAuthAdapters(enableInsecureAuthAdapters); this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken); this.validateRequestKeywordDenylist(requestKeywordDenylist); this.validateRateLimit(rateLimit); @@ -504,6 +507,15 @@ export class Config { } } + static validateEnableInsecureAuthAdapters(enableInsecureAuthAdapters) { + if (enableInsecureAuthAdapters && typeof enableInsecureAuthAdapters !== 'boolean') { + throw 'Parse Server option enableInsecureAuthAdapters must be a boolean.'; + } + if (enableInsecureAuthAdapters) { + Deprecator.logRuntimeDeprecation({ usage: 'insecure adapter' }); + } + } + get mount() { var mount = this._mount; if (this.publicServerURL) { diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 38e1d52d20..970364432b 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -15,4 +15,7 @@ * * If there are no deprecations, this must return an empty array. */ -module.exports = [{ optionKey: 'encodeParseObjectInCloudFunction', changeNewDefault: 'true' }]; +module.exports = [ + { optionKey: 'encodeParseObjectInCloudFunction', changeNewDefault: 'true' }, + { optionKey: 'enableInsecureAuthAdapters', changeNewDefault: 'false' }, +]; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 2356890425..11eb926ae9 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -233,6 +233,13 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + enableInsecureAuthAdapters: { + env: 'PARSE_SERVER_ENABLE_INSECURE_AUTH_ADAPTERS', + help: + 'Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.', + action: parsers.booleanParser, + default: true, + }, encodeParseObjectInCloudFunction: { env: 'PARSE_SERVER_ENCODE_PARSE_OBJECT_IN_CLOUD_FUNCTION', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index 9505266164..34807923d9 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -43,6 +43,7 @@ * @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true * @property {Boolean} enableCollationCaseComparison Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`. * @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors + * @property {Boolean} enableInsecureAuthAdapters Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them. * @property {Boolean} encodeParseObjectInCloudFunction If set to `true`, a `Parse.Object` that is in the payload when calling a Cloud Function will be converted to an instance of `Parse.Object`. If `false`, the object will not be converted and instead be a plain JavaScript object, which contains the raw data of a `Parse.Object` but is not an actual instance of `Parse.Object`. Default is `false`.

ℹ️ The expected behavior would be that the object is converted to an instance of `Parse.Object`, so you would normally set this option to `true`. The default is `false` because this is a temporary option that has been introduced to avoid a breaking change when fixing a bug where JavaScript objects are not converted to actual instances of `Parse.Object`. * @property {String} encryptionKey Key for encrypting your files * @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access. diff --git a/src/Options/index.js b/src/Options/index.js index 67dfa4e68e..8cae3b0ac3 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -159,6 +159,10 @@ export interface ParseServerOptions { /* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication :ENV: PARSE_SERVER_AUTH_PROVIDERS */ auth: ?{ [string]: AuthAdapter }; + /* Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them. + :ENV: PARSE_SERVER_ENABLE_INSECURE_AUTH_ADAPTERS + :DEFAULT: true */ + enableInsecureAuthAdapters: ?boolean; /* Max file size for uploads, defaults to 20mb :DEFAULT: 20mb */ maxUploadSize: ?string; diff --git a/src/RestWrite.js b/src/RestWrite.js index 255c55f24c..ad7aea803b 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -458,9 +458,8 @@ RestWrite.prototype.validateAuthData = function () { var providers = Object.keys(authData); if (providers.length > 0) { const canHandleAuthData = providers.some(provider => { - var providerAuthData = authData[provider]; - var hasToken = providerAuthData && providerAuthData.id; - return hasToken || providerAuthData === null; + const providerAuthData = authData[provider] || {}; + return !!Object.keys(providerAuthData).length; }); if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { return this.handleAuthData(authData); @@ -520,7 +519,7 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () { }; RestWrite.prototype.handleAuthData = async function (authData) { - const r = await Auth.findUsersWithAuthData(this.config, authData); + const r = await Auth.findUsersWithAuthData(this.config, authData, true); const results = this.filteredObjectsByACL(r); const userId = this.getUserId(); diff --git a/src/Security/CheckGroups/CheckGroupServerConfig.js b/src/Security/CheckGroups/CheckGroupServerConfig.js index b2b2376cb1..3f88c18898 100644 --- a/src/Security/CheckGroups/CheckGroupServerConfig.js +++ b/src/Security/CheckGroups/CheckGroupServerConfig.js @@ -69,6 +69,17 @@ class CheckGroupServerConfig extends CheckGroup { } }, }), + new Check({ + title: 'Insecure auth adapters disabled', + warning: + "Attackers may explore insecure auth adapters' vulnerabilities and log in on behalf of another user.", + solution: "Change Parse Server configuration to 'enableInsecureAuthAdapters: false'.", + check: () => { + if (config.enableInsecureAuthAdapters !== false) { + throw 1; + } + }, + }), ]; } } diff --git a/src/cli/parse-server.js b/src/cli/parse-server.js index b0c574bfea..7c7639b497 100755 --- a/src/cli/parse-server.js +++ b/src/cli/parse-server.js @@ -32,6 +32,7 @@ runner({ help, usage: '[options] ', start: function (program, options, logOptions) { + if (!options.appId || !options.masterKey) { program.outputHelp(); console.error('');