d365fo-client 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (91) hide show
  1. {d365fo_client-0.3.0/src/d365fo_client.egg-info → d365fo_client-0.3.1}/PKG-INFO +1 -1
  2. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/pyproject.toml +1 -1
  3. d365fo_client-0.3.1/src/d365fo_client/mcp/auth_server/auth/providers/apikey.py +83 -0
  4. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/azure.py +91 -23
  5. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/fastmcp_main.py +92 -43
  6. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/settings.py +14 -2
  7. {d365fo_client-0.3.0 → d365fo_client-0.3.1/src/d365fo_client.egg-info}/PKG-INFO +1 -1
  8. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/SOURCES.txt +3 -1
  9. d365fo_client-0.3.1/tests/test_azure_provider_persistence.py +394 -0
  10. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/LICENSE +0 -0
  11. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/README.md +0 -0
  12. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/setup.cfg +0 -0
  13. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/__init__.py +0 -0
  14. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/auth.py +0 -0
  15. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/cli.py +0 -0
  16. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/client.py +0 -0
  17. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/config.py +0 -0
  18. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/credential_sources.py +0 -0
  19. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/crud.py +0 -0
  20. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/exceptions.py +0 -0
  21. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/labels.py +0 -0
  22. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/main.py +0 -0
  23. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/__init__.py +0 -0
  24. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/__init__.py +0 -0
  25. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/__init__.py +0 -0
  26. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/auth.py +0 -0
  27. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/oauth_proxy.py +0 -0
  28. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/__init__.py +0 -0
  29. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/bearer.py +0 -0
  30. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/providers/jwt.py +0 -0
  31. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/auth/redirect_validation.py +0 -0
  32. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/auth_server/dependencies.py +0 -0
  33. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/client_manager.py +0 -0
  34. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/fastmcp_server.py +0 -0
  35. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/fastmcp_utils.py +0 -0
  36. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/main.py +0 -0
  37. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/__init__.py +0 -0
  38. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/base_tools_mixin.py +0 -0
  39. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/connection_tools_mixin.py +0 -0
  40. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/crud_tools_mixin.py +0 -0
  41. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/database_tools_mixin.py +0 -0
  42. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/label_tools_mixin.py +0 -0
  43. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/metadata_tools_mixin.py +0 -0
  44. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/performance_tools_mixin.py +0 -0
  45. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/profile_tools_mixin.py +0 -0
  46. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/mixins/sync_tools_mixin.py +0 -0
  47. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/models.py +0 -0
  48. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/prompts/__init__.py +0 -0
  49. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/prompts/action_execution.py +0 -0
  50. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/prompts/sequence_analysis.py +0 -0
  51. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/__init__.py +0 -0
  52. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/database_handler.py +0 -0
  53. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/entity_handler.py +0 -0
  54. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/environment_handler.py +0 -0
  55. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/metadata_handler.py +0 -0
  56. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/resources/query_handler.py +0 -0
  57. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/server.py +0 -0
  58. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/__init__.py +0 -0
  59. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/connection_tools.py +0 -0
  60. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/crud_tools.py +0 -0
  61. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/database_tools.py +0 -0
  62. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/label_tools.py +0 -0
  63. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/metadata_tools.py +0 -0
  64. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/profile_tools.py +0 -0
  65. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/tools/sync_tools.py +0 -0
  66. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/utilities/__init__.py +0 -0
  67. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/utilities/auth.py +0 -0
  68. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/utilities/logging.py +0 -0
  69. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/mcp/utilities/types.py +0 -0
  70. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_api.py +0 -0
  71. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/__init__.py +0 -0
  72. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/cache_v2.py +0 -0
  73. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/database_v2.py +0 -0
  74. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/global_version_manager.py +0 -0
  75. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/label_utils.py +0 -0
  76. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/search_engine_v2.py +0 -0
  77. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/sync_manager_v2.py +0 -0
  78. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/sync_session_manager.py +0 -0
  79. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/metadata_v2/version_detector.py +0 -0
  80. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/models.py +0 -0
  81. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/output.py +0 -0
  82. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/profile_manager.py +0 -0
  83. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/profiles.py +0 -0
  84. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/query.py +0 -0
  85. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/session.py +0 -0
  86. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/sync_models.py +0 -0
  87. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client/utils.py +0 -0
  88. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/dependency_links.txt +0 -0
  89. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/entry_points.txt +0 -0
  90. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/requires.txt +0 -0
  91. {d365fo_client-0.3.0 → d365fo_client-0.3.1}/src/d365fo_client.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d365fo-client
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Microsoft Dynamics 365 Finance & Operations client
5
5
  Author-email: Muhammad Afzaal <mo@thedataguy.pro>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "d365fo-client"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Microsoft Dynamics 365 Finance & Operations client"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,83 @@
