-
Notifications
You must be signed in to change notification settings - Fork 14
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
base: main
Are you sure you want to change the base?
Changes from all commits
b3fbcf1
416060c
d337b9b
a7a8a13
d58953c
89f03a5
a1ea1d1
52e9cc0
2ae3800
ad44bc4
2723eae
26b9eaa
7f3ebd1
22a76e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }) | ||
} | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
|
@@ -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,11 +118,17 @@ export interface Config { | |
oauth: { | ||
provider: string | ||
grant: grant.GrantConfig | grant.GrantOptions | ||
cognito: { | ||
cognito?: { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: { | ||
|
@@ -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 |
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`, | ||
}), | ||
}) | ||
); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please include unit tests