diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 5edb702ba4..9b172cdff4 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -41,6 +41,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); @@ -65,7 +66,7 @@ describe('AuthenticationProviders', function () { }); it(`should provide the right responses for adapter ${providerName}`, async () => { - const noResponse = ['twitter', 'apple', 'gcenter', 'google']; + const noResponse = ['twitter', 'apple', 'gcenter', "google", 'keycloak']; if (noResponse.includes(providerName)) { return; } @@ -703,6 +704,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 296013ce9a..d0da98ab3c 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -22,6 +22,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 = { @@ -56,6 +57,7 @@ const providers = { weibo, phantauth, microsoft, + keycloak, ldap, }; diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js new file mode 100644 index 0000000000..1223eac36b --- /dev/null +++ b/src/Adapters/Auth/keycloak.js @@ -0,0 +1,131 @@ +/* + # 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`) + } + } + } + ``` +*/ + +const { Parse } = require('parse/node'); +const httpsRequest = require('./httpsRequest'); + +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 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' + ); + } + 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' + ); + } + } +}; + +/* + @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 +*/ +function validateAuthData(authData, options = {}) { + return handleAuth(authData, options); +} + +// Returns a promise that fulfills if this app id is valid. +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +};