mcp-eregistrations-bpa 0.8.5__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 mcp-eregistrations-bpa might be problematic. Click here for more details.
- mcp_eregistrations_bpa/__init__.py +121 -0
- mcp_eregistrations_bpa/__main__.py +6 -0
- mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
- mcp_eregistrations_bpa/arazzo/expression.py +379 -0
- mcp_eregistrations_bpa/audit/__init__.py +56 -0
- mcp_eregistrations_bpa/audit/context.py +66 -0
- mcp_eregistrations_bpa/audit/logger.py +236 -0
- mcp_eregistrations_bpa/audit/models.py +131 -0
- mcp_eregistrations_bpa/auth/__init__.py +64 -0
- mcp_eregistrations_bpa/auth/callback.py +391 -0
- mcp_eregistrations_bpa/auth/cas.py +409 -0
- mcp_eregistrations_bpa/auth/oidc.py +252 -0
- mcp_eregistrations_bpa/auth/permissions.py +162 -0
- mcp_eregistrations_bpa/auth/token_manager.py +348 -0
- mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
- mcp_eregistrations_bpa/bpa_client/client.py +740 -0
- mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
- mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
- mcp_eregistrations_bpa/bpa_client/models.py +203 -0
- mcp_eregistrations_bpa/config.py +349 -0
- mcp_eregistrations_bpa/db/__init__.py +21 -0
- mcp_eregistrations_bpa/db/connection.py +64 -0
- mcp_eregistrations_bpa/db/migrations.py +168 -0
- mcp_eregistrations_bpa/exceptions.py +39 -0
- mcp_eregistrations_bpa/py.typed +0 -0
- mcp_eregistrations_bpa/rollback/__init__.py +19 -0
- mcp_eregistrations_bpa/rollback/manager.py +616 -0
- mcp_eregistrations_bpa/server.py +152 -0
- mcp_eregistrations_bpa/tools/__init__.py +372 -0
- mcp_eregistrations_bpa/tools/actions.py +155 -0
- mcp_eregistrations_bpa/tools/analysis.py +352 -0
- mcp_eregistrations_bpa/tools/audit.py +399 -0
- mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
- mcp_eregistrations_bpa/tools/bots.py +627 -0
- mcp_eregistrations_bpa/tools/classifications.py +575 -0
- mcp_eregistrations_bpa/tools/costs.py +765 -0
- mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
- mcp_eregistrations_bpa/tools/debugger.py +1230 -0
- mcp_eregistrations_bpa/tools/determinants.py +2235 -0
- mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
- mcp_eregistrations_bpa/tools/export.py +899 -0
- mcp_eregistrations_bpa/tools/fields.py +162 -0
- mcp_eregistrations_bpa/tools/form_errors.py +36 -0
- mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
- mcp_eregistrations_bpa/tools/forms.py +1269 -0
- mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
- mcp_eregistrations_bpa/tools/large_response.py +163 -0
- mcp_eregistrations_bpa/tools/messages.py +523 -0
- mcp_eregistrations_bpa/tools/notifications.py +241 -0
- mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
- mcp_eregistrations_bpa/tools/registrations.py +897 -0
- mcp_eregistrations_bpa/tools/role_status.py +447 -0
- mcp_eregistrations_bpa/tools/role_units.py +400 -0
- mcp_eregistrations_bpa/tools/roles.py +1236 -0
- mcp_eregistrations_bpa/tools/rollback.py +335 -0
- mcp_eregistrations_bpa/tools/services.py +674 -0
- mcp_eregistrations_bpa/tools/workflows.py +2487 -0
- mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
- mcp_eregistrations_bpa/workflows/__init__.py +28 -0
- mcp_eregistrations_bpa/workflows/loader.py +440 -0
- mcp_eregistrations_bpa/workflows/models.py +336 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Permission enforcement for MCP tools.
|
|
2
|
+
|
|
3
|
+
This module provides guards and utility functions to enforce
|
|
4
|
+
BPA permissions before tool execution. All permission checks
|
|
5
|
+
are server-side and based on JWT token claims.
|
|
6
|
+
|
|
7
|
+
Usage in MCP tools:
|
|
8
|
+
@mcp.tool()
|
|
9
|
+
async def service_create(name: str) -> dict[str, object]:
|
|
10
|
+
# Ensure write permission before proceeding
|
|
11
|
+
access_token = await ensure_write_permission()
|
|
12
|
+
# ... use access_token for BPA API call
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from mcp_eregistrations_bpa.auth.token_manager import TokenManager
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"PERMISSION_VIEWER",
|
|
29
|
+
"PERMISSION_SERVICE_DESIGNER",
|
|
30
|
+
"WRITE_PERMISSIONS",
|
|
31
|
+
"ensure_authenticated",
|
|
32
|
+
"ensure_write_permission",
|
|
33
|
+
"check_permission",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Permission constants
|
|
37
|
+
PERMISSION_VIEWER = "viewer"
|
|
38
|
+
PERMISSION_SERVICE_DESIGNER = "service_designer"
|
|
39
|
+
|
|
40
|
+
# Roles that allow write operations
|
|
41
|
+
WRITE_PERMISSIONS: list[str] = [PERMISSION_SERVICE_DESIGNER]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_token_manager() -> TokenManager:
|
|
45
|
+
"""Get the global token manager instance.
|
|
46
|
+
|
|
47
|
+
Uses late import to avoid circular dependency with server.py.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The global TokenManager instance.
|
|
51
|
+
"""
|
|
52
|
+
from mcp_eregistrations_bpa.server import get_token_manager as _get_tm
|
|
53
|
+
|
|
54
|
+
return _get_tm()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def ensure_authenticated() -> str:
|
|
58
|
+
"""Ensure user is authenticated and return access token.
|
|
59
|
+
|
|
60
|
+
Automatically triggers browser-based login if not authenticated.
|
|
61
|
+
Checks authentication status and token expiry. Calls
|
|
62
|
+
token_manager.get_access_token() which handles automatic
|
|
63
|
+
refresh if the token is near expiry.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Valid access token for BPA API calls.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ToolError: If authentication fails or token expired and cannot refresh.
|
|
70
|
+
"""
|
|
71
|
+
token_manager = get_token_manager()
|
|
72
|
+
logger.debug("Checking authentication status...")
|
|
73
|
+
|
|
74
|
+
# Auto-trigger login if not authenticated
|
|
75
|
+
if not token_manager.is_authenticated():
|
|
76
|
+
logger.info("Not authenticated - initiating browser login flow")
|
|
77
|
+
from mcp_eregistrations_bpa.config import AuthProvider, load_config
|
|
78
|
+
from mcp_eregistrations_bpa.exceptions import AuthenticationError
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
config = load_config()
|
|
82
|
+
if config.auth_provider == AuthProvider.CAS:
|
|
83
|
+
from mcp_eregistrations_bpa.auth.cas import perform_cas_browser_login
|
|
84
|
+
|
|
85
|
+
result = await perform_cas_browser_login()
|
|
86
|
+
else:
|
|
87
|
+
from mcp_eregistrations_bpa.auth.oidc import perform_browser_login
|
|
88
|
+
|
|
89
|
+
result = await perform_browser_login()
|
|
90
|
+
|
|
91
|
+
if result.get("error"):
|
|
92
|
+
logger.error("Authentication failed: %s", result.get("message"))
|
|
93
|
+
raise ToolError(str(result.get("message", "Authentication failed")))
|
|
94
|
+
logger.info("Authentication successful for user: %s", result.get("user"))
|
|
95
|
+
except AuthenticationError as e:
|
|
96
|
+
logger.error("Authentication error: %s", e)
|
|
97
|
+
raise ToolError(str(e))
|
|
98
|
+
|
|
99
|
+
# Check for expired token (shouldn't happen right after login, but handle it)
|
|
100
|
+
if token_manager.is_token_expired():
|
|
101
|
+
logger.warning("Token expired - user needs to re-authenticate")
|
|
102
|
+
raise ToolError("Session expired. Please run auth_login again.")
|
|
103
|
+
|
|
104
|
+
logger.debug("User authenticated: %s", token_manager.user_email)
|
|
105
|
+
# This will auto-refresh if within 5 minutes of expiry
|
|
106
|
+
return await token_manager.get_access_token()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def ensure_write_permission() -> str:
|
|
110
|
+
"""Ensure user has write permission and return access token.
|
|
111
|
+
|
|
112
|
+
Combines authentication check with write permission verification.
|
|
113
|
+
Checks if user has any role in WRITE_PERMISSIONS.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Valid access token for BPA API calls.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ToolError: If not authenticated or lacks write permission.
|
|
120
|
+
"""
|
|
121
|
+
# First ensure authenticated (raises if not)
|
|
122
|
+
token = await ensure_authenticated()
|
|
123
|
+
|
|
124
|
+
# Check for write permission
|
|
125
|
+
token_manager = get_token_manager()
|
|
126
|
+
user_permissions = set(token_manager.permissions)
|
|
127
|
+
|
|
128
|
+
if not user_permissions.intersection(WRITE_PERMISSIONS):
|
|
129
|
+
raise ToolError(
|
|
130
|
+
"Permission denied: You don't have write access. "
|
|
131
|
+
"Contact your administrator."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return token
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_permission(required_permission: str) -> None:
|
|
138
|
+
"""Check if user has a specific permission.
|
|
139
|
+
|
|
140
|
+
This is a synchronous check that also validates authentication status.
|
|
141
|
+
Raises ToolError if not authenticated or if permission is missing.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
required_permission: The permission role required.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ToolError: If not authenticated or user lacks the required permission.
|
|
148
|
+
"""
|
|
149
|
+
token_manager = get_token_manager()
|
|
150
|
+
|
|
151
|
+
# Verify authentication first (sync check - doesn't refresh)
|
|
152
|
+
if not token_manager.is_authenticated():
|
|
153
|
+
raise ToolError("Not authenticated. Run auth_login first.")
|
|
154
|
+
|
|
155
|
+
if token_manager.is_token_expired():
|
|
156
|
+
raise ToolError("Session expired. Please run auth_login again.")
|
|
157
|
+
|
|
158
|
+
if required_permission not in token_manager.permissions:
|
|
159
|
+
raise ToolError(
|
|
160
|
+
f"Permission denied: You need '{required_permission}' permission. "
|
|
161
|
+
"Contact your administrator."
|
|
162
|
+
)
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""In-memory token storage and automatic refresh.
|
|
2
|
+
|
|
3
|
+
This module handles token lifecycle management including storage,
|
|
4
|
+
refresh, and expiry checks. Tokens are stored in memory only
|
|
5
|
+
(never persisted to disk) per NFR2 and NFR6.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from mcp_eregistrations_bpa.exceptions import AuthenticationError
|
|
16
|
+
|
|
17
|
+
# Refresh tokens 5 minutes before expiry (NFR3)
|
|
18
|
+
REFRESH_THRESHOLD_SECONDS = 300
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TokenResponse(BaseModel):
|
|
22
|
+
"""Response from token endpoint."""
|
|
23
|
+
|
|
24
|
+
access_token: str
|
|
25
|
+
refresh_token: str | None = None
|
|
26
|
+
expires_in: int
|
|
27
|
+
token_type: str = "Bearer"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TokenManager:
|
|
31
|
+
"""In-memory token storage with automatic refresh.
|
|
32
|
+
|
|
33
|
+
Tokens are stored in memory only and never persisted to disk.
|
|
34
|
+
Automatic refresh is triggered when the access token is within
|
|
35
|
+
5 minutes of expiry (NFR3).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
"""Initialize token manager with empty state."""
|
|
40
|
+
self._access_token: str | None = None
|
|
41
|
+
self._refresh_token: str | None = None
|
|
42
|
+
self._expires_at: datetime | None = None
|
|
43
|
+
self._token_endpoint: str | None = None
|
|
44
|
+
self._client_id: str | None = None
|
|
45
|
+
self._cas_client_secret: str | None = None # CAS uses Basic Auth, no PKCE
|
|
46
|
+
self._user_email: str | None = None
|
|
47
|
+
self._permissions: list[str] = []
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def user_email(self) -> str | None:
|
|
51
|
+
"""Get the authenticated user's email."""
|
|
52
|
+
return self._user_email
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def expires_in_minutes(self) -> int:
|
|
56
|
+
"""Get remaining session time in minutes."""
|
|
57
|
+
if self._expires_at is None:
|
|
58
|
+
return 0
|
|
59
|
+
remaining = self._expires_at - datetime.now(UTC)
|
|
60
|
+
return max(0, int(remaining.total_seconds() / 60))
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def permissions(self) -> list[str]:
|
|
64
|
+
"""Get the authenticated user's permissions/roles.
|
|
65
|
+
|
|
66
|
+
Returns a copy to prevent external mutation of internal state.
|
|
67
|
+
"""
|
|
68
|
+
return list(self._permissions)
|
|
69
|
+
|
|
70
|
+
def is_token_expired(self) -> bool:
|
|
71
|
+
"""Check if a stored token has already expired (without triggering refresh).
|
|
72
|
+
|
|
73
|
+
This method should only be called after verifying is_authenticated() is True.
|
|
74
|
+
When no token exists (_expires_at is None), returns False because there's
|
|
75
|
+
nothing to consider "expired" - use is_authenticated() to check presence.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if token exists AND is expired, False otherwise.
|
|
79
|
+
"""
|
|
80
|
+
if self._expires_at is None:
|
|
81
|
+
return False
|
|
82
|
+
return datetime.now(UTC) >= self._expires_at
|
|
83
|
+
|
|
84
|
+
def is_authenticated(self) -> bool:
|
|
85
|
+
"""Check if we have valid tokens.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
True if we have tokens (may need refresh), False if not authenticated.
|
|
89
|
+
"""
|
|
90
|
+
return self._access_token is not None
|
|
91
|
+
|
|
92
|
+
def store_tokens(
|
|
93
|
+
self,
|
|
94
|
+
access_token: str,
|
|
95
|
+
refresh_token: str | None,
|
|
96
|
+
expires_in: int,
|
|
97
|
+
token_endpoint: str | None = None,
|
|
98
|
+
client_id: str | None = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Store tokens in memory.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
access_token: The access token from Keycloak.
|
|
104
|
+
refresh_token: The refresh token from Keycloak.
|
|
105
|
+
expires_in: Token lifetime in seconds.
|
|
106
|
+
token_endpoint: The token endpoint for refresh (optional).
|
|
107
|
+
client_id: The client ID for refresh (optional).
|
|
108
|
+
"""
|
|
109
|
+
self._access_token = access_token
|
|
110
|
+
self._refresh_token = refresh_token
|
|
111
|
+
self._expires_at = datetime.now(UTC) + timedelta(seconds=expires_in)
|
|
112
|
+
|
|
113
|
+
if token_endpoint:
|
|
114
|
+
self._token_endpoint = token_endpoint
|
|
115
|
+
if client_id:
|
|
116
|
+
self._client_id = client_id
|
|
117
|
+
|
|
118
|
+
# Extract user email and permissions from access token
|
|
119
|
+
self._user_email = self._extract_email_from_token(access_token)
|
|
120
|
+
self._permissions = self._extract_permissions_from_token(access_token)
|
|
121
|
+
|
|
122
|
+
def clear_tokens(self) -> None:
|
|
123
|
+
"""Clear all stored tokens (logout)."""
|
|
124
|
+
self._access_token = None
|
|
125
|
+
self._refresh_token = None
|
|
126
|
+
self._expires_at = None
|
|
127
|
+
self._user_email = None
|
|
128
|
+
self._permissions = []
|
|
129
|
+
|
|
130
|
+
def _needs_refresh(self) -> bool:
|
|
131
|
+
"""Check if token needs refresh (within 5 minutes of expiry).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if token should be refreshed, False otherwise.
|
|
135
|
+
"""
|
|
136
|
+
if self._expires_at is None:
|
|
137
|
+
return False
|
|
138
|
+
threshold = datetime.now(UTC) + timedelta(seconds=REFRESH_THRESHOLD_SECONDS)
|
|
139
|
+
return self._expires_at <= threshold
|
|
140
|
+
|
|
141
|
+
async def get_access_token(self) -> str:
|
|
142
|
+
"""Get valid access token, refreshing if needed.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A valid access token.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
AuthenticationError: If not authenticated or refresh fails.
|
|
149
|
+
"""
|
|
150
|
+
if self._access_token is None:
|
|
151
|
+
raise AuthenticationError("Not authenticated. Run auth_login first.")
|
|
152
|
+
|
|
153
|
+
if self._needs_refresh():
|
|
154
|
+
await self._refresh()
|
|
155
|
+
|
|
156
|
+
if self._access_token is None:
|
|
157
|
+
raise AuthenticationError("Session expired. Please run auth_login again.")
|
|
158
|
+
|
|
159
|
+
return self._access_token
|
|
160
|
+
|
|
161
|
+
async def _refresh(self) -> None:
|
|
162
|
+
"""Refresh the access token using refresh token.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
AuthenticationError: If refresh fails.
|
|
166
|
+
"""
|
|
167
|
+
if self._refresh_token is None:
|
|
168
|
+
raise AuthenticationError("Session expired. Please run auth_login again.")
|
|
169
|
+
|
|
170
|
+
if self._token_endpoint is None or self._client_id is None:
|
|
171
|
+
raise AuthenticationError("Session expired. Please run auth_login again.")
|
|
172
|
+
|
|
173
|
+
async with httpx.AsyncClient() as client:
|
|
174
|
+
try:
|
|
175
|
+
response = await client.post(
|
|
176
|
+
self._token_endpoint,
|
|
177
|
+
data={
|
|
178
|
+
"grant_type": "refresh_token",
|
|
179
|
+
"refresh_token": self._refresh_token,
|
|
180
|
+
"client_id": self._client_id,
|
|
181
|
+
},
|
|
182
|
+
timeout=10.0,
|
|
183
|
+
)
|
|
184
|
+
response.raise_for_status()
|
|
185
|
+
except httpx.HTTPStatusError as e:
|
|
186
|
+
# Refresh token is invalid/expired
|
|
187
|
+
self.clear_tokens()
|
|
188
|
+
raise AuthenticationError(
|
|
189
|
+
"Session expired. Please run auth_login again."
|
|
190
|
+
) from e
|
|
191
|
+
except httpx.RequestError as e:
|
|
192
|
+
raise AuthenticationError(
|
|
193
|
+
f"Cannot refresh session: {e}. Please try again."
|
|
194
|
+
) from e
|
|
195
|
+
|
|
196
|
+
data = response.json()
|
|
197
|
+
self.store_tokens(
|
|
198
|
+
access_token=data["access_token"],
|
|
199
|
+
refresh_token=data.get("refresh_token", self._refresh_token),
|
|
200
|
+
expires_in=data["expires_in"],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _decode_jwt_payload(self, token: str) -> dict[str, object] | None:
|
|
204
|
+
"""Decode and return JWT payload claims.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
token: The JWT access token.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Decoded claims dict, or None if decoding fails.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
# JWT is three base64-encoded parts separated by dots
|
|
214
|
+
parts = token.split(".")
|
|
215
|
+
if len(parts) != 3:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# Decode the payload (second part)
|
|
219
|
+
payload = parts[1]
|
|
220
|
+
# Add padding if needed
|
|
221
|
+
padding = 4 - len(payload) % 4
|
|
222
|
+
if padding != 4:
|
|
223
|
+
payload += "=" * padding
|
|
224
|
+
|
|
225
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
226
|
+
claims: dict[str, object] = json.loads(decoded)
|
|
227
|
+
return claims
|
|
228
|
+
except Exception:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
def _extract_email_from_token(self, token: str) -> str | None:
|
|
232
|
+
"""Extract user email from JWT access token.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
token: The JWT access token.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
The user email if found, None otherwise.
|
|
239
|
+
"""
|
|
240
|
+
claims = self._decode_jwt_payload(token)
|
|
241
|
+
if claims is None:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
# Try common email claim names
|
|
245
|
+
email = claims.get("email") or claims.get("preferred_username")
|
|
246
|
+
return str(email) if email else None
|
|
247
|
+
|
|
248
|
+
def _extract_permissions_from_token(self, token: str) -> list[str]:
|
|
249
|
+
"""Extract permissions/roles from JWT access token.
|
|
250
|
+
|
|
251
|
+
Parses Keycloak standard claims for realm and resource access roles.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
token: The JWT access token.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
List of role names from realm_access and resource_access claims.
|
|
258
|
+
"""
|
|
259
|
+
claims = self._decode_jwt_payload(token)
|
|
260
|
+
if claims is None:
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
roles: list[str] = []
|
|
264
|
+
|
|
265
|
+
# Extract realm roles (Keycloak standard)
|
|
266
|
+
realm_access = claims.get("realm_access")
|
|
267
|
+
if isinstance(realm_access, dict) and "roles" in realm_access:
|
|
268
|
+
roles.extend(realm_access["roles"])
|
|
269
|
+
|
|
270
|
+
# Extract client-specific roles (Keycloak resource_access)
|
|
271
|
+
resource_access = claims.get("resource_access")
|
|
272
|
+
if isinstance(resource_access, dict):
|
|
273
|
+
for _client, access in resource_access.items():
|
|
274
|
+
if isinstance(access, dict) and "roles" in access:
|
|
275
|
+
roles.extend(access["roles"])
|
|
276
|
+
|
|
277
|
+
# Deduplicate while preserving order
|
|
278
|
+
seen: set[str] = set()
|
|
279
|
+
unique_roles: list[str] = []
|
|
280
|
+
for role in roles:
|
|
281
|
+
if role not in seen:
|
|
282
|
+
seen.add(role)
|
|
283
|
+
unique_roles.append(role)
|
|
284
|
+
|
|
285
|
+
return unique_roles
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def exchange_code_for_tokens(
|
|
289
|
+
token_endpoint: str,
|
|
290
|
+
code: str,
|
|
291
|
+
code_verifier: str,
|
|
292
|
+
redirect_uri: str,
|
|
293
|
+
client_id: str,
|
|
294
|
+
) -> TokenResponse:
|
|
295
|
+
"""Exchange authorization code for tokens.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
token_endpoint: The Keycloak token endpoint.
|
|
299
|
+
code: The authorization code from callback.
|
|
300
|
+
code_verifier: The PKCE code verifier.
|
|
301
|
+
redirect_uri: The redirect URI used in authorization.
|
|
302
|
+
client_id: The OIDC client ID.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
TokenResponse with access and refresh tokens.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
AuthenticationError: If token exchange fails.
|
|
309
|
+
"""
|
|
310
|
+
async with httpx.AsyncClient() as client:
|
|
311
|
+
try:
|
|
312
|
+
response = await client.post(
|
|
313
|
+
token_endpoint,
|
|
314
|
+
data={
|
|
315
|
+
"grant_type": "authorization_code",
|
|
316
|
+
"code": code,
|
|
317
|
+
"redirect_uri": redirect_uri,
|
|
318
|
+
"client_id": client_id,
|
|
319
|
+
"code_verifier": code_verifier,
|
|
320
|
+
},
|
|
321
|
+
timeout=10.0,
|
|
322
|
+
)
|
|
323
|
+
response.raise_for_status()
|
|
324
|
+
except httpx.HTTPStatusError as e:
|
|
325
|
+
error_detail = ""
|
|
326
|
+
try:
|
|
327
|
+
error_data = e.response.json()
|
|
328
|
+
error_detail = error_data.get(
|
|
329
|
+
"error_description", error_data.get("error", "")
|
|
330
|
+
)
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
raise AuthenticationError(
|
|
334
|
+
f"Authentication failed: {error_detail or 'Token exchange failed'}. "
|
|
335
|
+
"Please try again."
|
|
336
|
+
) from e
|
|
337
|
+
except httpx.RequestError as e:
|
|
338
|
+
raise AuthenticationError(
|
|
339
|
+
f"Authentication failed: {e}. Please try again."
|
|
340
|
+
) from e
|
|
341
|
+
|
|
342
|
+
data = response.json()
|
|
343
|
+
return TokenResponse(
|
|
344
|
+
access_token=data["access_token"],
|
|
345
|
+
refresh_token=data.get("refresh_token"),
|
|
346
|
+
expires_in=data["expires_in"],
|
|
347
|
+
token_type=data.get("token_type", "Bearer"),
|
|
348
|
+
)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""BPA API client module.
|
|
2
|
+
|
|
3
|
+
This module provides the BPAClient for interacting with the BPA REST API.
|
|
4
|
+
|
|
5
|
+
Main exports:
|
|
6
|
+
BPAClient: Async HTTP client with retry logic and auth integration.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from mcp_eregistrations_bpa.bpa_client import BPAClient
|
|
10
|
+
|
|
11
|
+
async with BPAClient() as client:
|
|
12
|
+
services = await client.get("/service")
|
|
13
|
+
service = await client.get("/service/{id}", path_params={"id": 123})
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from mcp_eregistrations_bpa.bpa_client.client import (
|
|
17
|
+
BASE_DELAY,
|
|
18
|
+
DEFAULT_TIMEOUT,
|
|
19
|
+
MAX_DELAY,
|
|
20
|
+
MAX_RETRIES,
|
|
21
|
+
NON_RETRYABLE_STATUS_CODES,
|
|
22
|
+
RETRYABLE_STATUS_CODES,
|
|
23
|
+
BPAClient,
|
|
24
|
+
)
|
|
25
|
+
from mcp_eregistrations_bpa.bpa_client.errors import (
|
|
26
|
+
BPAAuthenticationError,
|
|
27
|
+
BPAClientError,
|
|
28
|
+
BPAConnectionError,
|
|
29
|
+
BPANotFoundError,
|
|
30
|
+
BPAPermissionError,
|
|
31
|
+
BPARateLimitError,
|
|
32
|
+
BPAServerError,
|
|
33
|
+
BPATimeoutError,
|
|
34
|
+
BPAValidationError,
|
|
35
|
+
translate_error,
|
|
36
|
+
)
|
|
37
|
+
from mcp_eregistrations_bpa.bpa_client.models import (
|
|
38
|
+
Action,
|
|
39
|
+
BPABaseModel,
|
|
40
|
+
Cost,
|
|
41
|
+
Determinant,
|
|
42
|
+
Document,
|
|
43
|
+
Form,
|
|
44
|
+
FormField,
|
|
45
|
+
PaginatedResponse,
|
|
46
|
+
Registration,
|
|
47
|
+
Role,
|
|
48
|
+
Service,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
# Client
|
|
53
|
+
"BPAClient",
|
|
54
|
+
# Client constants
|
|
55
|
+
"MAX_RETRIES",
|
|
56
|
+
"BASE_DELAY",
|
|
57
|
+
"MAX_DELAY",
|
|
58
|
+
"DEFAULT_TIMEOUT",
|
|
59
|
+
"RETRYABLE_STATUS_CODES",
|
|
60
|
+
"NON_RETRYABLE_STATUS_CODES",
|
|
61
|
+
# Errors
|
|
62
|
+
"BPAClientError",
|
|
63
|
+
"BPAConnectionError",
|
|
64
|
+
"BPATimeoutError",
|
|
65
|
+
"BPAAuthenticationError",
|
|
66
|
+
"BPAPermissionError",
|
|
67
|
+
"BPANotFoundError",
|
|
68
|
+
"BPAValidationError",
|
|
69
|
+
"BPARateLimitError",
|
|
70
|
+
"BPAServerError",
|
|
71
|
+
"translate_error",
|
|
72
|
+
# Models
|
|
73
|
+
"BPABaseModel",
|
|
74
|
+
"Service",
|
|
75
|
+
"Registration",
|
|
76
|
+
"FormField",
|
|
77
|
+
"Determinant",
|
|
78
|
+
"Role",
|
|
79
|
+
"Cost",
|
|
80
|
+
"Document",
|
|
81
|
+
"Action",
|
|
82
|
+
"Form",
|
|
83
|
+
"PaginatedResponse",
|
|
84
|
+
]
|