1
+ """API Key authentication provider for FastMCP.
2
+
3
+ This provider implements simple API key authentication using the Authorization header.
4
+ Suitable for service-to-service authentication and simpler deployment scenarios.
5
+
6
+ IMPORTANT: FastMCP uses BearerAuthBackend which extracts tokens from the Authorization header
7
+ and calls token_verifier.verify_token(). Clients must send the API key as:
8
+ Authorization: Bearer <your-api-key>
9
+
10
+ The token_verifier.verify_token() method performs constant-time comparison of the API key.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import secrets
16
+
17
+ from pydantic import SecretStr
18
+
19
+ from ..auth import AccessToken, TokenVerifier
20
+ from d365fo_client.mcp.utilities.logging import get_logger
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ class APIKeyVerifier(TokenVerifier):
26
+ """API Key token verifier for FastMCP.
27
+
28
+ This is a TokenVerifier that validates API keys sent as Bearer tokens.
29
+ FastMCP's BearerAuthBackend extracts the token from "Authorization: Bearer <token>"
30
+ and passes it to this verifier's verify_token() method.
31
+
32
+ This is a simpler alternative to OAuth for scenarios where:
33
+ - Service-to-service authentication is needed
34
+ - Simplified deployment without OAuth infrastructure
35
+ - Single-user or trusted client scenarios
36
+
37
+ Security features:
38
+ - Constant-time comparison to prevent timing attacks
39
+ - SecretStr storage to prevent accidental logging
40
+ - No token expiration (suitable for long-lived API keys)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: SecretStr,
46
+ base_url: str | None = None,
47
+ required_scopes: list[str] | None = None,
48
+ ):
49
+ """Initialize API key provider.
50
+
51
+ Args:
52
+ api_key: The secret API key value
53
+ base_url: Base URL of the server
54
+ required_scopes: Required scopes (for compatibility, not enforced for API keys)
55
+ """
56
+ super().__init__(base_url=base_url, required_scopes=required_scopes)
57
+ self.api_key = api_key
58
+
59
+ async def verify_token(self, token: str) -> AccessToken | None:
60
+ """Verify API key token.
61
+
62
+ This method is called by FastMCP's BearerAuthBackend after extracting
63
+ the token from "Authorization: Bearer <token>" header.
64
+
65
+ Args:
66
+ token: The API key extracted from the Authorization header
67
+
68
+ Returns:
69
+ AccessToken if valid, None otherwise
70
+ """
71
+ # Constant-time comparison to prevent timing attacks
72
+ if secrets.compare_digest(token, self.api_key.get_secret_value()):
73
+ logger.debug("API key authentication successful")
74
+ return AccessToken(
75
+ token=token,
76
+ scopes=self.required_scopes or [],
77
+ client_id="api_key_client", # Fixed client_id for API key auth
78
+ expires_at=None, # API keys don't expire
79
+ resource=None,
80
+ )
81
+
82
+ logger.warning("Invalid API key provided")
83
+ return None
@@ -284,42 +284,110 @@ class AzureProvider(OAuthProxy):
284
284
 
285
285
  async def register_client(self, client_info: OAuthClientInformationFull) -> None:
286
286
  """Register a new MCP client, validating redirect URIs if configured."""
287
- result = await super().register_client(client_info)
288
- self._save_clients()
289
- return result
287
+ await super().register_client(client_info)
288
+ try:
289
+ self._save_clients()
290
+ except Exception as e:
291
+ logger.error(f"Failed to persist client registration: {e}")
292
+ # Don't raise here as the client is already registered in memory
290
293
 
291
- def _save_clients(self) -> str | None:
294
+ def _save_clients(self) -> None:
295
+ """Save client data to persistent storage.
296
+
297
+ Raises:
298
+ ValueError: If clients_storage_path is not configured
299
+ OSError: If file operations fail
300
+ """
292
301
  if not self.clients_storage_path:
293
302
  logger.warning("No clients storage path configured. Skipping client save.")
294
- return None
303
+ return
295
304
 
296
- # Store self._clients to clients.json
297
305
  try:
298
- client_json_path = Path(self.clients_storage_path) / "clients.json"
306
+ # Ensure the storage directory exists
307
+ storage_dir = Path(self.clients_storage_path)
308
+ storage_dir.mkdir(parents=True, exist_ok=True)
309
+
310
+ client_json_path = storage_dir / "clients.json"
311
+
299
312
  # Convert OAuthClientInformationFull objects to dictionaries for JSON serialization
300
- clients_dict = {
301
- client_id: client.model_dump() if hasattr(client, 'model_dump') else client.__dict__
302
- for client_id, client in self._clients.items()
303
- }
304
- with client_json_path.open("w") as f:
305
- json.dump(clients_dict, f, indent=2)
313
+ # Use mode="json" to properly serialize complex types like AnyUrl
314
+ clients_dict = {}
315
+ for client_id, client in self._clients.items():
316
+ try:
317
+ if hasattr(client, 'model_dump'):
318
+ # Use json mode to ensure proper serialization of complex types (e.g., AnyUrl)
319
+ clients_dict[client_id] = client.model_dump(mode="json")
320
+ else:
321
+ # Fallback for non-Pydantic objects (shouldn't happen with OAuthClientInformationFull)
322
+ clients_dict[client_id] = client.__dict__
323
+ except Exception as client_error:
324
+ logger.error(f"Failed to serialize client {client_id}: {client_error}")
325
+ continue
326
+
327
+ # Write to temporary file first, then rename for atomic operation
328
+ temp_path = client_json_path.with_suffix('.tmp')
329
+ with temp_path.open("w") as f:
330
+ json.dump(clients_dict, f, indent=2, ensure_ascii=False)
331
+
332
+ # Atomic rename
333
+ temp_path.replace(client_json_path)
334
+
335
+ logger.debug(f"Successfully saved {len(clients_dict)} clients to {client_json_path}")
336
+
306
337
  except Exception as e:
307
- logger.error(f"Failed to write client data to {client_json_path}: {e}")
338
+ logger.error(f"Failed to save client data to {self.clients_storage_path}: {e}")
339
+ raise
308
340
 
309
341
  def _load_clients(self) -> None:
342
+ """Load client data from persistent storage.
343
+
344
+ Loads clients from the JSON file if it exists and is valid.
345
+ Invalid client data is logged and skipped.
346
+ """
310
347
  if not self.clients_storage_path:
348
+ logger.debug("No clients storage path configured. Skipping client load.")
311
349
  return
312
350
 
313
- # Load existing clients from storage if path is provided
314
-
315
351
  try:
316
352
  client_json_path = Path(self.clients_storage_path) / "clients.json"
317
- if client_json_path.exists():
318
- with client_json_path.open("r") as f:
319
- clients_data = json.load(f)
320
- for client_id, client_info in clients_data.items():
321
- # Ensure client_id is a string (it should be from JSON, but type checking requires this)
322
- if isinstance(client_id, str):
323
- self._clients[client_id] = OAuthClientInformationFull.model_validate(client_info)
353
+
354
+ if not client_json_path.exists():
355
+ logger.debug(f"Client storage file {client_json_path} does not exist. Starting with empty client registry.")
356
+ return
357
+
358
+ # Read and parse the JSON file
359
+ with client_json_path.open("r", encoding="utf-8") as f:
360
+ clients_data = json.load(f)
361
+
362
+ if not isinstance(clients_data, dict):
363
+ logger.error(f"Invalid client data format in {client_json_path}: expected dict, got {type(clients_data)}")
364
+ return
365
+
366
+ loaded_count = 0
367
+ for client_id, client_info in clients_data.items():
368
+ try:
369
+ # Validate client_id is a string
370
+ if not isinstance(client_id, str):
371
+ logger.warning(f"Skipping client with non-string ID: {client_id} (type: {type(client_id)})")
372
+ continue
373
+
374
+ # Validate and restore the client object
375
+ if not isinstance(client_info, dict):
376
+ logger.warning(f"Skipping client {client_id}: invalid data format (expected dict, got {type(client_info)})")
377
+ continue
378
+
379
+ # Use Pydantic model_validate to restore the object with proper validation
380
+ client_obj = OAuthClientInformationFull.model_validate(client_info)
381
+ self._clients[client_id] = client_obj
382
+ loaded_count += 1
383
+
384
+ except Exception as client_error:
385
+ logger.error(f"Failed to load client {client_id}: {client_error}")
386
+ continue
387
+
388
+ logger.info(f"Successfully loaded {loaded_count} clients from {client_json_path}")
389
+
390
+ except json.JSONDecodeError as e:
391
+ logger.error(f"Invalid JSON in client storage file {client_json_path}: {e}")
324
392
  except Exception as e:
325
393
  logger.error(f"Failed to load clients from {client_json_path}: {e}")
@@ -19,6 +19,7 @@ from d365fo_client.mcp.fastmcp_utils import create_default_profile_if_needed, lo
19
19
  from d365fo_client.profile_manager import ProfileManager
20
20
  from d365fo_client.settings import get_settings
21
21
  from mcp.server.auth.settings import AuthSettings,ClientRegistrationOptions
22
+ from d365fo_client.mcp.auth_server.auth.providers.apikey import APIKeyVerifier
22
23
 
23
24
 
24
25
 
@@ -120,6 +121,7 @@ Environment Variables:
120
121
  D365FO_MCP_AUTH_TENANT_ID Azure AD tenant ID for authentication
121
122
  D365FO_MCP_AUTH_BASE_URL http://localhost:8000
122
123
  D365FO_MCP_AUTH_REQUIRED_SCOPES User.Read,email,openid,profile
124
+ D365FO_MCP_API_KEY_VALUE API key for authentication (send as: Authorization: Bearer <key>)
123
125
  D365FO_LOG_LEVEL Logging level (DEBUG, INFO, WARNING, ERROR)
124
126
  D365FO_LOG_FILE Custom log file path (default: ~/.d365fo-mcp/logs/fastmcp-server.log)
125
127
  D365FO_META_CACHE_DIR Metadata cache directory (default: ~/.d365fo-mcp/cache)
@@ -254,63 +256,93 @@ logger.info(f"Startup Mode: {settings.get_startup_mode()}")
254
256
  logger.info(f"Client Credentials: {'Configured' if settings.has_client_credentials() else 'Not configured'}")
255
257
  logger.info("====================================")
256
258
 
257
- if is_remote_transport and not settings.has_mcp_auth_credentials():
259
+ # Validate authentication for remote transports
260
+ if is_remote_transport:
261
+ has_oauth = settings.has_mcp_auth_credentials()
262
+ has_api_key = settings.has_mcp_api_key_auth()
263
+
264
+ # Must have either OAuth or API key
265
+ if not has_oauth and not has_api_key:
266
+ logger.error(
267
+ "Error: Remote transports (SSE/HTTP) require authentication. "
268
+ "Please configure either:\n"
269
+ " OAuth: D365FO_MCP_AUTH_CLIENT_ID, D365FO_MCP_AUTH_CLIENT_SECRET, "
270
+ "D365FO_MCP_AUTH_TENANT_ID, D365FO_MCP_AUTH_BASE_URL, D365FO_MCP_AUTH_REQUIRED_SCOPES\n"
271
+ " OR\n"
272
+ " API Key: D365FO_MCP_API_KEY_VALUE, D365FO_MCP_API_KEY_HEADER_NAME (optional)"
273
+ )
274
+ sys.exit(1)
258
275
 
259
- logger.error("Warning: Client credentials (D365FO_MCP_AUTH_CLIENT_ID and D365FO_MCP_AUTH_CLIENT_SECRET, D365FO_MCP_AUTH_TENANT_ID,D365FO_MCP_AUTH_BASE_URL and D365FO_MCP_AUTH_REQUIRED_SCOPES) are not set. " +
260
- "Remote transports require authentication to the D365FO environment.")
261
- sys.exit(1)
276
+ # OAuth takes precedence if both are configured
277
+ if has_oauth and has_api_key:
278
+ logger.warning(
279
+ "Both OAuth and API Key authentication configured. "
280
+ "Using OAuth (takes precedence)."
281
+ )
262
282
 
263
- auth_provider: AzureProvider | None = None
283
+ # Initialize authentication provider
284
+ auth_provider: AzureProvider | APIKeyVerifier | None = None # type: ignore
264
285
  auth: AuthSettings | None = None
265
286
 
266
287
  if is_remote_transport:
267
- assert settings.mcp_auth_client_id is not None
268
- assert settings.mcp_auth_client_secret is not None
269
- assert settings.mcp_auth_tenant_id is not None
270
- assert settings.mcp_auth_base_url is not None
271
- assert settings.mcp_auth_required_scopes is not None
272
- required_scopes=settings.mcp_auth_required_scopes_list()
273
-
274
- # if "AX.FullAccess" not in required_scopes:
275
- # logger.warning("Warning: 'AX.FullAccess' scope is not supported. Adding it automatically.")
276
- # required_scopes.append("https://erp.dynamics.com/AX.FullAccess")
277
-
278
- # Initialize authorization settings
279
- auth_provider = AzureProvider(
280
- client_id=settings.mcp_auth_client_id, # Your Azure App Client ID
281
- client_secret=settings.mcp_auth_client_secret, # Your Azure App Client Secret
282
- tenant_id=settings.mcp_auth_tenant_id, # Your Azure Tenant ID (REQUIRED)
283
- base_url=settings.mcp_auth_base_url, # Must match your App registration
284
- required_scopes=required_scopes or ["User.Read"], # type: ignore # Scopes your app needs
285
- redirect_path="/auth/callback", # Ensure callback path is explicit
286
- clients_storage_path=config_path or ...
287
- )
288
- from mcp.shared.auth import OAuthClientInformationFull
289
-
290
- # auth_provider._clients["eae68fdd-610b-47c5-9907-709c7452f1b3"] = OAuthClientInformationFull(
291
- # client_id="5eae68fdd-610b-47c5-9907-709c7452f1b3",
292
- # client_name="Test Client",
293
- # redirect_uris=[AnyUrl("http://127.0.0.1:33418")],
294
- # scope=",".join(required_scopes)
295
- # )
296
-
288
+ has_oauth = settings.has_mcp_auth_credentials()
289
+ has_api_key = settings.has_mcp_api_key_auth()
290
+
291
+ if has_oauth:
292
+ # OAuth authentication setup
293
+ logger.info("Initializing OAuth authentication with Azure AD")
294
+
295
+ assert settings.mcp_auth_client_id is not None
296
+ assert settings.mcp_auth_client_secret is not None
297
+ assert settings.mcp_auth_tenant_id is not None
298
+ assert settings.mcp_auth_base_url is not None
299
+ assert settings.mcp_auth_required_scopes is not None
300
+ required_scopes = settings.mcp_auth_required_scopes_list()
301
+
302
+ # Initialize authorization settings
303
+ auth_provider = AzureProvider(
304
+ client_id=settings.mcp_auth_client_id,
305
+ client_secret=settings.mcp_auth_client_secret,
306
+ tenant_id=settings.mcp_auth_tenant_id,
307
+ base_url=settings.mcp_auth_base_url,
308
+ required_scopes=required_scopes or ["User.Read"], # type: ignore
309
+ redirect_path="/auth/callback",
310
+ clients_storage_path=config_path or ...
311
+ )
297
312
 
298
- auth=AuthSettings(
313
+ auth = AuthSettings(
299
314
  issuer_url=AnyHttpUrl(settings.mcp_auth_base_url),
300
315
  client_registration_options=ClientRegistrationOptions(
301
316
  enabled=True,
302
- valid_scopes=required_scopes or ["User.Read"], # type: ignore
303
- default_scopes=required_scopes or ["User.Read"], # type: ignore
317
+ valid_scopes=required_scopes or ["User.Read"], # type: ignore
318
+ default_scopes=required_scopes or ["User.Read"], # type: ignore
304
319
  ),
305
- required_scopes=required_scopes or ["User.Read"], # type: ignore
320
+ required_scopes=required_scopes or ["User.Read"], # type: ignore
306
321
  resource_server_url=AnyHttpUrl(settings.mcp_auth_base_url),
307
-
322
+ )
323
+
324
+ elif has_api_key:
325
+ # API Key authentication setup
326
+ logger.info("Initializing API Key authentication")
327
+
328
+ from d365fo_client.mcp.auth_server.auth.providers.apikey import APIKeyVerifier
329
+
330
+ auth_provider = APIKeyVerifier( # type: ignore
331
+ api_key=settings.mcp_api_key_value, # type: ignore
332
+ base_url=settings.mcp_auth_base_url,
333
+ )
334
+
335
+ # For API Key authentication
336
+ auth = AuthSettings(
337
+ issuer_url=AnyHttpUrl(settings.mcp_auth_base_url) if settings.mcp_auth_base_url else AnyHttpUrl("http://localhost"),
338
+ resource_server_url=None
308
339
  )
309
340
 
310
341
  # Initialize FastMCP server with configuration
311
342
  mcp = FastMCP(
312
343
  name=server_config.get("name", "d365fo-mcp-server"),
313
- auth_server_provider=auth_provider,
344
+ auth_server_provider=auth_provider if isinstance(auth_provider, AzureProvider) else None,
345
+ token_verifier=auth_provider if isinstance(auth_provider, APIKeyVerifier) else None,
314
346
  auth=auth,
315
347
  instructions=server_config.get(
316
348
  "instructions",
@@ -326,16 +358,33 @@ mcp = FastMCP(
326
358
 
327
359
  )
328
360
 
329
- if is_remote_transport:
361
+ # Add OAuth callback route only for Azure OAuth provider
362
+ if is_remote_transport and isinstance(auth_provider, AzureProvider):
330
363
  from starlette.requests import Request
331
364
  from starlette.responses import RedirectResponse
332
-
365
+
333
366
  @mcp.custom_route(path=auth_provider._redirect_path, methods=["GET"]) # type: ignore
334
367
  async def handle_idp_callback(request: Request) -> RedirectResponse:
335
368
  return await auth_provider._handle_idp_callback(request) # type: ignore
336
369
 
370
+ # Initialize FastD365FOMCPServer
337
371
  server = FastD365FOMCPServer(mcp, config, profile_manager=profile_manager)
338
372
 
373
+ # Configure API Key authentication if enabled
374
+ if is_remote_transport:
375
+ from d365fo_client.mcp.auth_server.auth.providers.apikey import APIKeyVerifier
376
+
377
+ if isinstance(auth_provider, APIKeyVerifier):
378
+
379
+ logger.info("API Key authentication configured successfully")
380
+ logger.info("=" * 60)
381
+ logger.info("IMPORTANT: Clients must authenticate using:")
382
+ logger.info(" Authorization: Bearer <your-api-key>")
383
+ logger.info("")
384
+ logger.info("Example:")
385
+ logger.info(f" curl -H 'Authorization: Bearer YOUR_KEY' http://localhost:{transport_config.get('http', {}).get('port', 8000)}/")
386
+ logger.info("=" * 60)
387
+
339
388
  logger.info("FastD365FOMCPServer initialized successfully")
340
389
 
341
390
 
@@ -5,7 +5,7 @@ from enum import Enum
5
5
  from pathlib import Path
6
6
  from typing import Literal, Optional
7
7
 
8
- from pydantic import Field, field_validator
8
+ from pydantic import Field, SecretStr, field_validator
9
9
  from pydantic_settings import BaseSettings, SettingsConfigDict
10
10
 
11
11
  from .utils import get_default_cache_directory
@@ -104,7 +104,15 @@ class D365FOSettings(BaseSettings):
104
104
  description="MCP authentication required scopes (comma-separated)",
105
105
  alias="D365FO_MCP_AUTH_REQUIRED_SCOPES"
106
106
  )
107
-
107
+
108
+ # === MCP API Key Authentication Settings ===
109
+
110
+ mcp_api_key_value: Optional[SecretStr] = Field(
111
+ default=None,
112
+ description="API key value for authentication (send as Authorization: Bearer <key>)",
113
+ alias="D365FO_MCP_API_KEY_VALUE"
114
+ )
115
+
108
116
  # === MCP Server Transport Settings ===
109
117
 
110
118
  mcp_transport: TransportProtocol = Field(
@@ -285,6 +293,10 @@ class D365FOSettings(BaseSettings):
285
293
  def has_mcp_auth_credentials(self) -> bool:
286
294
  """Check if MCP authentication credentials are configured."""
287
295
  return all([self.mcp_auth_client_id, self.mcp_auth_client_secret, self.mcp_auth_tenant_id])
296
+
297
+ def has_mcp_api_key_auth(self) -> bool:
298
+ """Check if API key authentication is configured."""
299
+ return self.mcp_api_key_value is not None
288
300
 
289
301
  def get_startup_mode(self) -> Literal["profile_only", "default_auth", "client_credentials"]:
290
302
  """Determine startup mode based on configuration."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d365fo-client
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Microsoft Dynamics 365 Finance & Operations client
5
5
  Author-email: Muhammad Afzaal <mo@thedataguy.pro>
6
6
  License-Expression: MIT
@@ -42,6 +42,7 @@ src/d365fo_client/mcp/auth_server/auth/auth.py
42
42
  src/d365fo_client/mcp/auth_server/auth/oauth_proxy.py
43
43
  src/d365fo_client/mcp/auth_server/auth/redirect_validation.py
44
44
  src/d365fo_client/mcp/auth_server/auth/providers/__init__.py
45
+ src/d365fo_client/mcp/auth_server/auth/providers/apikey.py
45
46
  src/d365fo_client/mcp/auth_server/auth/providers/azure.py
46
47
  src/d365fo_client/mcp/auth_server/auth/providers/bearer.py
47
48
  src/d365fo_client/mcp/auth_server/auth/providers/jwt.py
@@ -84,4 +85,5 @@ src/d365fo_client/metadata_v2/label_utils.py
84
85
  src/d365fo_client/metadata_v2/search_engine_v2.py
85
86
  src/d365fo_client/metadata_v2/sync_manager_v2.py
86
87
  src/d365fo_client/metadata_v2/sync_session_manager.py
87
- src/d365fo_client/metadata_v2/version_detector.py
88
+ src/d365fo_client/metadata_v2/version_detector.py
89
+ tests/test_azure_provider_persistence.py
@@ -0,0 +1,394 @@
1
+ """
2
+ Test cases for Azure OAuth provider client persistence functionality.
3
+
4
+ This module tests the save/load functionality for OAuth client data
5
+ in the Azure provider, including serialization, deserialization,
6
+ error handling, and edge cases.
7
+ """
8
+
9
+ import json
10
+ import tempfile
11
+ import pytest
12
+ from pathlib import Path
13
+ from unittest.mock import patch, MagicMock
14
+ from pydantic import AnyUrl
15
+
16
+ from mcp.shared.auth import OAuthClientInformationFull
17
+
18
+ from d365fo_client.mcp.auth_server.auth.providers.azure import AzureProvider, AzureProviderSettings
19
+
20
+
21
+ class TestAzureProviderPersistence:
22
+ """Test cases for Azure provider client persistence."""
23
+
24
+ @pytest.fixture
25
+ def temp_storage_dir(self):
26
+ """Create a temporary directory for testing storage."""
27
+ with tempfile.TemporaryDirectory() as temp_dir:
28
+ yield temp_dir
29
+
30
+ @pytest.fixture
31
+ def minimal_settings(self):
32
+ """Minimal settings for testing."""
33
+ return {
34
+ "client_id": "test-client-id",
35
+ "client_secret": "test-client-secret",
36
+ "tenant_id": "test-tenant-id",
37
+ "base_url": "http://localhost:8000",
38
+ }
39
+
40
+ @pytest.fixture
41
+ def sample_client_data(self):
42
+ """Sample OAuth client data for testing."""
43
+ return OAuthClientInformationFull(
44
+ client_id="test-oauth-client-123",
45
+ client_secret="oauth-secret-456",
46
+ client_name="Test OAuth Client",
47
+ scope="User.Read email openid profile",
48
+ redirect_uris=[
49
+ AnyUrl("http://localhost:3000/callback"),
50
+ AnyUrl("https://example.com/auth/callback"),
51
+ ],
52
+ )
53
+
54
+ @pytest.fixture
55
+ def azure_provider(self, minimal_settings, temp_storage_dir):
56
+ """Create an Azure provider instance for testing."""
57
+ settings = {**minimal_settings, "clients_storage_path": temp_storage_dir}
58
+ return AzureProvider(**settings)
59
+
60
+ def test_save_clients_creates_directory(self, azure_provider, sample_client_data, temp_storage_dir):
61
+ """Test that _save_clients creates the storage directory if it doesn't exist."""
62
+ # Remove the directory to test creation
63
+ storage_path = Path(temp_storage_dir)
64
+ storage_path.rmdir()
65
+ assert not storage_path.exists()
66
+
67
+ # Add a test client and save
68
+ azure_provider._clients["test-id"] = sample_client_data
69
+ azure_provider._save_clients()
70
+
71
+ # Verify directory was created and file exists
72
+ assert storage_path.exists()
73
+ assert (storage_path / "clients.json").exists()
74
+
75
+ def test_save_clients_success(self, azure_provider, sample_client_data, temp_storage_dir):
76
+ """Test successful client data saving."""
77
+ # Add test client
78
+ client_id = "test-client-123"
79
+ azure_provider._clients[client_id] = sample_client_data
80
+
81
+ # Save clients
82
+ azure_provider._save_clients()
83
+
84
+ # Verify file was created
85
+ json_path = Path(temp_storage_dir) / "clients.json"
86
+ assert json_path.exists()
87
+
88
+ # Verify content
89
+ with json_path.open("r") as f:
90
+ saved_data = json.load(f)
91
+
92
+ assert client_id in saved_data
93
+ client_data = saved_data[client_id]
94
+ assert client_data["client_id"] == sample_client_data.client_id
95
+ assert client_data["client_name"] == sample_client_data.client_name
96
+ assert client_data["scope"] == sample_client_data.scope
97
+
98
+ # Verify redirect_uris are properly serialized as strings
99
+ assert isinstance(client_data["redirect_uris"], list)
100
+ assert all(isinstance(uri, str) for uri in client_data["redirect_uris"])
101
+ assert "http://localhost:3000/callback" in client_data["redirect_uris"]
102
+
103
+ def test_save_clients_no_storage_path(self, minimal_settings, sample_client_data):
104
+ """Test save_clients when no storage path is configured."""
105
+ # Create provider without storage path
106
+ provider = AzureProvider(**minimal_settings)
107
+ provider._clients["test"] = sample_client_data
108
+
109
+ # Should not raise an error, just log a warning
110
+ provider._save_clients() # Should complete without error
111
+
112
+ def test_save_clients_individual_client_error(self, azure_provider, sample_client_data, temp_storage_dir):
113
+ """Test save_clients handles individual client serialization errors gracefully."""
114
+ # Add a good client
115
+ good_client = sample_client_data
116
+ azure_provider._clients["good-client"] = good_client
117
+
118
+ # Add a bad client (mock to cause serialization error)
119
+ bad_client = MagicMock()
120
+ bad_client.model_dump.side_effect = ValueError("Serialization error")
121
+ azure_provider._clients["bad-client"] = bad_client
122
+
123
+ # Save should succeed, skipping the bad client
124
+ azure_provider._save_clients()
125
+
126
+ # Verify only good client was saved
127
+ json_path = Path(temp_storage_dir) / "clients.json"
128
+ with json_path.open("r") as f:
129
+ saved_data = json.load(f)
130
+
131
+ assert "good-client" in saved_data
132
+ assert "bad-client" not in saved_data
133
+
134
+ def test_save_clients_atomic_write(self, azure_provider, sample_client_data, temp_storage_dir):
135
+ """Test that save_clients uses atomic write operations."""
136
+ # Add test client
137
+ azure_provider._clients["test"] = sample_client_data
138
+
139
+ json_path = Path(temp_storage_dir) / "clients.json"
140
+ temp_path = json_path.with_suffix('.tmp')
141
+
142
+ # Mock to verify temporary file usage
143
+ original_replace = Path.replace
144
+ replace_called = []
145
+
146
+ def mock_replace(self, target):
147
+ replace_called.append((str(self), str(target)))
148
+ return original_replace(self, target)
149
+
150
+ with patch.object(Path, 'replace', mock_replace):
151
+ azure_provider._save_clients()
152
+
153
+ # Verify atomic rename was used
154
+ assert len(replace_called) == 1
155
+ assert replace_called[0][0].endswith('.tmp')
156
+ assert replace_called[0][1] == str(json_path)
157
+
158
+ def test_load_clients_success(self, azure_provider, sample_client_data, temp_storage_dir):
159
+ """Test successful client data loading."""
160
+ # Create test data file
161
+ client_id = "test-client-123"
162
+ test_data = {
163
+ client_id: sample_client_data.model_dump(mode="json")
164
+ }
165
+
166
+ json_path = Path(temp_storage_dir) / "clients.json"
167
+ with json_path.open("w") as f:
168
+ json.dump(test_data, f)
169
+
170
+ # Load clients
171
+ azure_provider._load_clients()
172
+
173
+ # Verify client was loaded
174
+ assert client_id in azure_provider._clients
175
+ loaded_client = azure_provider._clients[client_id]
176
+ assert isinstance(loaded_client, OAuthClientInformationFull)
177
+ assert loaded_client.client_id == sample_client_data.client_id
178
+ assert loaded_client.client_name == sample_client_data.client_name
179
+ assert loaded_client.scope == sample_client_data.scope
180
+
181
+ # Verify redirect_uris are properly restored as AnyUrl objects
182
+ assert len(loaded_client.redirect_uris) == len(sample_client_data.redirect_uris)
183
+ for loaded_uri, original_uri in zip(loaded_client.redirect_uris, sample_client_data.redirect_uris):
184
+ assert str(loaded_uri) == str(original_uri)
185
+
186
+ def test_load_clients_no_storage_path(self, minimal_settings):
187
+ """Test load_clients when no storage path is configured."""
188
+ provider = AzureProvider(**minimal_settings)
189
+ provider._load_clients() # Should complete without error
190
+
191
+ def test_load_clients_no_file(self, azure_provider):
192
+ """Test load_clients when storage file doesn't exist."""
193
+ azure_provider._load_clients() # Should complete without error
194
+ assert len(azure_provider._clients) == 0
195
+
196
+ def test_load_clients_invalid_json(self, azure_provider, temp_storage_dir):
197
+ """Test load_clients with invalid JSON file."""
198
+ # Create invalid JSON file
199
+ json_path = Path(temp_storage_dir) / "clients.json"
200
+ with json_path.open("w") as f:
201
+ f.write("{ invalid json }")
202
+
203
+ # Should handle error gracefully
204
+ azure_provider._load_clients()
205
+ assert len(azure_provider._clients) == 0
206
+
207
+ def test_load_clients_invalid_data_format(self, azure_provider, temp_storage_dir):
208
+ """Test load_clients with invalid data format (not a dict)."""
209
+ # Create file with invalid format
210
+ json_path = Path(temp_storage_dir) / "clients.json"
211
+ with json_path.open("w") as f:
212
+ json.dump(["not", "a", "dict"], f)
213
+
214
+ # Should handle error gracefully
215
+ azure_provider._load_clients()
216
+ assert len(azure_provider._clients) == 0
217
+
218
+ def test_load_clients_individual_client_error(self, azure_provider, sample_client_data, temp_storage_dir):
219
+ """Test load_clients handles individual client validation errors gracefully."""
220
+ # Create test data with one good and one bad client
221
+ good_client_data = sample_client_data.model_dump(mode="json")
222
+ bad_client_data = {"client_id": "bad", "invalid_field": "invalid"}
223
+
224
+ test_data = {
225
+ "good-client": good_client_data,
226
+ "bad-client": bad_client_data,
227
+ }
228
+
229
+ json_path = Path(temp_storage_dir) / "clients.json"
230
+ with json_path.open("w") as f:
231
+ json.dump(test_data, f)
232
+
233
+ # Load should succeed, skipping the bad client
234
+ azure_provider._load_clients()
235
+
236
+ assert "good-client" in azure_provider._clients
237
+ assert "bad-client" not in azure_provider._clients
238
+ assert isinstance(azure_provider._clients["good-client"], OAuthClientInformationFull)
239
+
240
+ def test_load_clients_non_string_client_id(self, azure_provider, sample_client_data, temp_storage_dir):
241
+ """Test load_clients handles non-string client IDs gracefully."""
242
+ # Create test data with non-string client ID
243
+ good_client_data = sample_client_data.model_dump(mode="json")
244
+ test_data = {
245
+ "good-client": good_client_data,
246
+ # Note: JSON will convert numeric keys to strings, so we need to test this differently
247
+ # Instead, let's test that our validation catches non-string keys in the loading logic
248
+ }
249
+
250
+ json_path = Path(temp_storage_dir) / "clients.json"
251
+ with json_path.open("w") as f:
252
+ json.dump(test_data, f)
253
+
254
+ # Load should succeed with just the good client
255
+ azure_provider._load_clients()
256
+
257
+ assert "good-client" in azure_provider._clients
258
+ assert len(azure_provider._clients) == 1
259
+
260
+ def test_round_trip_persistence(self, azure_provider, sample_client_data, temp_storage_dir):
261
+ """Test complete save/load round trip."""
262
+ client_id = "round-trip-test"
263
+
264
+ # Add client and save
265
+ azure_provider._clients[client_id] = sample_client_data
266
+ azure_provider._save_clients()
267
+
268
+ # Clear clients and reload
269
+ azure_provider._clients.clear()
270
+ azure_provider._load_clients()
271
+
272
+ # Verify data integrity
273
+ assert client_id in azure_provider._clients
274
+ loaded_client = azure_provider._clients[client_id]
275
+
276
+ # Compare all important fields
277
+ assert loaded_client.client_id == sample_client_data.client_id
278
+ assert loaded_client.client_secret == sample_client_data.client_secret
279
+ assert loaded_client.client_name == sample_client_data.client_name
280
+ assert loaded_client.scope == sample_client_data.scope
281
+ assert len(loaded_client.redirect_uris) == len(sample_client_data.redirect_uris)
282
+
283
+ for loaded_uri, original_uri in zip(loaded_client.redirect_uris, sample_client_data.redirect_uris):
284
+ assert str(loaded_uri) == str(original_uri)
285
+
286
+ async def test_register_client_persistence(self, azure_provider, sample_client_data):
287
+ """Test that register_client properly persists the client data."""
288
+ # Mock the super().register_client to avoid complex setup
289
+ with patch.object(azure_provider.__class__.__bases__[0], 'register_client') as mock_register:
290
+ mock_register.return_value = None
291
+
292
+ # Register client
293
+ client_id = "register-test"
294
+ azure_provider._clients[client_id] = sample_client_data
295
+
296
+ # This should trigger save
297
+ await azure_provider.register_client(sample_client_data)
298
+
299
+ # Verify file was created (basic check since we mocked the registration)
300
+ json_path = Path(azure_provider.clients_storage_path) / "clients.json"
301
+ assert json_path.exists()
302
+
303
+ def test_load_clients_called_during_init(self, minimal_settings, temp_storage_dir):
304
+ """Test that _load_clients is called during provider initialization."""
305
+ # Create a test client file
306
+ test_data = {
307
+ "init-test": {
308
+ "client_id": "init-client",
309
+ "redirect_uris": ["http://localhost:8080/"],
310
+ "client_name": "Init Test Client"
311
+ }
312
+ }
313
+
314
+ json_path = Path(temp_storage_dir) / "clients.json"
315
+ with json_path.open("w") as f:
316
+ json.dump(test_data, f)
317
+
318
+ # Create provider (should load existing clients)
319
+ settings = {**minimal_settings, "clients_storage_path": temp_storage_dir}
320
+ provider = AzureProvider(**settings)
321
+
322
+ # Verify client was loaded during initialization
323
+ assert "init-test" in provider._clients
324
+ assert provider._clients["init-test"].client_id == "init-client"
325
+
326
+ def test_multiple_clients_persistence(self, azure_provider, temp_storage_dir):
327
+ """Test persistence with multiple clients."""
328
+ # Create multiple test clients
329
+ clients = {}
330
+ for i in range(3):
331
+ client_id = f"client-{i}"
332
+ client = OAuthClientInformationFull(
333
+ client_id=f"oauth-client-{i}",
334
+ client_name=f"Test Client {i}",
335
+ redirect_uris=[AnyUrl(f"http://localhost:300{i}/callback")],
336
+ scope="User.Read",
337
+ )
338
+ clients[client_id] = client
339
+ azure_provider._clients[client_id] = client
340
+
341
+ # Save and reload
342
+ azure_provider._save_clients()
343
+ azure_provider._clients.clear()
344
+ azure_provider._load_clients()
345
+
346
+ # Verify all clients were persisted
347
+ assert len(azure_provider._clients) == 3
348
+ for client_id, original_client in clients.items():
349
+ assert client_id in azure_provider._clients
350
+ loaded_client = azure_provider._clients[client_id]
351
+ assert loaded_client.client_id == original_client.client_id
352
+ assert loaded_client.client_name == original_client.client_name
353
+
354
+ async def test_error_handling_in_register_client(self, azure_provider, sample_client_data):
355
+ """Test error handling when save fails during client registration."""
356
+ # Mock _save_clients to raise an error
357
+ with patch.object(azure_provider, '_save_clients') as mock_save:
358
+ mock_save.side_effect = OSError("Disk full")
359
+
360
+ with patch.object(azure_provider.__class__.__bases__[0], 'register_client') as mock_register:
361
+ mock_register.return_value = None
362
+
363
+ # Should not raise despite save error
364
+ await azure_provider.register_client(sample_client_data)
365
+
366
+ def test_unicode_handling(self, azure_provider, temp_storage_dir):
367
+ """Test proper handling of Unicode characters in client data."""
368
+ # Create client with Unicode characters
369
+ unicode_client = OAuthClientInformationFull(
370
+ client_id="unicode-test",
371
+ client_name="Test Client with Unicode: 测试客户端 🔒",
372
+ redirect_uris=[AnyUrl("http://localhost:8080/callback")],
373
+ scope="User.Read",
374
+ )
375
+
376
+ client_id = "unicode-client"
377
+ azure_provider._clients[client_id] = unicode_client
378
+
379
+ # Save and reload
380
+ azure_provider._save_clients()
381
+ azure_provider._clients.clear()
382
+ azure_provider._load_clients()
383
+
384
+ # Verify Unicode characters were preserved
385
+ loaded_client = azure_provider._clients[client_id]
386
+ assert loaded_client.client_name == "Test Client with Unicode: 测试客户端 🔒"
387
+
388
+
389
+ if __name__ == "__main__":
390
+ pytest.main([__file__])
391
+
392
+
393
+ if __name__ == "__main__":
394
+ pytest.main([__file__])
File without changes
File without changes
File without changes