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.

Files changed (66) hide show
  1. mcp_eregistrations_bpa/__init__.py +121 -0
  2. mcp_eregistrations_bpa/__main__.py +6 -0
  3. mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
  4. mcp_eregistrations_bpa/arazzo/expression.py +379 -0
  5. mcp_eregistrations_bpa/audit/__init__.py +56 -0
  6. mcp_eregistrations_bpa/audit/context.py +66 -0
  7. mcp_eregistrations_bpa/audit/logger.py +236 -0
  8. mcp_eregistrations_bpa/audit/models.py +131 -0
  9. mcp_eregistrations_bpa/auth/__init__.py +64 -0
  10. mcp_eregistrations_bpa/auth/callback.py +391 -0
  11. mcp_eregistrations_bpa/auth/cas.py +409 -0
  12. mcp_eregistrations_bpa/auth/oidc.py +252 -0
  13. mcp_eregistrations_bpa/auth/permissions.py +162 -0
  14. mcp_eregistrations_bpa/auth/token_manager.py +348 -0
  15. mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
  16. mcp_eregistrations_bpa/bpa_client/client.py +740 -0
  17. mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
  18. mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
  19. mcp_eregistrations_bpa/bpa_client/models.py +203 -0
  20. mcp_eregistrations_bpa/config.py +349 -0
  21. mcp_eregistrations_bpa/db/__init__.py +21 -0
  22. mcp_eregistrations_bpa/db/connection.py +64 -0
  23. mcp_eregistrations_bpa/db/migrations.py +168 -0
  24. mcp_eregistrations_bpa/exceptions.py +39 -0
  25. mcp_eregistrations_bpa/py.typed +0 -0
  26. mcp_eregistrations_bpa/rollback/__init__.py +19 -0
  27. mcp_eregistrations_bpa/rollback/manager.py +616 -0
  28. mcp_eregistrations_bpa/server.py +152 -0
  29. mcp_eregistrations_bpa/tools/__init__.py +372 -0
  30. mcp_eregistrations_bpa/tools/actions.py +155 -0
  31. mcp_eregistrations_bpa/tools/analysis.py +352 -0
  32. mcp_eregistrations_bpa/tools/audit.py +399 -0
  33. mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
  34. mcp_eregistrations_bpa/tools/bots.py +627 -0
  35. mcp_eregistrations_bpa/tools/classifications.py +575 -0
  36. mcp_eregistrations_bpa/tools/costs.py +765 -0
  37. mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
  38. mcp_eregistrations_bpa/tools/debugger.py +1230 -0
  39. mcp_eregistrations_bpa/tools/determinants.py +2235 -0
  40. mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
  41. mcp_eregistrations_bpa/tools/export.py +899 -0
  42. mcp_eregistrations_bpa/tools/fields.py +162 -0
  43. mcp_eregistrations_bpa/tools/form_errors.py +36 -0
  44. mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
  45. mcp_eregistrations_bpa/tools/forms.py +1269 -0
  46. mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
  47. mcp_eregistrations_bpa/tools/large_response.py +163 -0
  48. mcp_eregistrations_bpa/tools/messages.py +523 -0
  49. mcp_eregistrations_bpa/tools/notifications.py +241 -0
  50. mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
  51. mcp_eregistrations_bpa/tools/registrations.py +897 -0
  52. mcp_eregistrations_bpa/tools/role_status.py +447 -0
  53. mcp_eregistrations_bpa/tools/role_units.py +400 -0
  54. mcp_eregistrations_bpa/tools/roles.py +1236 -0
  55. mcp_eregistrations_bpa/tools/rollback.py +335 -0
  56. mcp_eregistrations_bpa/tools/services.py +674 -0
  57. mcp_eregistrations_bpa/tools/workflows.py +2487 -0
  58. mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
  59. mcp_eregistrations_bpa/workflows/__init__.py +28 -0
  60. mcp_eregistrations_bpa/workflows/loader.py +440 -0
  61. mcp_eregistrations_bpa/workflows/models.py +336 -0
  62. mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
  63. mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
  64. mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
  65. mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
  66. mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
