-
Notifications
You must be signed in to change notification settings - Fork 13
feat(medcat-trainer-client): Support OIDC in medcat trainer client #428
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
Changes from 2 commits
5e08506
a01d189
dbbe7d2
c4b03e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,14 +3,65 @@ | |
| import json | ||
| import os | ||
| from abc import ABC | ||
| from typing import Any, Dict, List, Tuple, Union | ||
| from typing import Any, Dict, List, Optional, Tuple, Union | ||
|
|
||
| import requests | ||
|
|
||
| import logging | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| @dataclass | ||
| class KeycloakSettings: | ||
| """ | ||
| Keycloak settings for OIDC token retrieval. | ||
|
|
||
| If a field is not provided, it falls back to environment variables and then | ||
| the same defaults used by `webapp/scripts/load_examples.py`. | ||
| """ | ||
|
|
||
| keycloak_url: Optional[str] = None | ||
| realm: Optional[str] = None | ||
| client_id: Optional[str] = None | ||
| client_secret: Optional[str] = None | ||
| username: Optional[str] = None | ||
| password: Optional[str] = None | ||
| scope: str = "openid profile email" | ||
|
|
||
| def __post_init__(self): | ||
| self.keycloak_url = self.keycloak_url or os.environ.get("KEYCLOAK_URL", "http://keycloak.cogstack.localhost") | ||
| self.realm = self.realm or os.environ.get("KEYCLOAK_REALM", "cogstack-realm") | ||
| self.client_id = self.client_id or os.environ.get("KEYCLOAK_CLIENT_ID", "cogstack-medcattrainer-frontend") | ||
| self.client_secret = self.client_secret or os.environ.get("KEYCLOAK_CLIENT_SECRET") | ||
| self.username = self.username or os.environ.get("KEYCLOAK_USERNAME", "admin") | ||
| self.password = self.password or os.environ.get("KEYCLOAK_PASSWORD", "admin") | ||
|
|
||
|
|
||
| def get_keycloak_access_token(settings: KeycloakSettings) -> str: | ||
| token_url = f"{settings.keycloak_url}/realms/{settings.realm}/protocol/openid-connect/token" | ||
| if settings.client_secret: | ||
| data = { | ||
| "grant_type": "client_credentials", | ||
| "client_id": settings.client_id, | ||
| "client_secret": settings.client_secret, | ||
| "scope": settings.scope, | ||
| } | ||
| else: | ||
| data = { | ||
| "grant_type": "password", | ||
| "client_id": settings.client_id, | ||
| "username": settings.username, | ||
| "password": settings.password, | ||
| "scope": settings.scope, | ||
| } | ||
| try: | ||
| logger.info(f"Getting Keycloak access token from {token_url} with data: {data}") | ||
Check failureCode scanning / CodeQL Clear-text logging of sensitive information High
This expression logs
sensitive data (secret) Error loading related location Loading This expression logs sensitive data (password) Error loading related location Loading |
||
| resp = requests.post(token_url, data=data) | ||
| resp.raise_for_status() | ||
| return resp.json()["access_token"] | ||
| except Exception as e: | ||
| raise MCTUtilsException("Failed to get Keycloak access token", e) | ||
|
|
||
|
|
||
| @dataclass | ||
| class MCTObj(ABC): | ||
|
|
@@ -224,7 +275,7 @@ | |
| >>> annotations = session.get_project_annos(projects[0]) | ||
| """ | ||
|
|
||
| def __init__(self, server=None, username=None, password=None): | ||
| def __init__(self, server=None, username=None, password=None, keycloak_settings=None, use_oidc: bool = False): | ||
| """Initialize the MedCATTrainerSession. | ||
|
|
||
| Args: | ||
|
|
@@ -237,15 +288,31 @@ | |
| self.password = password or os.getenv("MCTRAINER_PASSWORD") | ||
| self.server = server or 'http://localhost:8001' | ||
|
|
||
| env_use_oidc = os.getenv("MCTRAINER_USE_OIDC", "") | ||
| env_use_oidc_truthy = env_use_oidc.strip() == "1" | ||
| effective_use_oidc = bool(use_oidc) or bool(env_use_oidc_truthy) | ||
|
|
||
| if effective_use_oidc: | ||
| if keycloak_settings is None: | ||
| kc_settings = KeycloakSettings() | ||
| else: | ||
| if not isinstance(keycloak_settings, KeycloakSettings): | ||
| raise TypeError("keycloak_settings must be a KeycloakSettings instance") | ||
| kc_settings = keycloak_settings | ||
|
|
||
| token = get_keycloak_access_token(kc_settings) | ||
| self.headers = {"Authorization": f"Bearer {token}"} | ||
| return | ||
|
Collaborator
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 feel like the early return could bite us in the bum later down the road. |
||
|
|
||
| payload = {"username": self.username, "password": self.password} | ||
| resp = requests.post(f"{self.server}/api/api-token-auth/", json=payload) | ||
| if 200 <= resp.status_code < 300: | ||
| token = json.loads(resp.text)["token"] | ||
| self.headers = { | ||
| 'Authorization': f'Token {token}', | ||
| "Authorization": f"Token {token}", | ||
| } | ||
| else: | ||
| raise MCTUtilsException(f'Failed to login to MedCATtrainer instance running at: {self.server}') | ||
| raise MCTUtilsException(f"Failed to login to MedCATtrainer instance running at: {self.server}") | ||
|
|
||
| def create_project(self, name: str, | ||
| description: str, | ||
|
|
@@ -482,8 +549,21 @@ | |
| Returns: | ||
| List[MCTUser]: A list of all users in the MedCATTrainer instance | ||
| """ | ||
| users = json.loads(requests.get(f'{self.server}/api/users/', headers=self.headers).text)['results'] | ||
| return [MCTUser(id=u['id'], username=u['username']) for u in users] | ||
| resp = requests.get(f"{self.server}/api/users/", headers=self.headers) | ||
| if not (200 <= resp.status_code < 300): | ||
| raise MCTUtilsException( | ||
| f"Failed to get users from MedCATTrainer instance running at: {self.server}", | ||
| f"HTTP {resp.status_code}: {resp.text[:500]}", | ||
| ) | ||
| try: | ||
| payload = resp.json() | ||
| except Exception as e: | ||
| raise MCTUtilsException( | ||
| f"Failed to parse users response from MedCATTrainer instance running at: {self.server}", | ||
| e, | ||
| ) | ||
| users = payload.get("results", []) | ||
| return [MCTUser(id=u.get("id"), username=u.get("username")) for u in users] | ||
|
|
||
| def get_models(self) -> Tuple[List[str], List[str]]: | ||
| """Get all MedCAT cdb and vocab models in the MedCATTrainer instance. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import os | ||
| import unittest | ||
|
|
||
| from mctclient import MedCATTrainerSession | ||
|
|
||
|
|
||
| class TestMCTClientIntegration(unittest.TestCase): | ||
| @unittest.skipUnless( | ||
| os.getenv("MCTRAINER_SERVER") and os.getenv("MCTRAINER_USERNAME") and os.getenv("MCTRAINER_PASSWORD"), | ||
| "Integration test requires MCTRAINER_SERVER, MCTRAINER_USERNAME, MCTRAINER_PASSWORD", | ||
| ) | ||
| def test_get_users_contains_expected_users(self): | ||
| server = os.getenv("MCTRAINER_SERVER").rstrip("/") | ||
| username = os.getenv("MCTRAINER_USERNAME") | ||
| password = os.getenv("MCTRAINER_PASSWORD") | ||
| expected_users_env = os.getenv("MCTRAINER_EXPECTED_USERS", "") | ||
|
|
||
| expected_users = {username} | ||
| if expected_users_env: | ||
| expected_users |= {u.strip() for u in expected_users_env.split(",") if u.strip()} | ||
|
|
||
| session = MedCATTrainerSession(server=server, username=username, password=password) | ||
| users = session.get_users() | ||
|
|
||
| self.assertIsInstance(users, list) | ||
| self.assertGreater(len(users), 0, "Expected at least one user from API") | ||
|
|
||
| usernames = {u.username for u in users} | ||
| missing = expected_users - usernames | ||
| self.assertFalse(missing, f"Expected user(s) missing from API: {sorted(missing)}") | ||
|
|
Uh oh!
There was an error while loading. Please reload this page.