Skip to content

Commit 1667dd1

Browse files
feat(medcat-trainer-client): Support OIDC in medcat trainer client (#428)
1 parent 223d2f6 commit 1667dd1

4 files changed

Lines changed: 242 additions & 8 deletions

File tree

medcat-trainer/client/README.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export MCTRAINER_PASSWORD=<password>
3434
```
3535

3636
```python
37-
from mctclient import MedCATTrainerSession, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject
37+
from mctclient import MedCATTrainerSession, KeycloakSettings, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject
3838

3939
# Connect to your MedCATTrainer instance
4040
session = MedCATTrainerSession(server="http://localhost:8001")
@@ -59,6 +59,31 @@ project = session.create_project(
5959
)
6060
```
6161

62+
## Authentication
63+
64+
### OIDC / Keycloak (optional)
65+
66+
You can pass a `KeycloakSettings` object to use OIDC Bearer auth. Any missing
67+
fields fall back to environment variables
68+
(`KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_USERNAME`, `KEYCLOAK_PASSWORD`)
69+
and then the same defaults as `webapp/scripts/load_examples.py`.
70+
71+
```python
72+
from mctclient import MedCATTrainerSession, KeycloakSettings
73+
74+
session = MedCATTrainerSession(
75+
server="http://localhost:8001",
76+
use_oidc=True,
77+
keycloak_settings=KeycloakSettings(
78+
keycloak_url="http://keycloak.cogstack.localhost",
79+
realm="cogstack-realm",
80+
client_id="cogstack-medcattrainer-frontend",
81+
username="admin",
82+
password="admin",
83+
),
84+
)
85+
```
86+
6287
### MedCATTrainerSession Methods
6388

6489
- `create_project(name, description, members, dataset, cuis=[], cuis_file=None, concept_db=None, vocab=None, cdb_search_filter=None, modelpack=None, meta_tasks=[], rel_tasks=[])`
@@ -81,6 +106,51 @@ Each method returns the corresponding object or a list of objects.
81106

82107
This project is licensed under the Apache 2.0 License.
83108

109+
## Developer Instructions
110+
111+
### Local dev setup (uv)
112+
113+
From the repo root:
114+
115+
```sh
116+
cd medcat-trainer/client
117+
uv venv
118+
source .venv/bin/activate
119+
uv pip install -e .
120+
```
121+
122+
### Run unit tests
123+
124+
```sh
125+
cd medcat-trainer/client
126+
python -m unittest discover -s tests -p "test_*.py" -v
127+
```
128+
129+
### Run integration test (real server)
130+
131+
```sh
132+
cd medcat-trainer/client
133+
export MCTRAINER_SERVER="http://localhost:8000"
134+
export MCTRAINER_USERNAME="<username>"
135+
export MCTRAINER_PASSWORD="<password>"
136+
export MCTRAINER_EXPECTED_USERS="admin,alice,bob" # optional
137+
python -m unittest tests.integration.test_integration_mctclient -v
138+
```
139+
140+
Optional: use OIDC / Keycloak Bearer token auth:
141+
142+
```sh
143+
export MCTRAINER_USE_OIDC=1
144+
export KEYCLOAK_URL="http://keycloak.cogstack.localhost"
145+
export KEYCLOAK_REALM="cogstack"
146+
export KEYCLOAK_CLIENT_ID="cogstack-medcattrainer-frontend"
147+
# If set, the client will use client-credentials grant; otherwise password grant.
148+
export KEYCLOAK_CLIENT_SECRET="<client_secret>" # optional
149+
export KEYCLOAK_USERNAME="<keycloak_username>" # used if no client_secret
150+
export KEYCLOAK_PASSWORD="<keycloak_password>" # used if no client_secret
151+
python -m unittest tests.integration.test_integration_mctclient -v
152+
```
153+
84154
## Contributing
85155

86156
Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.

medcat-trainer/client/mctclient.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,65 @@
33
import json
44
import os
55
from abc import ABC
6-
from typing import Any, Dict, List, Tuple, Union
6+
from typing import Any, Dict, List, Optional, Tuple, Union
77

88
import requests
99

1010
import logging
1111

1212
logger = logging.getLogger(__name__)
1313

14+
@dataclass
15+
class KeycloakSettings:
16+
"""
17+
Keycloak settings for OIDC token retrieval.
18+
19+
If a field is not provided, it falls back to environment variables and then
20+
the same defaults used by `webapp/scripts/load_examples.py`.
21+
"""
22+
23+
keycloak_url: Optional[str] = None
24+
realm: Optional[str] = None
25+
client_id: Optional[str] = None
26+
client_secret: Optional[str] = None
27+
username: Optional[str] = None
28+
password: Optional[str] = None
29+
scope: str = "openid profile email"
30+
31+
def __post_init__(self):
32+
self.keycloak_url = self.keycloak_url or os.environ.get("KEYCLOAK_URL", "http://keycloak.cogstack.localhost")
33+
self.realm = self.realm or os.environ.get("KEYCLOAK_REALM", "cogstack-realm")
34+
self.client_id = self.client_id or os.environ.get("KEYCLOAK_CLIENT_ID", "cogstack-medcattrainer-frontend")
35+
self.client_secret = self.client_secret or os.environ.get("KEYCLOAK_CLIENT_SECRET")
36+
self.username = self.username or os.environ.get("KEYCLOAK_USERNAME", "admin")
37+
self.password = self.password or os.environ.get("KEYCLOAK_PASSWORD", "admin")
38+
39+
40+
def get_keycloak_access_token(settings: KeycloakSettings) -> str:
41+
token_url = f"{settings.keycloak_url}/realms/{settings.realm}/protocol/openid-connect/token"
42+
if settings.client_secret:
43+
data = {
44+
"grant_type": "client_credentials",
45+
"client_id": settings.client_id,
46+
"client_secret": settings.client_secret,
47+
"scope": settings.scope,
48+
}
49+
else:
50+
data = {
51+
"grant_type": "password",
52+
"client_id": settings.client_id,
53+
"username": settings.username,
54+
"password": settings.password,
55+
"scope": settings.scope,
56+
}
57+
try:
58+
logger.debug(f"Getting Keycloak token from {token_url}")
59+
resp = requests.post(token_url, data=data)
60+
resp.raise_for_status()
61+
return resp.json()["access_token"]
62+
except Exception as e:
63+
raise MCTUtilsException("Failed to get Keycloak access token", e)
64+
1465

1566
@dataclass
1667
class MCTObj(ABC):
@@ -224,7 +275,7 @@ class MedCATTrainerSession:
224275
>>> annotations = session.get_project_annos(projects[0])
225276
"""
226277

227-
def __init__(self, server=None, username=None, password=None):
278+
def __init__(self, server=None, username=None, password=None, keycloak_settings=None, use_oidc: bool = False):
228279
"""Initialize the MedCATTrainerSession.
229280
230281
Args:
@@ -237,15 +288,31 @@ def __init__(self, server=None, username=None, password=None):
237288
self.password = password or os.getenv("MCTRAINER_PASSWORD")
238289
self.server = server or 'http://localhost:8001'
239290

291+
env_use_oidc = os.getenv("MCTRAINER_USE_OIDC", "")
292+
env_use_oidc_truthy = env_use_oidc.strip() == "1"
293+
effective_use_oidc = bool(use_oidc) or bool(env_use_oidc_truthy)
294+
295+
if effective_use_oidc:
296+
if keycloak_settings is None:
297+
kc_settings = KeycloakSettings()
298+
else:
299+
if not isinstance(keycloak_settings, KeycloakSettings):
300+
raise TypeError("keycloak_settings must be a KeycloakSettings instance")
301+
kc_settings = keycloak_settings
302+
303+
token = get_keycloak_access_token(kc_settings)
304+
self.headers = {"Authorization": f"Bearer {token}"}
305+
return
306+
240307
payload = {"username": self.username, "password": self.password}
241308
resp = requests.post(f"{self.server}/api/api-token-auth/", json=payload)
242309
if 200 <= resp.status_code < 300:
243310
token = json.loads(resp.text)["token"]
244311
self.headers = {
245-
'Authorization': f'Token {token}',
312+
"Authorization": f"Token {token}",
246313
}
247314
else:
248-
raise MCTUtilsException(f'Failed to login to MedCATtrainer instance running at: {self.server}')
315+
raise MCTUtilsException(f"Failed to login to MedCATtrainer instance running at: {self.server}")
249316

250317
def create_project(self, name: str,
251318
description: str,
@@ -482,8 +549,23 @@ def get_users(self) -> List[MCTUser]:
482549
Returns:
483550
List[MCTUser]: A list of all users in the MedCATTrainer instance
484551
"""
485-
users = json.loads(requests.get(f'{self.server}/api/users/', headers=self.headers).text)['results']
486-
return [MCTUser(id=u['id'], username=u['username']) for u in users]
552+
resp = requests.get(f"{self.server}/api/users/", headers=self.headers)
553+
if not (200 <= resp.status_code < 300):
554+
raise MCTUtilsException(
555+
f"Failed to get users from MedCATTrainer instance running at: {self.server}",
556+
f"HTTP {resp.status_code}: {resp.text[:500]}",
557+
)
558+
try:
559+
payload = resp.json()
560+
if not isinstance(payload, dict):
561+
payload = json.loads(resp.text or "{}")
562+
except Exception as e:
563+
raise MCTUtilsException(
564+
f"Failed to parse users response from MedCATTrainer instance running at: {self.server}",
565+
e,
566+
)
567+
users = payload.get("results", [])
568+
return [MCTUser(id=u.get("id"), username=u.get("username")) for u in users]
487569

488570
def get_models(self) -> Tuple[List[str], List[str]]:
489571
"""Get all MedCAT cdb and vocab models in the MedCATTrainer instance.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import os
2+
import unittest
3+
4+
from mctclient import MedCATTrainerSession
5+
6+
7+
class TestMCTClientIntegration(unittest.TestCase):
8+
@unittest.skipUnless(
9+
os.getenv("MCTRAINER_SERVER") and os.getenv("MCTRAINER_USERNAME") and os.getenv("MCTRAINER_PASSWORD"),
10+
"Integration test requires MCTRAINER_SERVER, MCTRAINER_USERNAME, MCTRAINER_PASSWORD",
11+
)
12+
def test_get_users_contains_expected_users(self):
13+
server = os.getenv("MCTRAINER_SERVER").rstrip("/")
14+
username = os.getenv("MCTRAINER_USERNAME")
15+
password = os.getenv("MCTRAINER_PASSWORD")
16+
expected_users_env = os.getenv("MCTRAINER_EXPECTED_USERS", "")
17+
18+
expected_users = {username}
19+
if expected_users_env:
20+
expected_users |= {u.strip() for u in expected_users_env.split(",") if u.strip()}
21+
22+
session = MedCATTrainerSession(server=server, username=username, password=password)
23+
users = session.get_users()
24+
25+
self.assertIsInstance(users, list)
26+
self.assertGreater(len(users), 0, "Expected at least one user from API")
27+
28+
usernames = {u.username for u in users}
29+
missing = expected_users - usernames
30+
self.assertFalse(missing, f"Expected user(s) missing from API: {sorted(missing)}")
31+

medcat-trainer/client/tests/test_mctclient.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,62 @@
22
import unittest
33
from unittest.mock import patch, MagicMock
44
from mctclient import (
5-
MedCATTrainerSession, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject
5+
MedCATTrainerSession, KeycloakSettings,
6+
MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject
67
)
78

89
class TestMCTClient(unittest.TestCase):
910

11+
@patch('mctclient.requests.post')
12+
def test_session_init_with_oidc_sets_bearer_header(self, mock_post):
13+
def post_side_effect(url, *args, **kwargs):
14+
if url.endswith('/protocol/openid-connect/token'):
15+
return MagicMock(status_code=200, json=lambda: {"access_token": "jwt"})
16+
return MagicMock(status_code=404, text='')
17+
18+
mock_post.side_effect = post_side_effect
19+
20+
session = MedCATTrainerSession(
21+
server='http://localhost',
22+
username='u',
23+
password='p',
24+
use_oidc=True,
25+
keycloak_settings=KeycloakSettings(
26+
keycloak_url="http://keycloak.example",
27+
realm="test-realm",
28+
client_id="client",
29+
username="kc-user",
30+
password="kc-pass",
31+
),
32+
)
33+
self.assertEqual(session.headers, {"Authorization": "Bearer jwt"})
34+
35+
@patch('mctclient.requests.post')
36+
def test_session_init_with_oidc_client_secret_uses_client_credentials_grant(self, mock_post):
37+
def post_side_effect(url, *args, **kwargs):
38+
if url.endswith('/protocol/openid-connect/token'):
39+
self.assertEqual(kwargs.get("data", {}).get("grant_type"), "client_credentials")
40+
self.assertEqual(kwargs.get("data", {}).get("client_id"), "client")
41+
self.assertEqual(kwargs.get("data", {}).get("client_secret"), "secret")
42+
return MagicMock(status_code=200, json=lambda: {"access_token": "jwt"})
43+
return MagicMock(status_code=404, text='')
44+
45+
mock_post.side_effect = post_side_effect
46+
47+
session = MedCATTrainerSession(
48+
server='http://localhost',
49+
username='u',
50+
password='p',
51+
use_oidc=True,
52+
keycloak_settings=KeycloakSettings(
53+
keycloak_url="http://keycloak.example",
54+
realm="test-realm",
55+
client_id="client",
56+
client_secret="secret",
57+
),
58+
)
59+
self.assertEqual(session.headers, {"Authorization": "Bearer jwt"})
60+
1061
@patch('mctclient.requests.post')
1162
@patch('mctclient.requests.get')
1263
def test_session_get_projects(self, mock_get, mock_post):

0 commit comments

Comments
 (0)