@@ -0,0 +1,409 @@
1
+ """CAS (eRegistrations custom OAuth2) authentication implementation.
2
+
3
+ This module handles CAS authentication for legacy BPA systems that don't use Keycloak.
4
+ Key differences from Keycloak OIDC:
5
+ - No PKCE support (uses client_secret with Basic Auth)
6
+ - No OIDC discovery endpoint (endpoints must be configured)
7
+ - Authorization endpoint is SPA-based (/cas/spa.html#/)
8
+ - User roles fetched from separate PARTC service
9
+ """
10
+
11
+ import base64
12
+ import logging
13
+ import secrets
14
+ from urllib.parse import urlencode
15
+
16
+ import httpx
17
+
18
+ from mcp_eregistrations_bpa.auth.token_manager import TokenResponse
19
+ from mcp_eregistrations_bpa.exceptions import AuthenticationError
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def generate_state() -> str:
25
+ """Generate a cryptographically secure state parameter.
26
+
27
+ Returns:
28
+ A random state string for CSRF protection.
29
+ """
30
+ return secrets.token_urlsafe(16)
31
+
32
+
33
+ def build_cas_authorization_url(
34
+ cas_authorization_base: str,
35
+ client_id: str,
36
+ redirect_uri: str,
37
+ state: str,
38
+ scope: str = "any",
39
+ lang: str = "en",
40
+ ) -> str:
41
+ """Build CAS authorization URL (no PKCE).
42
+
43
+ CAS uses a SPA-based authorization endpoint with hash fragment parameters.
44
+
45
+ Args:
46
+ cas_authorization_base: The CAS authorization base URL
47
+ (e.g., {CAS_URL}/cas/spa.html)
48
+ client_id: The OAuth2 client ID.
49
+ redirect_uri: The local callback URL.
50
+ state: The state parameter for CSRF protection.
51
+ scope: OAuth scope (CAS uses "any").
52
+ lang: Language code for the login page.
53
+
54
+ Returns:
55
+ The complete authorization URL to open in browser.
56
+ """
57
+ params = {
58
+ "response_type": "code",
59
+ "client_id": client_id,
60
+ "redirect_uri": redirect_uri,
61
+ "scope": scope,
62
+ "state": state,
63
+ "lang": lang,
64
+ }
65
+ # CAS uses hash fragment (#/) for SPA routing
66
+ return f"{cas_authorization_base}#/?{urlencode(params)}"
67
+
68
+
69
+ def _build_basic_auth_header(client_id: str, client_secret: str) -> str:
70
+ """Build HTTP Basic Auth header value.
71
+
72
+ Args:
73
+ client_id: The OAuth2 client ID.
74
+ client_secret: The OAuth2 client secret.
75
+
76
+ Returns:
77
+ The Authorization header value (e.g., "Basic base64(client_id:secret)").
78
+ """
79
+ credentials = f"{client_id}:{client_secret}"
80
+ encoded = base64.b64encode(credentials.encode()).decode()
81
+ return f"Basic {encoded}"
82
+
83
+
84
+ async def exchange_code_for_tokens_cas(
85
+ token_endpoint: str,
86
+ code: str,
87
+ redirect_uri: str,
88
+ client_id: str,
89
+ client_secret: str,
90
+ ) -> TokenResponse:
91
+ """Exchange authorization code for tokens using CAS (Basic Auth).
92
+
93
+ Unlike Keycloak PKCE flow, CAS requires:
94
+ - Authorization: Basic base64(client_id:client_secret) header
95
+ - No code_verifier (no PKCE)
96
+
97
+ Args:
98
+ token_endpoint: The CAS token endpoint (e.g., {CAS_URL}/access_token).
99
+ code: The authorization code from callback.
100
+ redirect_uri: The redirect URI used in authorization.
101
+ client_id: The OAuth2 client ID.
102
+ client_secret: The OAuth2 client secret.
103
+
104
+ Returns:
105
+ TokenResponse with access and refresh tokens.
106
+
107
+ Raises:
108
+ AuthenticationError: If token exchange fails.
109
+ """
110
+ auth_header = _build_basic_auth_header(client_id, client_secret)
111
+
112
+ async with httpx.AsyncClient() as client:
113
+ try:
114
+ response = await client.post(
115
+ token_endpoint,
116
+ headers={
117
+ "Authorization": auth_header,
118
+ "Content-Type": "application/x-www-form-urlencoded",
119
+ },
120
+ data={
121
+ "grant_type": "authorization_code",
122
+ "code": code,
123
+ "redirect_uri": redirect_uri,
124
+ },
125
+ timeout=10.0,
126
+ )
127
+ response.raise_for_status()
128
+ except httpx.HTTPStatusError as e:
129
+ error_detail = ""
130
+ try:
131
+ error_data = e.response.json()
132
+ error_detail = error_data.get(
133
+ "error_description", error_data.get("error", "")
134
+ )
135
+ except Exception:
136
+ pass
137
+ raise AuthenticationError(
138
+ f"CAS auth failed: {error_detail or 'Token exchange failed'}. "
139
+ "Verify CAS_CLIENT_ID and CAS_CLIENT_SECRET."
140
+ ) from e
141
+ except httpx.RequestError as e:
142
+ raise AuthenticationError(
143
+ f"Cannot connect to CAS: {e}. "
144
+ "Verify CAS_URL is correct and the server is accessible."
145
+ ) from e
146
+
147
+ try:
148
+ data = response.json()
149
+ except Exception as e:
150
+ raise AuthenticationError(
151
+ f"CAS returned invalid response: {e}. Contact administrator."
152
+ ) from e
153
+
154
+ if "access_token" not in data:
155
+ raise AuthenticationError(
156
+ "CAS response missing access_token. "
157
+ "Verify CAS_CLIENT_ID and CAS_CLIENT_SECRET."
158
+ )
159
+
160
+ return TokenResponse(
161
+ access_token=data["access_token"],
162
+ refresh_token=data.get("refresh_token"),
163
+ expires_in=data.get(
164
+ "expires_in", 28800
165
+ ), # Default 8 hours if not specified
166
+ token_type=data.get("token_type", "Bearer"),
167
+ )
168
+
169
+
170
+ async def refresh_tokens_cas(
171
+ token_endpoint: str,
172
+ refresh_token: str,
173
+ client_id: str,
174
+ client_secret: str,
175
+ ) -> TokenResponse:
176
+ """Refresh access token using CAS (Basic Auth).
177
+
178
+ Args:
179
+ token_endpoint: The CAS token endpoint.
180
+ refresh_token: The refresh token.
181
+ client_id: The OAuth2 client ID.
182
+ client_secret: The OAuth2 client secret.
183
+
184
+ Returns:
185
+ TokenResponse with new access and refresh tokens.
186
+
187
+ Raises:
188
+ AuthenticationError: If refresh fails.
189
+ """
190
+ auth_header = _build_basic_auth_header(client_id, client_secret)
191
+
192
+ async with httpx.AsyncClient() as client:
193
+ try:
194
+ response = await client.post(
195
+ token_endpoint,
196
+ headers={
197
+ "Authorization": auth_header,
198
+ "Content-Type": "application/x-www-form-urlencoded",
199
+ },
200
+ data={
201
+ "grant_type": "refresh_token",
202
+ "refresh_token": refresh_token,
203
+ },
204
+ timeout=10.0,
205
+ )
206
+ response.raise_for_status()
207
+ except httpx.HTTPStatusError as e:
208
+ raise AuthenticationError(
209
+ "CAS session expired. Please run auth_login again."
210
+ ) from e
211
+ except httpx.RequestError as e:
212
+ raise AuthenticationError(
213
+ f"Cannot refresh CAS session: {e}. Please try again."
214
+ ) from e
215
+
216
+ data = response.json()
217
+ return TokenResponse(
218
+ access_token=data["access_token"],
219
+ refresh_token=data.get("refresh_token", refresh_token),
220
+ expires_in=data.get("expires_in", 28800), # Default 8 hours
221
+ token_type=data.get("token_type", "Bearer"),
222
+ )
223
+
224
+
225
+ async def fetch_user_roles_from_partc(
226
+ partc_url: str,
227
+ access_token: str,
228
+ ) -> list[str]:
229
+ """Fetch user roles from PARTC service.
230
+
231
+ CAS doesn't include all roles in the JWT, so we need to fetch them
232
+ from the PARTC user attributes endpoint.
233
+
234
+ Args:
235
+ partc_url: The PARTC user attributes URL (e.g., {PARTC_URL}/user/attributes).
236
+ access_token: The access token for authorization.
237
+
238
+ Returns:
239
+ List of role strings.
240
+
241
+ Raises:
242
+ AuthenticationError: If role fetching fails.
243
+ """
244
+ async with httpx.AsyncClient() as client:
245
+ try:
246
+ response = await client.get(
247
+ partc_url,
248
+ headers={
249
+ "Authorization": f"Bearer {access_token}",
250
+ },
251
+ timeout=10.0,
252
+ )
253
+ response.raise_for_status()
254
+ except httpx.HTTPStatusError as e:
255
+ logger.warning(
256
+ "Failed to fetch PARTC roles: HTTP %d", e.response.status_code
257
+ )
258
+ return [] # Return empty roles rather than failing auth
259
+ except httpx.RequestError as e:
260
+ logger.warning("Failed to connect to PARTC: %s", e)
261
+ return [] # Return empty roles rather than failing auth
262
+
263
+ data = response.json()
264
+
265
+ # PARTC returns array of role strings
266
+ if isinstance(data, list):
267
+ return [str(role) for role in data if role]
268
+
269
+ # Handle potential wrapper object
270
+ if isinstance(data, dict):
271
+ roles = data.get("roles") or data.get("attributes") or []
272
+ if isinstance(roles, list):
273
+ return [str(role) for role in roles if role]
274
+
275
+ return []
276
+
277
+
278
+ async def perform_cas_browser_login() -> dict[str, object]:
279
+ """Perform browser-based CAS login flow.
280
+
281
+ This is the CAS equivalent of perform_browser_login() in oidc.py.
282
+
283
+ Returns:
284
+ dict: Authentication result with user email and session duration.
285
+
286
+ Raises:
287
+ AuthenticationError: If authentication fails.
288
+ """
289
+ import webbrowser
290
+
291
+ from mcp_eregistrations_bpa.auth.callback import CallbackServer
292
+ from mcp_eregistrations_bpa.config import load_config
293
+
294
+ logger.info("Starting CAS browser login flow...")
295
+
296
+ # Load configuration
297
+ config = load_config()
298
+
299
+ if not config.cas_url or not config.cas_client_id or not config.cas_client_secret:
300
+ raise AuthenticationError(
301
+ "CAS configuration incomplete. "
302
+ "Set CAS_URL, CAS_CLIENT_ID, and CAS_CLIENT_SECRET."
303
+ )
304
+
305
+ logger.debug("CAS URL: %s", config.cas_url)
306
+
307
+ # Generate state (no PKCE for CAS)
308
+ state = generate_state()
309
+ logger.debug("Generated state parameter")
310
+
311
+ # Start callback server on fixed port (CAS requires exact redirect_uri match)
312
+ callback_server = CallbackServer(port=config.cas_callback_port)
313
+ callback_server.start()
314
+ logger.info("Callback server started on port %d", callback_server.port)
315
+
316
+ try:
317
+ # Build CAS authorization URL (no PKCE)
318
+ auth_url = build_cas_authorization_url(
319
+ cas_authorization_base=config.cas_authorization_url or "",
320
+ client_id=config.cas_client_id,
321
+ redirect_uri=callback_server.redirect_uri,
322
+ state=state,
323
+ )
324
+ logger.debug("CAS auth URL built: %s...", auth_url[:80])
325
+
326
+ # Open browser
327
+ logger.info("Opening browser for CAS authentication...")
328
+ if not webbrowser.open(auth_url):
329
+ logger.error("Failed to open browser")
330
+ return {
331
+ "error": True,
332
+ "message": (
333
+ "Cannot open browser for authentication. "
334
+ f"Please open this URL manually: {auth_url}"
335
+ ),
336
+ }
337
+
338
+ # Wait for callback
339
+ logger.info("Waiting for CAS OAuth callback...")
340
+ code = await callback_server.wait_for_callback(expected_state=state)
341
+ logger.info("Received authorization code from CAS")
342
+
343
+ # Exchange code for tokens (using Basic Auth, not PKCE)
344
+ logger.info("Exchanging code for tokens via CAS...")
345
+ token_response = await exchange_code_for_tokens_cas(
346
+ token_endpoint=config.cas_token_url or "",
347
+ code=code,
348
+ redirect_uri=callback_server.redirect_uri,
349
+ client_id=config.cas_client_id,
350
+ client_secret=config.cas_client_secret.get_secret_value(),
351
+ )
352
+ logger.info("CAS token exchange successful")
353
+
354
+ # Get token manager and store tokens
355
+ from mcp_eregistrations_bpa.server import get_token_manager
356
+
357
+ token_manager = get_token_manager()
358
+ token_manager.store_tokens(
359
+ access_token=token_response.access_token,
360
+ refresh_token=token_response.refresh_token,
361
+ expires_in=token_response.expires_in,
362
+ token_endpoint=config.cas_token_url,
363
+ client_id=config.cas_client_id,
364
+ )
365
+
366
+ # Store client_secret for CAS token refresh (needed because CAS uses Basic Auth)
367
+ token_manager._cas_client_secret = config.cas_client_secret.get_secret_value()
368
+
369
+ # Fetch additional roles from PARTC if configured
370
+ if config.partc_user_attributes_url:
371
+ logger.info("Fetching user roles from PARTC...")
372
+ partc_roles = await fetch_user_roles_from_partc(
373
+ partc_url=config.partc_user_attributes_url,
374
+ access_token=token_response.access_token,
375
+ )
376
+ if partc_roles:
377
+ # Merge PARTC roles with JWT roles
378
+ existing_roles = set(token_manager.permissions)
379
+ for role in partc_roles:
380
+ if role not in existing_roles:
381
+ token_manager._permissions.append(role)
382
+ logger.info("Added %d roles from PARTC", len(partc_roles))
383
+
384
+ logger.info("Tokens stored for user: %s", token_manager.user_email)
385
+
386
+ result = {
387
+ "success": True,
388
+ "message": (
389
+ f"Authenticated as {token_manager.user_email} via CAS. "
390
+ f"Session valid for {token_manager.expires_in_minutes} minutes."
391
+ ),
392
+ "user_email": token_manager.user_email,
393
+ "session_expires_in_minutes": token_manager.expires_in_minutes,
394
+ "auth_provider": "cas",
395
+ }
396
+ logger.info("CAS browser login complete")
397
+ return result
398
+
399
+ except AuthenticationError:
400
+ logger.exception("CAS authentication error")
401
+ raise
402
+ except Exception as e:
403
+ logger.exception("Unexpected error during CAS authentication")
404
+ raise AuthenticationError(
405
+ f"CAS authentication failed: {e}. Please try again."
406
+ ) from e
407
+ finally:
408
+ logger.debug("Stopping callback server")
409
+ callback_server.stop()
@@ -0,0 +1,252 @@
1
+ """OIDC Discovery and PKCE flow implementation.
2
+
3
+ This module handles Keycloak OIDC endpoint discovery and PKCE flow
4
+ for secure browser-based authentication.
5
+ """
6
+
7
+ import base64
8
+ import hashlib
9
+ import logging
10
+ import secrets
11
+ from urllib.parse import urlencode
12
+
13
+ import httpx
14
+ from pydantic import BaseModel
15
+
16
+ from mcp_eregistrations_bpa.exceptions import AuthenticationError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # PKCE constants
21
+ PKCE_VERIFIER_LENGTH = 32 # bytes, produces 43 char base64url string
22
+
23
+
24
+ class OIDCConfig(BaseModel):
25
+ """OIDC configuration discovered from well-known endpoint."""
26
+
27
+ issuer: str
28
+ authorization_endpoint: str
29
+ token_endpoint: str
30
+ userinfo_endpoint: str | None = None
31
+
32
+
33
+ async def discover_oidc_config(base_url: str) -> OIDCConfig:
34
+ """Discover OIDC configuration from well-known endpoint.
35
+
36
+ Args:
37
+ base_url: The base URL for OIDC discovery. This is typically:
38
+ - For Keycloak: https://login.example.org/realms/my-realm
39
+ - Falls back to BPA URL if Keycloak URL not configured
40
+
41
+ Returns:
42
+ OIDCConfig with discovered endpoints.
43
+
44
+ Raises:
45
+ AuthenticationError: If discovery fails.
46
+ """
47
+ discovery_url = f"{base_url.rstrip('/')}/.well-known/openid-configuration"
48
+
49
+ async with httpx.AsyncClient() as client:
50
+ try:
51
+ response = await client.get(discovery_url, timeout=10.0)
52
+ response.raise_for_status()
53
+ except httpx.HTTPStatusError as e:
54
+ raise AuthenticationError(
55
+ f"Cannot discover Keycloak at {discovery_url}: "
56
+ f"HTTP {e.response.status_code}. "
57
+ "Verify KEYCLOAK_URL and KEYCLOAK_REALM are correct."
58
+ ) from e
59
+ except httpx.RequestError as e:
60
+ raise AuthenticationError(
61
+ f"Cannot connect to Keycloak at {discovery_url}: {e}. "
62
+ "Verify network connectivity and that Keycloak is accessible."
63
+ ) from e
64
+
65
+ try:
66
+ data = response.json()
67
+ return OIDCConfig(
68
+ issuer=data["issuer"],
69
+ authorization_endpoint=data["authorization_endpoint"],
70
+ token_endpoint=data["token_endpoint"],
71
+ userinfo_endpoint=data.get("userinfo_endpoint"),
72
+ )
73
+ except (KeyError, ValueError) as e:
74
+ raise AuthenticationError(
75
+ f"Invalid OIDC configuration response: {e}. "
76
+ "The URL may not be a valid Keycloak realm endpoint."
77
+ ) from e
78
+
79
+
80
+ def generate_pkce_pair() -> tuple[str, str]:
81
+ """Generate PKCE code_verifier and code_challenge.
82
+
83
+ Returns:
84
+ Tuple of (code_verifier, code_challenge).
85
+ The challenge is computed using S256 method.
86
+ """
87
+ # code_verifier: 43-128 characters, [A-Za-z0-9-._~]
88
+ code_verifier = secrets.token_urlsafe(PKCE_VERIFIER_LENGTH)
89
+
90
+ # code_challenge: SHA256(code_verifier) then base64url encode (no padding)
91
+ digest = hashlib.sha256(code_verifier.encode()).digest()
92
+ code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
93
+
94
+ return code_verifier, code_challenge
95
+
96
+
97
+ def generate_state() -> str:
98
+ """Generate a cryptographically secure state parameter.
99
+
100
+ Returns:
101
+ A random state string for CSRF protection.
102
+ """
103
+ return secrets.token_urlsafe(16)
104
+
105
+
106
+ def build_authorization_url(
107
+ authorization_endpoint: str,
108
+ client_id: str,
109
+ redirect_uri: str,
110
+ code_challenge: str,
111
+ state: str,
112
+ scope: str = "openid email profile",
113
+ ) -> str:
114
+ """Build Keycloak authorization URL with PKCE.
115
+
116
+ Args:
117
+ authorization_endpoint: The Keycloak authorization endpoint.
118
+ client_id: The OIDC client ID.
119
+ redirect_uri: The local callback URL.
120
+ code_challenge: The PKCE code challenge (S256).
121
+ state: The state parameter for CSRF protection.
122
+ scope: OAuth scopes to request.
123
+
124
+ Returns:
125
+ The complete authorization URL to open in browser.
126
+ """
127
+ params = {
128
+ "response_type": "code",
129
+ "client_id": client_id,
130
+ "redirect_uri": redirect_uri,
131
+ "scope": scope,
132
+ "code_challenge": code_challenge,
133
+ "code_challenge_method": "S256",
134
+ "state": state,
135
+ }
136
+ return f"{authorization_endpoint}?{urlencode(params)}"
137
+
138
+
139
+ async def perform_browser_login() -> dict[str, object]:
140
+ """Perform browser-based OIDC login flow.
141
+
142
+ This is the core login implementation that can be called from
143
+ both the auth_login tool and auto-auth in ensure_authenticated.
144
+
145
+ Returns:
146
+ dict: Authentication result with user email and session duration.
147
+
148
+ Raises:
149
+ AuthenticationError: If authentication fails.
150
+ """
151
+ import webbrowser
152
+
153
+ from mcp_eregistrations_bpa.auth.callback import CallbackServer
154
+ from mcp_eregistrations_bpa.auth.token_manager import exchange_code_for_tokens
155
+ from mcp_eregistrations_bpa.config import load_config
156
+
157
+ logger.info("Starting browser login flow...")
158
+
159
+ # Load configuration
160
+ config = load_config()
161
+ logger.debug("Config loaded: %s", config.oidc_discovery_url)
162
+
163
+ # Discover OIDC endpoints
164
+ logger.info("Discovering OIDC endpoints...")
165
+ oidc_config = await discover_oidc_config(config.oidc_discovery_url)
166
+ logger.debug("OIDC config: auth=%s", oidc_config.authorization_endpoint)
167
+
168
+ # Generate PKCE pair and state
169
+ code_verifier, code_challenge = generate_pkce_pair()
170
+ state = generate_state()
171
+ logger.debug("Generated PKCE pair and state")
172
+
173
+ # Start callback server
174
+ callback_server = CallbackServer()
175
+ callback_server.start()
176
+ logger.info("Callback server started on port %d", callback_server.port)
177
+
178
+ try:
179
+ # Build authorization URL
180
+ auth_url = build_authorization_url(
181
+ authorization_endpoint=oidc_config.authorization_endpoint,
182
+ client_id=config.keycloak_client_id,
183
+ redirect_uri=callback_server.redirect_uri,
184
+ code_challenge=code_challenge,
185
+ state=state,
186
+ )
187
+ logger.debug("Auth URL built: %s...", auth_url[:80])
188
+
189
+ # Open browser
190
+ logger.info("Opening browser for authentication...")
191
+ if not webbrowser.open(auth_url):
192
+ logger.error("Failed to open browser")
193
+ return {
194
+ "error": True,
195
+ "message": (
196
+ "Cannot open browser for authentication. "
197
+ f"Please open this URL manually: {auth_url}"
198
+ ),
199
+ }
200
+
201
+ # Wait for callback
202
+ logger.info("Waiting for OAuth callback...")
203
+ code = await callback_server.wait_for_callback(expected_state=state)
204
+ logger.info("Received authorization code")
205
+
206
+ # Exchange code for tokens
207
+ logger.info("Exchanging code for tokens...")
208
+ token_response = await exchange_code_for_tokens(
209
+ token_endpoint=oidc_config.token_endpoint,
210
+ code=code,
211
+ code_verifier=code_verifier,
212
+ redirect_uri=callback_server.redirect_uri,
213
+ client_id=config.keycloak_client_id,
214
+ )
215
+ logger.info("Token exchange successful")
216
+
217
+ # Get token manager and store tokens
218
+ from mcp_eregistrations_bpa.server import get_token_manager
219
+
220
+ token_manager = get_token_manager()
221
+ token_manager.store_tokens(
222
+ access_token=token_response.access_token,
223
+ refresh_token=token_response.refresh_token,
224
+ expires_in=token_response.expires_in,
225
+ token_endpoint=oidc_config.token_endpoint,
226
+ client_id=config.keycloak_client_id,
227
+ )
228
+ logger.info("Tokens stored for user: %s", token_manager.user_email)
229
+
230
+ result = {
231
+ "success": True,
232
+ "message": (
233
+ f"Authenticated as {token_manager.user_email}. "
234
+ f"Session valid for {token_manager.expires_in_minutes} minutes."
235
+ ),
236
+ "user_email": token_manager.user_email,
237
+ "session_expires_in_minutes": token_manager.expires_in_minutes,
238
+ }
239
+ logger.info("Browser login complete, returning result")
240
+ return result
241
+
242
+ except AuthenticationError:
243
+ logger.exception("Authentication error")
244
+ raise
245
+ except Exception as e:
246
+ logger.exception("Unexpected error during authentication")
247
+ raise AuthenticationError(
248
+ f"Authentication failed: {e}. Please try again."
249
+ ) from e
250
+ finally:
251
+ logger.debug("Stopping callback server")
252
+ callback_server.stop()