Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add keycloak authentication closes #1634 #1716

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/clients/cognito.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export async function listUsers(query: string, exactMatch = false) {
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) {
Expand Down
112 changes: 112 additions & 0 deletions backend/src/clients/keycloak.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please include unit tests

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few eslint warnings that need addressing. I'd recommend installing an ESLint plugin for your IDE, like this one for VS Code https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint

Original file line number Diff line number Diff line change
@@ -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, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use node-fetch as we have done for similar clients.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utilised node-fetch and added types of Response to results

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<UserInformation & { dn: string }> = []
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 })
}
}
14 changes: 13 additions & 1 deletion backend/src/connectors/authentication/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,26 @@ import { NextFunction, Request, Response, Router } from 'express'
import session from 'express-session'
import grant from 'grant'

import { listUsers } from '../../clients/cognito.js'
import { 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'
import { fromEntity, toEntity } from '../../utils/entity.js'
import { InternalError, NotFound } from '../../utils/error.js'
import { BaseAuthenticationConnector, RoleKeys, 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',
} as const
Expand Down
17 changes: 16 additions & 1 deletion backend/src/utils/config.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update the helm chart configuration with these changes here https://github.com/gchq/Bailo/blob/main/infrastructure/helm/bailo/templates/bailo/bailo.configmap.yaml and the values file.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated chart with configmap and values changes.

Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -117,11 +118,17 @@ export interface Config {
oauth: {
provider: string
grant: grant.GrantConfig | grant.GrantOptions
cognito: {
cognito?: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having optional blocks of configuration isn't something we've decided to do for things like this previously as you can see from the oauth block which would only be used if the oauth connector is selected. We define everything anyway in the default configuration as a method of documenting example configuration so everything should always been defined there. This is another reason (in addition to https://github.com/gchq/Bailo/pull/1716/files#r190195685) why I'd have two separate connectors that can be chosen using the existing authentication connector config value rather than the presence of an optional config block. Most of the other existing optional config like the oauth config is very similar to this and is connector related and it only get used if the relevant connector is selected. As we don't explicitly define any of the configuration as optional though, it probably would a good idea to include this in documentation. I'll make a note to include this information in the connector documentation we're planning on adding.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added my thoughts around this to the top comment, let me know what you would prefer and I'm happy to add around this as needed

identityProviderClient: { region: string; credentials: { accessKeyId: string; secretAccessKey: string } }
userPoolId: string
userIdAttribute: string
}
keycloak?: {
realm: string
clientId: string
clientSecret: string
serverUrl: string
}
}

defaultSchemas: {
Expand Down Expand Up @@ -175,4 +182,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
145 changes: 145 additions & 0 deletions backend/test/clients/keycloak.spec.ts
Original file line number Diff line number Diff line change
@@ -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 protected]', firstName: 'Test' }],
});

const { listUsers } = await import('../../src/clients/keycloak.js');
const results = await listUsers('user');

expect(results).toStrictEqual([
{
dn: 'user1',
email: '[email protected]',
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 protected]', 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`,
}),
})
);
});
});
21 changes: 14 additions & 7 deletions infrastructure/helm/bailo/templates/bailo/bailo.configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -136,6 +136,13 @@ data:
userPoolId: '{{ .Values.oauth.identityProviderClient.userPoolId }}',
userIdAttribute: '{{ .Values.oauth.identityProviderClient.userIdAttribute }}',
},

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
Expand Down
16 changes: 11 additions & 5 deletions infrastructure/helm/bailo/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,22 @@ 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 /
subdomain: "cognito domain"
identityProviderClient:
subdomain: "domain"
identityProviderClient: # cognito
userPoolId: "region_id"
userIdAttribute: "name"
keycloak: # keycloak
realm: "realm"
clientId: "client_id"
clientSecret: "client_secret"
authServerUrl: "auth_server_url"

# Used for aws storage
aws:
Expand Down