saamfi-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of saamfi-sdk might be problematic. Click here for more details.

saamfi_sdk/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """
2
+ Saamfi SDK for Python
3
+
4
+ Python SDK to connect to SAAMFI services - Authentication and Authorization.
5
+
6
+ This SDK is equivalent to saamfi-security (Java SDK) and provides the same
7
+ functionality for systems that require integration with the Saamfi service.
8
+
9
+ Basic usage:
10
+ >>> from saamfi_sdk import SaamfiClient
11
+ >>> client = SaamfiClient("https://saamfi.example.com", system_id=123)
12
+ >>> # Client is ready for authentication and token validation
13
+
14
+ Equivalent to: co.edu.icesi.dev.saamfi.saamfisecurity (Java package)
15
+ """
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ from .client import SaamfiClient
20
+ from .exceptions import (
21
+ SaamfiAuthenticationError,
22
+ SaamfiConnectionError,
23
+ SaamfiException,
24
+ SaamfiInvalidSystemError,
25
+ SaamfiNotFoundError,
26
+ SaamfiTokenValidationError,
27
+ SaamfiUnauthorizedError,
28
+ )
29
+ from .models import LoginBody, LoginResponse, UserDetailToken, UserInfo
30
+
31
+ __all__ = [
32
+ # Main client
33
+ "SaamfiClient",
34
+ # Models
35
+ "LoginBody",
36
+ "LoginResponse",
37
+ "UserInfo",
38
+ "UserDetailToken",
39
+ # Exceptions
40
+ "SaamfiException",
41
+ "SaamfiAuthenticationError",
42
+ "SaamfiTokenValidationError",
43
+ "SaamfiConnectionError",
44
+ "SaamfiInvalidSystemError",
45
+ "SaamfiUnauthorizedError",
46
+ "SaamfiNotFoundError",
47
+ # Metadata
48
+ "__version__",
49
+ ]
saamfi_sdk/client.py ADDED
@@ -0,0 +1,311 @@
1
+ """
2
+ Main client entry point for the Saamfi SDK.
3
+
4
+ This module now composes several service classes to keep responsibilities
5
+ focused and the public API unchanged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import requests
15
+ from cryptography.hazmat.primitives.asymmetric import rsa
16
+ from dotenv import load_dotenv
17
+
18
+ from .models import LoginResponse, UserDetailToken, UserInfo
19
+ from .services import (
20
+ AuthenticationService,
21
+ InstitutionService,
22
+ SystemService,
23
+ TokenService,
24
+ UserService,
25
+ )
26
+
27
+ # Load environment variables from .env file
28
+ load_dotenv()
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class SaamfiClient:
34
+ """
35
+ Main client for interacting with the Saamfi platform.
36
+
37
+ The class delegates most of the work to specialized service classes while
38
+ preserving the public interface exposed by previous versions.
39
+ """
40
+
41
+ ROLE_CLAIM = TokenService.ROLE_CLAIM
42
+ SYSTEM_CLAIM = TokenService.SYSTEM_CLAIM
43
+ USERNAME_CLAIM = TokenService.USERNAME_CLAIM
44
+ ID_CLAIM = TokenService.ID_CLAIM
45
+
46
+ def __init__(
47
+ self,
48
+ saamfi_url: Optional[str] = None,
49
+ system_id: Optional[int] = None,
50
+ client_id: Optional[int] = None,
51
+ client_secret: Optional[str] = None,
52
+ tenant_id: Optional[int] = None,
53
+ ):
54
+ """
55
+ Create a Saamfi client instance.
56
+
57
+ Args:
58
+ saamfi_url: Base URL of the Saamfi server. Falls back to the
59
+ `SAAMFI_BASE_URL`/`SAAMFI_URL` environment variables.
60
+ system_id: Tenant/system identifier. Falls back to the
61
+ `SAAMFI_SYS_ID`/`SAAMFI_SYSTEM_ID` environment variables.
62
+ client_id: OAuth-style client identifier. Falls back to the
63
+ `SAAMFI_CLIENT_ID` environment variable.
64
+ client_secret: Client secret used for confidential flows.
65
+ Falls back to `SAAMFI_CLIENT_SECRET`.
66
+ tenant_id: Institution/tenant identifier for institution-specific
67
+ requests. Falls back to `SAAMFI_TENANT_ID`/`SAAMFI_INST_ID`.
68
+
69
+ Raises:
70
+ ValueError: Missing or invalid configuration values.
71
+ """
72
+ resolved_url = saamfi_url or self._get_first_env("SAAMFI_BASE_URL", "SAAMFI_URL")
73
+ if not resolved_url:
74
+ raise ValueError(
75
+ "saamfi_url must be provided either as parameter or through "
76
+ "the SAAMFI_BASE_URL/SAAMFI_URL environment variables"
77
+ )
78
+
79
+ if system_id is None:
80
+ system_id_raw = self._get_first_env("SAAMFI_SYS_ID", "SAAMFI_SYSTEM_ID")
81
+ if system_id_raw is None:
82
+ raise ValueError(
83
+ "system_id must be provided either as parameter or through "
84
+ "the SAAMFI_SYS_ID/SAAMFI_SYSTEM_ID environment variables"
85
+ )
86
+ try:
87
+ system_id = int(system_id_raw)
88
+ except ValueError as exc:
89
+ raise ValueError(
90
+ "SAAMFI_SYS_ID/SAAMFI_SYSTEM_ID environment variables must contain a valid "
91
+ f"integer, got: {system_id_raw}"
92
+ ) from exc
93
+
94
+ if client_id is None:
95
+ client_id_raw = self._get_first_env("SAAMFI_CLIENT_ID")
96
+ if client_id_raw is not None:
97
+ try:
98
+ client_id = int(client_id_raw)
99
+ except ValueError as exc:
100
+ raise ValueError(
101
+ "SAAMFI_CLIENT_ID environment variable must be a valid integer, "
102
+ f"got: {client_id_raw}"
103
+ ) from exc
104
+
105
+ if tenant_id is None:
106
+ tenant_id_raw = self._get_first_env("SAAMFI_TENANT_ID", "SAAMFI_INST_ID")
107
+ if tenant_id_raw is not None:
108
+ try:
109
+ tenant_id = int(tenant_id_raw)
110
+ except ValueError as exc:
111
+ raise ValueError(
112
+ "SAAMFI_TENANT_ID/SAAMFI_INST_ID environment variables must contain a "
113
+ f"valid integer, got: {tenant_id_raw}"
114
+ ) from exc
115
+
116
+ self._saamfi_url = resolved_url.rstrip("/")
117
+ self._system_id = system_id
118
+ self._client_id = client_id
119
+ self._client_secret = client_secret or self._get_first_env("SAAMFI_CLIENT_SECRET")
120
+ self._tenant_id = tenant_id
121
+ self._session = requests.Session()
122
+
123
+ self._token_service = TokenService(self._saamfi_url, self._session)
124
+ self._auth_service = AuthenticationService(
125
+ self._saamfi_url,
126
+ self._session,
127
+ self._system_id,
128
+ self._token_service,
129
+ )
130
+ self._user_service = UserService(self._saamfi_url, self._session)
131
+ self._institution_service = InstitutionService(self._saamfi_url, self._session)
132
+ self._system_service = SystemService(self._saamfi_url, self._session)
133
+
134
+ self._public_key: Optional[bytes] = None
135
+
136
+ try:
137
+ self._public_key = self._token_service.fetch_public_key_bytes()
138
+ except Exception as exc: # pragma: no cover - initialization is best-effort
139
+ logger.warning("Error obtaining public key during initialization: %s", exc)
140
+
141
+ @staticmethod
142
+ def _get_first_env(*names: str) -> Optional[str]:
143
+ """Return the first non-empty environment variable among the provided names."""
144
+ for env_name in names:
145
+ value = os.getenv(env_name)
146
+ if value:
147
+ return value
148
+ return None
149
+
150
+ def _get_public_key(self) -> bytes:
151
+ """
152
+ Retrieve the cached raw public key (DER encoded) or fetch it from Saamfi.
153
+
154
+ Returns:
155
+ bytes: DER-encoded public key.
156
+
157
+ Raises:
158
+ SaamfiConnectionError: If the Saamfi service cannot be reached.
159
+ """
160
+ key_bytes = self._token_service.fetch_public_key_bytes()
161
+ self._public_key = key_bytes
162
+ return key_bytes
163
+
164
+ def get_public_key(self) -> rsa.RSAPublicKey:
165
+ """
166
+ Return the RSA public key used to validate Saamfi JWT tokens.
167
+
168
+ Raises:
169
+ SaamfiConnectionError: If the public key cannot be fetched or parsed.
170
+ """
171
+ public_key = self._token_service.get_rsa_public_key()
172
+ self._public_key = self._token_service.public_key_bytes
173
+ return public_key
174
+
175
+ @property
176
+ def saamfi_url(self) -> str:
177
+ """Base URL of the Saamfi server."""
178
+ return self._saamfi_url
179
+
180
+ @property
181
+ def system_id(self) -> int:
182
+ """Current tenant/system identifier."""
183
+ return self._system_id
184
+
185
+ @property
186
+ def client_id(self) -> Optional[int]:
187
+ """Client identifier configured for this SDK instance."""
188
+ return self._client_id
189
+
190
+ @property
191
+ def client_secret(self) -> Optional[str]:
192
+ """Client secret configured for this SDK instance."""
193
+ return self._client_secret
194
+
195
+ @property
196
+ def tenant_id(self) -> Optional[int]:
197
+ """Tenant/Institution identifier configured for this SDK instance."""
198
+ return self._tenant_id
199
+
200
+ def login(self, username: str, password: str) -> Optional[LoginResponse]:
201
+ """
202
+ Authenticate a user and return the login payload if successful.
203
+
204
+ Returns:
205
+ LoginResponse or None.
206
+
207
+ Raises:
208
+ SaamfiConnectionError: Request errors while contacting Saamfi.
209
+ """
210
+ return self._auth_service.login(username, password)
211
+
212
+ def get_roles_from_jwt(self, auth_token: str) -> List[str]:
213
+ """
214
+ Extract roles from a JWT token.
215
+
216
+ Returns:
217
+ List of role strings. Returns an empty list on any error.
218
+ """
219
+ return self._auth_service.get_roles_from_jwt(auth_token)
220
+
221
+ def validate_token(self, auth_token: str) -> UserDetailToken:
222
+ """
223
+ Validate a JWT token and return the associated user details.
224
+
225
+ Raises:
226
+ SaamfiTokenValidationError: Invalid or expired tokens.
227
+ """
228
+ return self._auth_service.validate_token(auth_token)
229
+
230
+ def get_user_info(self, auth_token: str, user_id: int) -> Optional[UserInfo]:
231
+ """
232
+ Retrieve complete user information by ID.
233
+
234
+ Returns:
235
+ UserInfo or None if the request fails or the user does not exist.
236
+ """
237
+ return self._user_service.get_user_info(auth_token, user_id)
238
+
239
+ def get_user_by_username(self, auth_token: str, username: str) -> Optional[Dict[str, Any]]:
240
+ """
241
+ Retrieve a user by username.
242
+
243
+ Raises:
244
+ SaamfiAuthenticationError: Invalid credentials (HTTP 401).
245
+ SaamfiConnectionError: Other request errors.
246
+ """
247
+ return self._user_service.get_user_by_username(auth_token, username)
248
+
249
+ def get_users_by_document(self, auth_token: str, user_documents: List[str]) -> List[Dict[str, Any]]:
250
+ """
251
+ Retrieve users by document numbers.
252
+
253
+ Raises:
254
+ SaamfiAuthenticationError: Invalid credentials (HTTP 401).
255
+ SaamfiConnectionError: Other request errors.
256
+ """
257
+ return self._user_service.get_users_by_document(auth_token, user_documents)
258
+
259
+ def get_users_from_list(self, auth_token: str, user_ids: List[int]) -> Optional[str]:
260
+ """Retrieve multiple users by their IDs."""
261
+ return self._user_service.get_users_from_list(auth_token, user_ids)
262
+
263
+ def get_users_by_param_and_value(self, auth_token: str, param: str, value: str) -> Optional[str]:
264
+ """Retrieve users using the generic param/value endpoint."""
265
+ return self._user_service.get_users_by_param_and_value(auth_token, param, value)
266
+
267
+ def get_institution_by_nit(self, auth_token: str, nit: str) -> Optional[str]:
268
+ """Retrieve institution metadata by NIT."""
269
+ return self._institution_service.get_institution_by_nit(auth_token, nit)
270
+
271
+ def get_institutions_by_ids(self, auth_token: str, institution_ids: List[int]) -> Optional[str]:
272
+ """Retrieve multiple institutions by ID list."""
273
+ return self._institution_service.get_institutions_by_ids(auth_token, institution_ids)
274
+
275
+ def list_public_institutions(self) -> Optional[List[Dict[str, Any]]]:
276
+ """Retrieve the list of public institutions exposed by Saamfi."""
277
+ return self._institution_service.list_public_institutions()
278
+
279
+ def get_institution_params(
280
+ self, auth_token: str, institution_id: Optional[int] = None
281
+ ) -> Optional[Dict[str, Any]]:
282
+ """Retrieve configuration parameters for an institution."""
283
+ target_institution = institution_id or self._tenant_id
284
+ if target_institution is None:
285
+ raise ValueError(
286
+ "institution_id must be provided either as parameter or configured via "
287
+ "SAAMFI_TENANT_ID/SAAMFI_INST_ID"
288
+ )
289
+ return self._institution_service.get_institution_params(auth_token, target_institution)
290
+
291
+ def list_public_systems(self) -> Optional[List[Dict[str, Any]]]:
292
+ """Retrieve the list of systems available in Saamfi."""
293
+ return self._system_service.list_public_systems()
294
+
295
+ def get_system_roles(
296
+ self, auth_token: str, system_id: Optional[int] = None
297
+ ) -> Optional[List[Dict[str, Any]]]:
298
+ """Retrieve roles configured in a system."""
299
+ target_system = system_id or self._system_id
300
+ return self._system_service.get_system_roles(auth_token, target_system)
301
+
302
+ def __repr__(self) -> str:
303
+ """Object representation for debugging."""
304
+ return (
305
+ "SaamfiClient("
306
+ f"saamfi_url='{self._saamfi_url}', "
307
+ f"system_id={self._system_id}, "
308
+ f"tenant_id={self._tenant_id}, "
309
+ f"client_id={self._client_id}"
310
+ ")"
311
+ )
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Custom exceptions for the Saamfi SDK.
5
+
6
+ This module defines exceptions that can be raised by the SDK
7
+ during interaction with the Saamfi service.
8
+ """
9
+
10
+
11
+ class SaamfiException(Exception):
12
+ """
13
+ Base exception for all Saamfi SDK exceptions.
14
+
15
+ Parameters
16
+ ----------
17
+ message:
18
+ Optional custom error message. When omitted, a class-specific default
19
+ message is used.
20
+ details:
21
+ Optional dictionary with extra information to aid debugging or error
22
+ handling.
23
+ """
24
+
25
+ default_message = "An unexpected Saamfi SDK error occurred."
26
+
27
+ def __init__(self, message: str | None = None, *, details: dict | None = None):
28
+ if message is None:
29
+ message = self.default_message
30
+ super().__init__(message)
31
+ self.details = details or {}
32
+
33
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
34
+ return f"{self.__class__.__name__}({self.args[0]!r}, details={self.details!r})"
35
+
36
+
37
+ class SaamfiAuthenticationError(SaamfiException):
38
+ """Exception raised when authentication with Saamfi fails."""
39
+
40
+ default_message = "Authentication with Saamfi failed due to invalid credentials."
41
+
42
+
43
+ class SaamfiTokenValidationError(SaamfiException):
44
+ """Exception raised when JWT token validation fails."""
45
+
46
+ default_message = "The provided Saamfi token is invalid or has expired."
47
+
48
+
49
+ class SaamfiConnectionError(SaamfiException):
50
+ """Exception raised when there are connection issues with the Saamfi server."""
51
+
52
+ default_message = "Unable to connect or communicate with the Saamfi service."
53
+
54
+
55
+ class SaamfiUnauthorizedError(SaamfiException):
56
+ """Exception raised when the caller lacks permissions for a Saamfi operation."""
57
+
58
+ default_message = "Operation not permitted: insufficient Saamfi permissions."
59
+
60
+
61
+ class SaamfiNotFoundError(SaamfiException):
62
+ """Exception raised when a requested Saamfi resource cannot be found."""
63
+
64
+ default_message = "The requested Saamfi resource could not be found."
65
+
66
+
67
+ class SaamfiInvalidSystemError(SaamfiException):
68
+ """Exception raised when the system_id in the token doesn't match the expected one."""
69
+
70
+ default_message = "The Saamfi token belongs to a different system than expected."
saamfi_sdk/models.py ADDED
@@ -0,0 +1,141 @@
1
+ """
2
+ Data models for the Saamfi SDK.
3
+
4
+ This module defines the data classes used for communication with the Saamfi service.
5
+ All models use Pydantic for data validation.
6
+ """
7
+
8
+ from typing import List, Optional
9
+
10
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field
11
+
12
+
13
+ class LoginBody(BaseModel):
14
+ """
15
+ DTO for login requests.
16
+
17
+ Equivalent to: LoginBody.java
18
+ """
19
+
20
+ model_config = ConfigDict(populate_by_name=True, str_strip_whitespace=True, extra="forbid")
21
+
22
+ username: str = Field(..., description="Username")
23
+ password: str = Field(..., description="User password")
24
+ system_id: int = Field(
25
+ ...,
26
+ description="System/tenant ID",
27
+ serialization_alias="sysid",
28
+ )
29
+
30
+
31
+ class LoginResponse(BaseModel):
32
+ """
33
+ DTO for successful authentication response.
34
+
35
+ Equivalent to: LoginResponse.java
36
+ """
37
+
38
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
39
+
40
+ id: int = Field(..., description="User ID")
41
+ username: str = Field(..., description="Username")
42
+ email: str = Field(..., description="Email address")
43
+ phone: Optional[str] = Field(None, description="Phone number")
44
+ name: str = Field(..., description="First name")
45
+ lastname: str = Field(..., description="Last name")
46
+ document_id: str = Field(
47
+ ...,
48
+ description="Document ID or ID card",
49
+ validation_alias=AliasChoices("documentId", "document_id"),
50
+ serialization_alias="documentId",
51
+ )
52
+ token: str = Field(
53
+ ...,
54
+ description="JWT access token",
55
+ validation_alias=AliasChoices("accessToken", "token"),
56
+ serialization_alias="accessToken",
57
+ )
58
+ token_type: str = Field(
59
+ ...,
60
+ description="Token type (e.g., Bearer)",
61
+ validation_alias=AliasChoices("tokenType", "token_type"),
62
+ serialization_alias="tokenType",
63
+ )
64
+ system_home_page: str = Field(
65
+ ...,
66
+ description="System home page URL",
67
+ validation_alias=AliasChoices("systemHomePage", "system_home_page"),
68
+ serialization_alias="systemHomePage",
69
+ )
70
+
71
+ @property
72
+ def access_token(self) -> str:
73
+ """Backwards compatibility with previous attribute name."""
74
+ return self.token
75
+
76
+
77
+ class UserInfo(BaseModel):
78
+ """
79
+ DTO for detailed user information.
80
+
81
+ Equivalent to: UserInfo.java
82
+ """
83
+
84
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
85
+
86
+ email: str = Field(..., description="Email address")
87
+ document_id: str = Field(
88
+ ...,
89
+ description="Document ID or ID card",
90
+ validation_alias=AliasChoices("documentId", "document_id"),
91
+ serialization_alias="documentId",
92
+ )
93
+ username: str = Field(..., description="Username")
94
+ name: str = Field(..., description="First name")
95
+ lastname: str = Field(..., description="Last name")
96
+ phone: Optional[str] = Field(None, description="Phone number")
97
+ password: Optional[str] = Field(None, description="Password (usually not returned)")
98
+ institution: Optional[int] = Field(None, description="Institution ID")
99
+ institution_name: Optional[str] = Field(
100
+ None,
101
+ description="Institution name",
102
+ validation_alias=AliasChoices("institutionName", "institution_name"),
103
+ serialization_alias="institutionName",
104
+ )
105
+ system: Optional[int] = Field(None, description="System ID")
106
+ is_active: Optional[str] = Field(
107
+ None,
108
+ description="User active status",
109
+ validation_alias=AliasChoices("isActive", "is_active"),
110
+ serialization_alias="isActive",
111
+ )
112
+
113
+
114
+ class UserDetailToken(BaseModel):
115
+ """
116
+ User information extracted from JWT token.
117
+
118
+ Equivalent to: UserDetailToken.java (Spring Security UserDetails implementation)
119
+
120
+ In Python we don't need to implement an interface like UserDetails,
121
+ but we maintain the same data structure.
122
+ """
123
+
124
+ model_config = ConfigDict(populate_by_name=True, extra="ignore")
125
+
126
+ username: str = Field(..., description="Username")
127
+ system: int = Field(..., description="System ID")
128
+ pers_id: str = Field(
129
+ ...,
130
+ description="Person ID",
131
+ validation_alias=AliasChoices("persId", "pers_id"),
132
+ serialization_alias="persId",
133
+ )
134
+ roles: List[str] = Field(default_factory=list, description="User roles and permissions")
135
+
136
+ def __str__(self) -> str:
137
+ """String representation of the object, equivalent to Java's toString()."""
138
+ return (
139
+ f"UserDetailToken [persId={self.pers_id}, roles={self.roles}, "
140
+ f"system={self.system}, username={self.username}]"
141
+ )
saamfi_sdk/py.typed ADDED
@@ -0,0 +1,2 @@
1
+
2
+
@@ -0,0 +1,18 @@
1
+ """Service-layer helpers used by the Saamfi client."""
2
+
3
+ from .auth import AuthenticationService
4
+ from .base import SaamfiServiceBase
5
+ from .institutions import InstitutionService
6
+ from .systems import SystemService
7
+ from .token import TokenService
8
+ from .users import UserService
9
+
10
+ __all__ = [
11
+ "AuthenticationService",
12
+ "InstitutionService",
13
+ "SystemService",
14
+ "SaamfiServiceBase",
15
+ "TokenService",
16
+ "UserService",
17
+ ]
18
+
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Optional
5
+
6
+ from saamfi_sdk.exceptions import (
7
+ SaamfiAuthenticationError,
8
+ SaamfiConnectionError,
9
+ SaamfiTokenValidationError,
10
+ )
11
+ from saamfi_sdk.models import LoginBody, LoginResponse, UserDetailToken
12
+
13
+ from .base import SaamfiServiceBase
14
+ from .token import TokenService
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class AuthenticationService(SaamfiServiceBase):
20
+ """Authentication-related operations."""
21
+
22
+ def __init__(
23
+ self,
24
+ base_url: str,
25
+ session,
26
+ system_id: int,
27
+ token_service: TokenService,
28
+ ):
29
+ super().__init__(base_url, session)
30
+ self._system_id = system_id
31
+ self._token_service = token_service
32
+
33
+ def login(self, username: str, password: str) -> Optional[LoginResponse]:
34
+ """Authenticate a user and return the login response."""
35
+ login_body = LoginBody(username=username, password=password, system_id=self._system_id)
36
+
37
+ try:
38
+ response = self._post(
39
+ "public/authentication/login",
40
+ json=login_body.model_dump(by_alias=True),
41
+ timeout=30,
42
+ )
43
+ except Exception as exc:
44
+ logger.error("Error in authentication request: %s", exc)
45
+ raise SaamfiConnectionError(f"Error during authentication: {exc}") from exc
46
+
47
+ if self._is_success(response.status_code):
48
+ try:
49
+ login_response = LoginResponse(**response.json())
50
+ except Exception as exc:
51
+ logger.warning("Unexpected login response payload: %s", exc)
52
+ return None
53
+
54
+ logger.info("User '%s' authenticated successfully", username)
55
+ return login_response
56
+
57
+ if response.status_code == 401:
58
+ logger.warning("Authentication failed for user '%s': 401", username)
59
+ return None
60
+
61
+ logger.warning("Authentication failed for user '%s': %s", username, response.status_code)
62
+ return None
63
+
64
+ def get_roles_from_jwt(self, auth_token: str) -> list[str]:
65
+ """Return roles stored in the JWT token."""
66
+ return self._token_service.extract_roles(auth_token)
67
+
68
+ def validate_token(self, auth_token: str) -> UserDetailToken:
69
+ """Validate a JWT token and return user details."""
70
+ decoded_token = self._token_service.validate_token(auth_token)
71
+
72
+ try:
73
+ username = str(decoded_token.get(TokenService.USERNAME_CLAIM))
74
+ system = int(decoded_token.get(TokenService.SYSTEM_CLAIM))
75
+ pers_id = str(decoded_token.get(TokenService.ID_CLAIM))
76
+ except (TypeError, ValueError, KeyError) as exc:
77
+ logger.error("Missing required claim in token: %s", exc)
78
+ raise SaamfiTokenValidationError(f"Missing required claim in token: {exc}") from exc
79
+
80
+ roles = self.get_roles_from_jwt(auth_token)
81
+
82
+ user_detail = UserDetailToken(
83
+ username=username,
84
+ system=system,
85
+ pers_id=pers_id,
86
+ roles=roles,
87
+ )
88
+ logger.info("Token validated successfully for user '%s'", username)
89
+ return user_detail
90
+