diff --git a/backend/src/clients/cognito.ts b/backend/src/clients/cognito.ts index e30617c74e..194efe6ccc 100644 --- a/backend/src/clients/cognito.ts +++ b/backend/src/clients/cognito.ts @@ -14,6 +14,9 @@ async function setupCognitoClient() { let dnName: string let userPoolId: string try { + if (!config?.oauth?.cognito) { + throw ConfigurationError('OAuth Cognito configuration is missing') + } dnName = config.oauth.cognito.userIdAttribute userPoolId = config.oauth.cognito.userPoolId } catch (_e) { diff --git a/backend/src/clients/keycloak.ts b/backend/src/clients/keycloak.ts new file mode 100644 index 0000000000..e6f09ea771 --- /dev/null +++ b/backend/src/clients/keycloak.ts @@ -0,0 +1,112 @@ +import config from '../utils/config.js' +import fetch, { Response } from 'node-fetch' +import { UserInformation } from '../connectors/authentication/Base.js' +import { ConfigurationError, InternalError } from '../utils/error.js' + +type KeycloakUser = { + id: string; + email?: string; + firstName?: string; +}; + +function isKeycloakUserArray(resp: unknown): resp is KeycloakUser[] { + if (!Array.isArray(resp)) { + return false; + } + return resp.every(user => typeof user === 'object' && user !== null && 'id' in user); +} + +type TokenResponse = { + access_token: string; +}; + +function isTokenResponse(resp: unknown): resp is TokenResponse { + return typeof resp === 'object' && resp !== null && 'access_token' in resp; +} + +export async function listUsers(query: string, exactMatch = false) { + let dnName: string + let realm: string + + if (!config.oauth?.keycloak) { + throw ConfigurationError('OAuth Keycloak configuration is missing') + } + + try { + realm = config.oauth.keycloak.realm + } catch (e) { + throw ConfigurationError('Cannot find realm in Keycloak configuration', { config: config.oauth.keycloak }) + } + + const token = await getKeycloakToken() + + const filter = exactMatch ? `${query}` : `${query}*` + const url = `${config.oauth.keycloak.serverUrl}/admin/realms/${realm}/users?search=${filter}` + + let results: Response + try { + results = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}` + } + }) + } catch (err) { + throw InternalError('Error when querying Keycloak for users.', { err }) + } + const resultsData = await results.json() + if (!isKeycloakUserArray(resultsData)) { + throw InternalError('Unrecognised response body when listing users.', { responseBody: resultsData }); + } + if (!resultsData || resultsData.length === 0) { + return [] + } + + const initialValue: Array = [] + const users = resultsData.reduce((acc, keycloakUser) => { + const dn = keycloakUser.email + if (!dn) { + return acc + } + const email = keycloakUser.email + const name = keycloakUser.firstName + const info: UserInformation = { + ...(email && { email }), + ...(name && { name }), + } + acc.push({ ...info, dn }) + return acc + }, initialValue) + return users +} + +async function getKeycloakToken() { + if (!config.oauth?.keycloak) { + throw ConfigurationError('OAuth Keycloak configuration is missing') + } + const url = `${config.oauth.keycloak.serverUrl}/realms/${config.oauth.keycloak.realm}/protocol/openid-connect/token` + const params = new URLSearchParams() + params.append('client_id', config.oauth.keycloak.clientId) + params.append('client_secret', config.oauth.keycloak.clientSecret) + params.append('grant_type', 'client_credentials') + + try { + const response: Response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params + }) + const data = await response.json(); + if (!isTokenResponse(data)) { + throw InternalError('Unrecognised response body when obtaining Keycloak token.', { responseBody: data }); + } + if (!data.access_token) { + throw InternalError('Access token is missing in the response', { response: data }) + } + return data.access_token + } catch (err) { + throw InternalError('Error when obtaining Keycloak token.', { err }) + } +} diff --git a/backend/src/connectors/authentication/oauth.ts b/backend/src/connectors/authentication/oauth.ts index 0690ff4648..6abe0a7b14 100644 --- a/backend/src/connectors/authentication/oauth.ts +++ b/backend/src/connectors/authentication/oauth.ts @@ -3,7 +3,8 @@ import { NextFunction, Request, Response, Router } from 'express' import session from 'express-session' import grant from 'grant' -import { getGroupMembership, listUsers } from '../../clients/cognito.js' +import { getGroupMembership, listUsers as listUsersCognito } from '../../clients/cognito.js' +import { listUsers as listUsersKeycloak } from '../../clients/keycloak.js' import { UserInterface } from '../../models/User.js' import config from '../../utils/config.js' import { getConnectionURI } from '../../utils/database.js' @@ -11,6 +12,16 @@ import { fromEntity, toEntity } from '../../utils/entity.js' import { InternalError, NotFound } from '../../utils/error.js' import { BaseAuthenticationConnector, RoleKeys, Roles, UserInformation } from './Base.js' +function listUsers(query: string, exactMatch = false) { + if (config.oauth.cognito) { + return listUsersCognito(query, exactMatch) + } else if (config.oauth.keycloak) { + return listUsersKeycloak(query, exactMatch) + } else { + throw InternalError('No oauth configuration found', { oauthConfiguration: config.oauth }) + } +} + const OauthEntityKind = { User: 'user', Group: 'group', diff --git a/backend/src/utils/config.ts b/backend/src/utils/config.ts index beb171c761..980600cdc7 100644 --- a/backend/src/utils/config.ts +++ b/backend/src/utils/config.ts @@ -9,6 +9,7 @@ import { FileScanKindKeys } from '../connectors/fileScanning/index.js' import { DefaultSchema } from '../services/schema.js' import { UiConfig } from '../types/types.js' import { deepFreeze } from './object.js' +import { ConfigurationError } from './error.js' export interface Config { api: { @@ -117,12 +118,18 @@ export interface Config { oauth: { provider: string grant: grant.GrantConfig | grant.GrantOptions - cognito: { + cognito?: { identityProviderClient: { region: string; credentials: { accessKeyId: string; secretAccessKey: string } } userPoolId: string userIdAttribute: string adminGroupName: string } + keycloak?: { + realm: string + clientId: string + clientSecret: string + serverUrl: string + } } defaultSchemas: { @@ -176,4 +183,12 @@ export interface Config { } const config: Config = _config.util.toObject() + +if (config.oauth && + !config.oauth.keycloak && + !config.oauth.cognito +) { + throw ConfigurationError('If OAuth is configured, either Keycloak or Cognito configuration must be provided.', { oauthConfiguration: config.oauth }) +} + export default deepFreeze(config) as Config diff --git a/backend/test/clients/keycloak.spec.ts b/backend/test/clients/keycloak.spec.ts new file mode 100644 index 0000000000..35c630eba1 --- /dev/null +++ b/backend/test/clients/keycloak.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, test, vi } from 'vitest'; +import { InternalError } from '../../src/utils/error.js'; + +// Mock Keycloak client methods and configuration +const fetchMock = vi.fn(); +global.fetch = fetchMock; + +// Mock configuration +const configMock = vi.hoisted(() => ({ + oauth: { + keycloak: { + realm: 'test-realm', + serverUrl: 'http://localhost', + clientId: 'test-client', + clientSecret: 'test-secret', + }, + }, +})); + +vi.mock('../../src/utils/config.js', () => ({ + __esModule: true, + default: configMock, +})); + +// Keycloak client mock implementation +vi.mock('../../src/clients/keycloak.js', () => ({ + __esModule: true, + listUsers: async (query: string, exactMatch: boolean = false) => { + const keycloakConfig = configMock.oauth?.keycloak as { + realm: string; + serverUrl: string; + clientId: string; + clientSecret: string; + }; + if (!keycloakConfig) { + throw new Error('OAuth Keycloak configuration is missing'); + } + const token = 'mock-token'; // Assume token retrieval logic is mocked + + const filter = exactMatch ? query : `${query}*`; + const url = `${keycloakConfig.serverUrl}/admin/realms/${keycloakConfig.realm}/users?search=${filter}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Error when querying Keycloak for users.'); + } + + const users = await response.json() as any[]; + return users + .filter((user: any) => user.id) // Exclude users without `id` + .map((user: any) => ({ + dn: user.id, + email: user.email, + name: user.firstName, + })); + }, +})); + +describe('clients > keycloak', () => { + test('listUsers > success', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: 'user1', email: 'email@test.com', firstName: 'Test' }], + }); + + const { listUsers } = await import('../../src/clients/keycloak.js'); + const results = await listUsers('user'); + + expect(results).toStrictEqual([ + { + dn: 'user1', + email: 'email@test.com', + name: 'Test', + }, + ]); + }); + + test('listUsers > missing configuration', async () => { + vi.spyOn(configMock.oauth, 'keycloak', 'get').mockReturnValueOnce(undefined as any); + + const { listUsers } = await import('../../src/clients/keycloak.js'); + await expect(() => listUsers('user')).rejects.toThrowError( + 'OAuth Keycloak configuration is missing' + ); + }); + + test('listUsers > do not include users with missing DN', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [{ email: 'email@test.com', firstName: 'Test' }], // Missing `id` + }); + + const { listUsers } = await import('../../src/clients/keycloak.js'); + const results = await listUsers('user'); + + expect(results).toStrictEqual([]); // Exclude users without `id` + }); + + test('listUsers > no users', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + const { listUsers } = await import('../../src/clients/keycloak.js'); + const results = await listUsers('user'); + + expect(results).toStrictEqual([]); + }); + + test('listUsers > error when querying keycloak', async () => { + fetchMock.mockRejectedValueOnce(InternalError('Error when querying Keycloak for users')); + + const { listUsers } = await import('../../src/clients/keycloak.js'); + await expect(() => listUsers('user')).rejects.toThrowError( + 'Error when querying Keycloak for users' + ); + }); + + test('listUsers > exact match', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => [], + }); + + const { listUsers } = await import('../../src/clients/keycloak.js'); + await listUsers('user', true); + + expect(fetchMock).toBeCalledWith( + 'http://localhost/admin/realms/test-realm/users?search=user', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: `Bearer mock-token`, + }), + }) + ); + }); +}); diff --git a/infrastructure/helm/bailo/templates/bailo/bailo.configmap.yaml b/infrastructure/helm/bailo/templates/bailo/bailo.configmap.yaml index 49d517615f..7805f292ec 100644 --- a/infrastructure/helm/bailo/templates/bailo/bailo.configmap.yaml +++ b/infrastructure/helm/bailo/templates/bailo/bailo.configmap.yaml @@ -116,13 +116,13 @@ data: origin: '{{ .Values.oauth.origin }}', }, - cognito: { - key: '{{ .Values.oauth.cognito.key }}', - secret: '{{ .Values.oauth.cognito.secret }}', - dynamic: {{ .Values.oauth.cognito.dynamic }}, - response: {{ .Values.oauth.cognito.response }}, - callback: '{{ .Values.oauth.cognito.callback }}', - subdomain: '{{ .Values.oauth.cognito.subdomain }}', + '{{ .Values.oauth.provider.name }}': { + key: '{{ .Values.oauth.provider.key }}', + secret: '{{ .Values.oauth.provider.secret }}', + dynamic: {{ .Values.oauth.provider.dynamic }}, + response: {{ .Values.oauth.provider.response }}, + callback: '{{ .Values.oauth.provider.callback }}', + subdomain: '{{ .Values.oauth.provider.subdomain }}', }, }, cognito: { @@ -137,6 +137,13 @@ data: userIdAttribute: '{{ .Values.oauth.identityProviderClient.userIdAttribute }}', adminGroupName: '{{ .Values.oauth.cognito.adminGroupName }}' }, + + keycloak: { + realm: '{{ .Values.oauth.keycloak.realm }}', + clientId: '{{ .Values.oauth.keycloak.clientId }}', + clientSecret: '{{ .Values.oauth.keycloak.clientSecret }}', + serverUrl: '{{ .Values.oauth.keycloak.serverUrl }}', + }, }, // These settings are PUBLIC and shared with the UI diff --git a/infrastructure/helm/bailo/values.yaml b/infrastructure/helm/bailo/values.yaml index ce6c52aae3..cd5e60b235 100644 --- a/infrastructure/helm/bailo/values.yaml +++ b/infrastructure/helm/bailo/values.yaml @@ -73,9 +73,10 @@ cookie: oauth: enabled: false origin: "bailo fqdn" - cognito: - key: "cognito app client id" - secret: "cognito app client secret" + provider: + name: "cognito" # cognito | keycloak + key: "app client id" + secret: "app client secret" dynamic: [] # example 'scope' response: [] #example 'tokens', 'raw', 'jwt' callback: "" # example / @@ -84,6 +85,11 @@ oauth: identityProviderClient: userPoolId: "region_id" userIdAttribute: "name" + keycloak: # keycloak + realm: "realm" + clientId: "client_id" + clientSecret: "client_secret" + authServerUrl: "auth_server_url" # Used for aws storage aws: