diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index bb59eefc8c..f7f3d8d45e 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -659,7 +659,8 @@ describe('google auth adapter', () => { describe('keycloak auth adapter', () => { const keycloak = require('../lib/Adapters/Auth/keycloak'); - const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); + const jwt = require('jsonwebtoken'); + const authUtils = require('../lib/Adapters/Auth/utils'); it('validateAuthData should fail without access token', async () => { const authData = { @@ -704,17 +705,12 @@ describe('keycloak auth adapter', () => { } }); - it('validateAuthData should fail connect error', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.reject({ - text: JSON.stringify({ error: 'hosting_error' }), - }); - }); + it('validateAuthData should fail without client-id', async () => { const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', }, }, }; @@ -727,86 +723,172 @@ describe('keycloak auth adapter', () => { await adapter.validateAuthData(authData, providerOptions); fail(); } catch (e) { - expect(e.message).toBe('Could not connect to the authentication server'); + expect(e.message).toBe('Keycloak auth is not configured. Missing client-id.'); } }); - it('validateAuthData should fail with error description', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.reject({ - text: JSON.stringify({ error_description: 'custom error message' }), - }); - }); + it('validateAuthData should fail with invalid JWT token', async () => { const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'not-a-jwt', }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); try { await adapter.validateAuthData(authData, providerOptions); fail(); } catch (e) { - expect(e.message).toBe('custom error message'); + expect(e.message).toBe('provided token does not decode as JWT'); } }); - it('validateAuthData should fail with invalid auth', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({}); - }); + it('validateAuthData should fail with wrong issuer', async () => { + const fakeClaim = { + iss: 'https://evil.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'fake.jwt.token', }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); try { await adapter.validateAuthData(authData, providerOptions); fail(); } catch (e) { - expect(e.message).toBe('Invalid authentication'); + expect(e.message).toBe( + 'access token not issued by correct provider - expected: https://auth.example.com/realms/my-realm | from: https://evil.example.com/realms/my-realm' + ); } }); - it('validateAuthData should fail with invalid groups', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ - data: { - sub: 'fakeid', - roles: ['role1'], - groups: ['unknown'], + it('validateAuthData should fail with wrong azp (audience)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'other-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, - }); - }); + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe( + 'access token is not authorized for this client - expected: parse-app | from: other-app' + ); + } + }); + + it('validateAuthData should fail with wrong sub', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'wrong-id', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it('validateAuthData should fail with invalid roles (JWT validation)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, roles: ['role1'], groups: ['group1'], }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + roles: ['wrong-role'], + groups: ['group1'], + }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); try { await adapter.validateAuthData(authData, providerOptions); @@ -816,29 +898,35 @@ describe('keycloak auth adapter', () => { } }); - it('validateAuthData should fail with invalid roles', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ - data: { - sub: 'fakeid', - roles: 'unknown', - groups: ['group1'], - }, - }); - }); + it('validateAuthData should fail with invalid groups (JWT validation)', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + roles: ['role1'], + groups: ['group1'], + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'fake.jwt.token', roles: ['role1'], - groups: ['group1'], + groups: ['wrong-group'], }; const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); try { @@ -849,39 +937,201 @@ describe('keycloak auth adapter', () => { } }); - it('validateAuthData should handle authentication', async () => { - spyOn(httpsRequest, 'get').and.callFake(() => { - return Promise.resolve({ - data: { - sub: 'fakeid', - roles: ['role1'], - groups: ['group1'], - }, - }); - }); + it('validateAuthData should handle successful authentication', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + roles: ['role1'], + groups: ['group1'], + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const options = { keycloak: { config: { - 'auth-server-url': 'http://example.com', - realm: 'new', + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', }, }, }; const authData = { id: 'fakeid', - access_token: 'sometoken', + access_token: 'fake.jwt.token', 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', + expect(jwt.verify).toHaveBeenCalled(); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('validateAuthData should handle successful authentication without roles and groups', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { header: { kid: '123', alg: 'RS256' } }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken.header); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + await adapter.validateAuthData(authData, providerOptions); + expect(jwt.verify).toHaveBeenCalled(); + }); + + it('validateAuthData should use hardcoded RS256 algorithm, not JWT header alg', async () => { + const fakeClaim = { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'fakeid', + exp: Math.floor(Date.now() / 1000) + 3600, + }; + const fakeDecodedToken = { kid: '123', alg: 'none' }; + const fakeSigningKey = { kid: '123', rsaPublicKey: 'the_rsa_public_key' }; + spyOn(authUtils, 'getHeaderFromToken').and.callFake(() => fakeDecodedToken); + spyOn(authUtils, 'getSigningKey').and.resolveTo(fakeSigningKey); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'fakeid', + access_token: 'fake.jwt.token', + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + await adapter.validateAuthData(authData, providerOptions); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(['RS256']); + }); + + it('validateAuthData should verify a real signed JWT end-to-end', async () => { + const crypto = require('crypto'); + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + const token = jwt.sign( + { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'user123', + roles: ['admin'], + groups: ['staff'], + }, + privateKey, + { algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' } + ); + + // Only mock the JWKS key fetch — jwt.verify runs for real + spyOn(authUtils, 'getSigningKey').and.resolveTo({ + kid: 'test-key-1', + publicKey: publicKey, }); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'user123', + access_token: token, + roles: ['admin'], + groups: ['staff'], + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + const result = await adapter.validateAuthData(authData, providerOptions); + expect(result.sub).toBe('user123'); + expect(result.azp).toBe('parse-app'); + expect(result.iss).toBe('https://auth.example.com/realms/my-realm'); + }); + + it('validateAuthData should reject a JWT signed with a different key', async () => { + const crypto = require('crypto'); + const { privateKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + const { publicKey: differentPublicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + + const token = jwt.sign( + { + iss: 'https://auth.example.com/realms/my-realm', + azp: 'parse-app', + sub: 'user123', + }, + privateKey, + { algorithm: 'RS256', keyid: 'test-key-1', expiresIn: '1h' } + ); + + // Return a different public key — signature verification should fail + spyOn(authUtils, 'getSigningKey').and.resolveTo({ + kid: 'test-key-1', + publicKey: differentPublicKey, + }); + + const options = { + keycloak: { + config: { + 'auth-server-url': 'https://auth.example.com', + realm: 'my-realm', + 'client-id': 'parse-app', + }, + }, + }; + const authData = { + id: 'user123', + access_token: token, + }; + const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter('keycloak', options); + try { + await adapter.validateAuthData(authData, providerOptions); + fail(); + } catch (e) { + expect(e.message).toBe('invalid signature'); + } }); }); diff --git a/src/Adapters/Auth/keycloak.js b/src/Adapters/Auth/keycloak.js index 457faeeaed..f3b4db37f9 100644 --- a/src/Adapters/Auth/keycloak.js +++ b/src/Adapters/Auth/keycloak.js @@ -67,7 +67,9 @@ */ const { Parse } = require('parse/node'); -const httpsRequest = require('./httpsRequest'); +const jwksClient = require('jwks-rsa'); +const jwt = require('jsonwebtoken'); +const authUtils = require('./utils'); const arraysEqual = (_arr1, _arr2) => { if (!Array.isArray(_arr1) || !Array.isArray(_arr2) || _arr1.length !== _arr2.length) { return false; } @@ -82,61 +84,96 @@ const arraysEqual = (_arr1, _arr2) => { return true; }; -const handleAuth = async ({ access_token, id, roles, groups } = {}, { config } = {}) => { +const getKeycloakKeyByKeyId = async (keyId, jwksUri, cacheMaxEntries, cacheMaxAge) => { + const client = jwksClient({ + jwksUri, + cache: true, + cacheMaxEntries, + cacheMaxAge, + }); + + let key; + try { + key = await authUtils.getSigningKey(client, keyId); + } catch { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `Unable to find matching key for Key ID: ${keyId}` + ); + } + return key; +}; + +const verifyAccessToken = async ( + { access_token, id, roles, groups } = {}, + { config, cacheMaxEntries, cacheMaxAge } = {} +) => { 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'); } + if (!config['client-id']) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'Keycloak auth is not configured. Missing client-id.' + ); + } + + const expectedIssuer = `${config['auth-server-url']}/realms/${config['realm']}`; + const jwksUri = `${config['auth-server-url']}/realms/${config['realm']}/protocol/openid-connect/certs`; + + const { kid: keyId } = authUtils.getHeaderFromToken(access_token); + const ONE_HOUR_IN_MS = 3600000; + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const keycloakKey = await getKeycloakKeyByKeyId(keyId, jwksUri, cacheMaxEntries, cacheMaxAge); + const signingKey = keycloakKey.publicKey || keycloakKey.rsaPublicKey; + + let jwtClaims; try { - const response = await httpsRequest.get({ - host: config['auth-server-url'], - path: `/realms/${config['realm']}/protocol/openid-connect/userinfo`, - headers: { - Authorization: 'Bearer ' + access_token, - }, + jwtClaims = jwt.verify(access_token, signingKey, { + algorithms: ['RS256'], }); - if ( - response && - response.data && - response.data.sub == id && - arraysEqual(response.data.roles, roles) && - arraysEqual(response.data.groups, groups) - ) { - return; - } + } catch (exception) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${exception.message}`); + } + + if (jwtClaims.iss !== expectedIssuer) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `access token not issued by correct provider - expected: ${expectedIssuer} | from: ${jwtClaims.iss}` + ); + } + + if (jwtClaims.azp !== config['client-id']) { + throw new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + `access token is not authorized for this client - expected: ${config['client-id']} | from: ${jwtClaims.azp}` + ); + } + + if (jwtClaims.sub !== id) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.'); + } + + const rolesMatch = jwtClaims.roles === roles || arraysEqual(jwtClaims.roles, roles); + const groupsMatch = jwtClaims.groups === groups || arraysEqual(jwtClaims.groups, groups); + + if (!rolesMatch || !groupsMatch) { 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' - ); - } } + + return jwtClaims; }; -/* - @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); + return verifyAccessToken(authData, options); } -// Returns a promise that fulfills if this app id is valid. function validateAppId() { return Promise.resolve(); }