From 36be7a319988aedba5e1999133d64bcd0b1bdbd8 Mon Sep 17 00:00:00 2001 From: Rhuan Date: Tue, 28 Jan 2020 16:47:32 +0000 Subject: [PATCH 1/4] Copy auth adapter to create keycloak adapter --- src/Adapters/Auth/keycloak.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/Adapters/Auth/keycloak.js diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js new file mode 100644 index 0000000000..9af6d5e449 --- /dev/null +++ b/src/Adapters/Auth/keycloak.js @@ -0,0 +1,22 @@ +/*eslint no-unused-vars: "off"*/ +export class AuthAdapter { + /* + @param appIds: the specified app ids in the configuration + @param authData: the client provided authData + @param options: additional options + @returns a promise that resolves if the applicationId is valid + */ + validateAppId(appIds, authData, options) { + return Promise.resolve({}); + } + + /* + @param authData: the client provided authData + @param options: additional options + */ + validateAuthData(authData, options) { + return Promise.resolve({}); + } +} + +export default AuthAdapter; From 3f5446781cca5d8edbcc4d711a15136a3a70b471 Mon Sep 17 00:00:00 2001 From: Rhuan Date: Tue, 28 Jan 2020 18:32:37 +0000 Subject: [PATCH 2/4] Add keycloak authentication adapter --- src/Adapters/Auth/keycloak.js | 143 ++++++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 15 deletions(-) diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js index 9af6d5e449..b15936677b 100644 --- a/src/Adapters/Auth/keycloak.js +++ b/src/Adapters/Auth/keycloak.js @@ -1,22 +1,135 @@ -/*eslint no-unused-vars: "off"*/ -export class AuthAdapter { - /* - @param appIds: the specified app ids in the configuration - @param authData: the client provided authData - @param options: additional options - @returns a promise that resolves if the applicationId is valid - */ - validateAppId(appIds, authData, options) { - return Promise.resolve({}); +/* + # 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`) + } + } + } + ``` +*/ + +var Parse = require('parse/node').Parse; + +const arraysEqual = (_arr1, _arr2) => { + if ( + !Array.isArray(_arr1) || + !Array.isArray(_arr2) || + _arr1.length !== _arr2.length + ) + return false; + + var arr1 = _arr1.concat().sort(); + var arr2 = _arr2.concat().sort(); + + for (var i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) return false; + } + + return true; +}; + +const userinfoURL = config => { + if (!(config['auth-server-url'] && config['realm'])) + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Missing keycloak configuration' + ); + + return ( + config['auth-server-url'] + + '/realms/' + + config['realm'] + + '/protocol/openid-connect/userinfo' + ); +}; + +export class KeycloakAuthAdapter { + validateAppId() { + return Promise.resolve(); } /* - @param authData: the client provided authData - @param options: additional options + @param {Object} authData: the client provided authData + @param {string} authData.access_token: the access_token retrieved from client authentication in Keycloak + @param {string} authData.id: the id retrieved from client authentication in Keycloak + @param {Array} authData.roles: the roles retrieved from client authentication in Keycloak + @param {Array} authData.groups: the groups retrieved from client authentication in Keycloak + @param {Object} options: additional options + @param {Object} options.config: the config object passed during Parse Server instantiation */ - validateAuthData(authData, options) { - return Promise.resolve({}); + validateAuthData({ access_token, id, roles, groups }, { config }) { + if (process.env.NODE_ENV === 'development') return Promise.resolve(); + + if (!(access_token && id)) + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Missing access token and/or User id' + ); + if (!config) + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Missing keycloak configuration' + ); + return Parse.Cloud.httpRequest({ + url: userinfoURL(config), + headers: { + Authorization: 'Bearer ' + access_token, + }, + }) + .then(response => { + if ( + response.data && + response.data.sub == id && + arraysEqual(response.data.roles, roles) && + arraysEqual(response.data.groups, groups) + ) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Invalid authentication' + ); + }) + .catch(e => { + const error = JSON.parse(e.text); + if (error.error_description) { + throw new Parse.Error( + Parse.Error.HOSTING_ERROR, + error.error_description + ); + } else { + throw new Parse.Error( + Parse.Error.HOSTING_ERROR, + 'Could not connect to the authentication server' + ); + } + }); } } -export default AuthAdapter; +export default KeycloakAuthAdapter; From 0aceeca25a663236a2741f8103cfc4ad9af5865a Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 10 Feb 2020 11:11:32 +0100 Subject: [PATCH 3/4] Add keycloak to auth adapter tests --- spec/AuthenticationAdapters.spec.js | 1 + src/Adapters/Auth/keycloak.js | 16 +++++++--------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 2636405a07..c60aae97be 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -42,6 +42,7 @@ describe('AuthenticationProviders', function() { 'weibo', 'phantauth', 'microsoft', + 'keycloak', ].map(function(providerName) { it('Should validate structure of ' + providerName, done => { const provider = require('../lib/Adapters/Auth/' + providerName); diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js index b15936677b..0b4ce74337 100644 --- a/src/Adapters/Auth/keycloak.js +++ b/src/Adapters/Auth/keycloak.js @@ -68,10 +68,10 @@ const userinfoURL = config => { ); }; -export class KeycloakAuthAdapter { - validateAppId() { +module.exports = { + validateAppId: () => { return Promise.resolve(); - } + }, /* @param {Object} authData: the client provided authData @@ -82,8 +82,8 @@ export class KeycloakAuthAdapter { @param {Object} options: additional options @param {Object} options.config: the config object passed during Parse Server instantiation */ - validateAuthData({ access_token, id, roles, groups }, { config }) { - if (process.env.NODE_ENV === 'development') return Promise.resolve(); + validateAuthData: ({ access_token, id, roles, groups }, { config }) => { + if (process.env.NODE_ENV !== 'development') return Promise.resolve(); if (!(access_token && id)) throw new Parse.Error( @@ -129,7 +129,5 @@ export class KeycloakAuthAdapter { ); } }); - } -} - -export default KeycloakAuthAdapter; + }, +}; From 6e0a1504932d652b13c5f35b499f8f7c2c3d90ba Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Sun, 22 Mar 2020 14:00:34 -0500 Subject: [PATCH 4/4] Improve tests --- spec/AuthenticationAdapters.spec.js | 251 +++++++++++++++++++++++++++- src/Adapters/Auth/index.js | 2 + src/Adapters/Auth/keycloak.js | 126 +++++++------- 3 files changed, 314 insertions(+), 65 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index c60aae97be..522adcebcb 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -67,7 +67,7 @@ describe('AuthenticationProviders', function() { }); it(`should provide the right responses for adapter ${providerName}`, async () => { - const noResponse = ['twitter', 'apple', 'gcenter']; + const noResponse = ['twitter', 'apple', 'gcenter', 'keycloak']; if (noResponse.includes(providerName)) { return; } @@ -688,6 +688,255 @@ describe('google play games service auth', () => { }); }); +describe('keycloak auth adapter', () => { + const keycloak = require('../lib/Adapters/Auth/keycloak'); + const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + + it('validateAuthData should fail without access token', async () => { + const authData = { + id: 'fakeid', + }; + try { + await keycloak.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe('Missing access token and/or User id'); + } + }); + + it('validateAuthData should fail without user id', async () => { + const authData = { + access_token: 'sometoken', + }; + try { + await keycloak.validateAuthData(authData); + fail(); + } catch (e) { + expect(e.message).toBe('Missing access token and/or User id'); + } + }); + + it('validateAuthData should fail without config', async () => { + const options = { + keycloak: { + config: null, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'keycloak', + options + ); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Missing keycloak configuration'); + } + }); + + it('validateAuthData should fail connect error', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.reject({ + text: JSON.stringify({ error: 'hosting_error' }), + }); + }); + const options = { + keycloak: { + config: { + 'auth-server-url': 'http://example.com', + realm: 'new', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'keycloak', + options + ); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Could not connect to the authentication server'); + } + }); + + it('validateAuthData should fail with error description', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.reject({ + text: JSON.stringify({ error_description: 'custom error message' }), + }); + }); + const options = { + keycloak: { + config: { + 'auth-server-url': 'http://example.com', + realm: 'new', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'keycloak', + options + ); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('custom error message'); + } + }); + + it('validateAuthData should fail with invalid auth', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({}); + }); + const options = { + keycloak: { + config: { + 'auth-server-url': 'http://example.com', + realm: 'new', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'keycloak', + options + ); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Invalid authentication'); + } + }); + + it('validateAuthData should fail with invalid groups', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ + data: { + sub: 'fakeid', + roles: ['role1'], + groups: ['unknown'], + }, + }); + }); + const options = { + keycloak: { + config: { + 'auth-server-url': 'http://example.com', + realm: 'new', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + roles: ['role1'], + groups: ['group1'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'keycloak', + options + ); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Invalid authentication'); + } + }); + + it('validateAuthData should fail with invalid roles', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ + data: { + sub: 'fakeid', + roles: 'unknown', + groups: ['group1'], + }, + }); + }); + const options = { + keycloak: { + config: { + 'auth-server-url': 'http://example.com', + realm: 'new', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + roles: ['role1'], + groups: ['group1'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'keycloak', + options + ); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('Invalid authentication'); + } + }); + + it('validateAuthData should handle authentication', async () => { + spyOn(httpsRequest, 'get').and.callFake(() => { + return Promise.resolve({ + data: { + sub: 'fakeid', + roles: ['role1'], + groups: ['group1'], + }, + }); + }); + const options = { + keycloak: { + config: { + 'auth-server-url': 'http://example.com', + realm: 'new', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'sometoken', + roles: ['role1'], + groups: ['group1'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter( + 'keycloak', + options + ); + await adapter.validateAuthData(authData, providerOptions); + expect(httpsRequest.get).toHaveBeenCalledWith({ + host: 'http://example.com', + path: '/realms/new/protocol/openid-connect/userinfo', + headers: { + Authorization: 'Bearer sometoken', + }, + }); + }); +}); + describe('oauth2 auth adapter', () => { const oauth2 = require('../lib/Adapters/Auth/oauth2'); const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index e5e8c955bd..4a3aa48aba 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -23,6 +23,7 @@ const weibo = require('./weibo'); const oauth2 = require('./oauth2'); const phantauth = require('./phantauth'); const microsoft = require('./microsoft'); +const keycloak = require('./keycloak'); const ldap = require('./ldap'); const anonymous = { @@ -58,6 +59,7 @@ const providers = { weibo, phantauth, microsoft, + keycloak, ldap, }; diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js index 0b4ce74337..1223eac36b 100644 --- a/src/Adapters/Auth/keycloak.js +++ b/src/Adapters/Auth/keycloak.js @@ -33,7 +33,8 @@ ``` */ -var Parse = require('parse/node').Parse; +const { Parse } = require('parse/node'); +const httpsRequest = require('./httpsRequest'); const arraysEqual = (_arr1, _arr2) => { if ( @@ -53,27 +54,60 @@ const arraysEqual = (_arr1, _arr2) => { return true; }; -const userinfoURL = config => { - if (!(config['auth-server-url'] && config['realm'])) +const handleAuth = async ( + { access_token, id, roles, groups } = {}, + { config } = {} +) => { + if (!(access_token && id)) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Missing access token and/or User id' + ); + } + if (!config || !(config['auth-server-url'] && config['realm'])) { throw new Parse.Error( Parse.Error.OBJECT_NOT_FOUND, 'Missing keycloak configuration' ); - - return ( - config['auth-server-url'] + - '/realms/' + - config['realm'] + - '/protocol/openid-connect/userinfo' - ); + } + try { + const response = await httpsRequest.get({ + host: config['auth-server-url'], + path: `/realms/${config['realm']}/protocol/openid-connect/userinfo`, + headers: { + Authorization: 'Bearer ' + access_token, + }, + }); + if ( + response && + response.data && + response.data.sub == id && + arraysEqual(response.data.roles, roles) && + arraysEqual(response.data.groups, groups) + ) { + return; + } + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Invalid authentication' + ); + } catch (e) { + if (e instanceof Parse.Error) { + throw e; + } + const error = JSON.parse(e.text); + if (error.error_description) { + throw new Parse.Error(Parse.Error.HOSTING_ERROR, error.error_description); + } else { + throw new Parse.Error( + Parse.Error.HOSTING_ERROR, + 'Could not connect to the authentication server' + ); + } + } }; -module.exports = { - validateAppId: () => { - return Promise.resolve(); - }, - - /* +/* @param {Object} authData: the client provided authData @param {string} authData.access_token: the access_token retrieved from client authentication in Keycloak @param {string} authData.id: the id retrieved from client authentication in Keycloak @@ -81,53 +115,17 @@ module.exports = { @param {Array} authData.groups: the groups retrieved from client authentication in Keycloak @param {Object} options: additional options @param {Object} options.config: the config object passed during Parse Server instantiation - */ - validateAuthData: ({ access_token, id, roles, groups }, { config }) => { - if (process.env.NODE_ENV !== 'development') return Promise.resolve(); +*/ +function validateAuthData(authData, options = {}) { + return handleAuth(authData, options); +} - if (!(access_token && id)) - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Missing access token and/or User id' - ); - if (!config) - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Missing keycloak configuration' - ); - return Parse.Cloud.httpRequest({ - url: userinfoURL(config), - headers: { - Authorization: 'Bearer ' + access_token, - }, - }) - .then(response => { - if ( - response.data && - response.data.sub == id && - arraysEqual(response.data.roles, roles) && - arraysEqual(response.data.groups, groups) - ) { - return; - } - throw new Parse.Error( - Parse.Error.OBJECT_NOT_FOUND, - 'Invalid authentication' - ); - }) - .catch(e => { - const error = JSON.parse(e.text); - if (error.error_description) { - throw new Parse.Error( - Parse.Error.HOSTING_ERROR, - error.error_description - ); - } else { - throw new Parse.Error( - Parse.Error.HOSTING_ERROR, - 'Could not connect to the authentication server' - ); - } - }); - }, +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, };