teams-phone-cli 0.1.2__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.
Files changed (45) hide show
  1. teams_phone/__init__.py +3 -0
  2. teams_phone/__main__.py +7 -0
  3. teams_phone/cli/__init__.py +8 -0
  4. teams_phone/cli/api_check.py +267 -0
  5. teams_phone/cli/auth.py +201 -0
  6. teams_phone/cli/context.py +108 -0
  7. teams_phone/cli/helpers.py +65 -0
  8. teams_phone/cli/locations.py +308 -0
  9. teams_phone/cli/main.py +99 -0
  10. teams_phone/cli/numbers.py +1644 -0
  11. teams_phone/cli/policies.py +893 -0
  12. teams_phone/cli/tenants.py +364 -0
  13. teams_phone/cli/users.py +394 -0
  14. teams_phone/constants.py +97 -0
  15. teams_phone/exceptions.py +137 -0
  16. teams_phone/infrastructure/__init__.py +22 -0
  17. teams_phone/infrastructure/cache_manager.py +274 -0
  18. teams_phone/infrastructure/config_manager.py +209 -0
  19. teams_phone/infrastructure/debug_logger.py +321 -0
  20. teams_phone/infrastructure/graph_client.py +666 -0
  21. teams_phone/infrastructure/output_formatter.py +234 -0
  22. teams_phone/models/__init__.py +76 -0
  23. teams_phone/models/api_responses.py +69 -0
  24. teams_phone/models/auth.py +100 -0
  25. teams_phone/models/cache.py +25 -0
  26. teams_phone/models/config.py +66 -0
  27. teams_phone/models/location.py +36 -0
  28. teams_phone/models/number.py +184 -0
  29. teams_phone/models/policy.py +26 -0
  30. teams_phone/models/tenant.py +45 -0
  31. teams_phone/models/user.py +117 -0
  32. teams_phone/services/__init__.py +21 -0
  33. teams_phone/services/auth_service.py +536 -0
  34. teams_phone/services/bulk_operations.py +562 -0
  35. teams_phone/services/location_service.py +195 -0
  36. teams_phone/services/number_service.py +489 -0
  37. teams_phone/services/policy_service.py +330 -0
  38. teams_phone/services/tenant_service.py +205 -0
  39. teams_phone/services/user_service.py +435 -0
  40. teams_phone/utils.py +172 -0
  41. teams_phone_cli-0.1.2.dist-info/METADATA +15 -0
  42. teams_phone_cli-0.1.2.dist-info/RECORD +45 -0
  43. teams_phone_cli-0.1.2.dist-info/WHEEL +4 -0
  44. teams_phone_cli-0.1.2.dist-info/entry_points.txt +2 -0
  45. teams_phone_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,536 @@
1
+ """Authentication service for Microsoft Entra ID authentication.
2
+
3
+ This module provides the AuthService class for handling certificate-based
4
+ and client-secret authentication with Microsoft Graph API.
5
+ """
6
+
7
+ import os
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+
11
+ from cryptography import x509
12
+ from cryptography.hazmat.primitives import hashes, serialization
13
+ from msal import ConfidentialClientApplication # type: ignore[import-untyped]
14
+
15
+ from teams_phone.constants import TOKEN_EXPIRY_BUFFER_SECONDS
16
+ from teams_phone.exceptions import (
17
+ AuthenticationError,
18
+ ConfigurationError,
19
+ NotFoundError,
20
+ )
21
+ from teams_phone.infrastructure import CacheManager, ConfigManager
22
+ from teams_phone.models import (
23
+ AuthMethod,
24
+ AuthStatus,
25
+ AuthStatusType,
26
+ CachedToken,
27
+ TenantProfile,
28
+ )
29
+
30
+
31
+ # Microsoft Graph API default scope
32
+ SCOPES: list[str] = ["https://graph.microsoft.com/.default"]
33
+
34
+ # Environment variable name for client secret authentication
35
+ CLIENT_SECRET_ENV_VAR: str = "TEAMS_PHONE_CLIENT_SECRET"
36
+ """Environment variable name for client secret authentication.
37
+
38
+ Note: Client-secret authentication is less secure than certificate-based
39
+ authentication and should only be used for development and testing purposes.
40
+ """
41
+
42
+
43
+ class AuthService:
44
+ """Service for authenticating with Microsoft Entra ID.
45
+
46
+ Handles certificate-based and client-secret authentication methods,
47
+ token caching, and authentication status management.
48
+
49
+ Attributes:
50
+ config_manager: Manager for tenant configuration.
51
+ cache_manager: Manager for token caching.
52
+ """
53
+
54
+ def __init__(
55
+ self, config_manager: ConfigManager, cache_manager: CacheManager
56
+ ) -> None:
57
+ """Initialize the AuthService.
58
+
59
+ Args:
60
+ config_manager: ConfigManager instance for tenant configuration.
61
+ cache_manager: CacheManager instance for token storage.
62
+ """
63
+ self.config_manager = config_manager
64
+ self.cache_manager = cache_manager
65
+
66
+ def _load_certificate(self, cert_path: str) -> dict[str, str]:
67
+ """Load certificate and extract private key and thumbprint.
68
+
69
+ Reads a PEM file containing both the X.509 certificate and private key,
70
+ extracts the SHA-1 thumbprint for MSAL authentication.
71
+
72
+ Args:
73
+ cert_path: Path to the PEM file containing certificate and private key.
74
+
75
+ Returns:
76
+ Dictionary with 'private_key' (PEM string) and 'thumbprint' (hex string).
77
+
78
+ Raises:
79
+ AuthenticationError: If the certificate file cannot be read or parsed.
80
+ """
81
+ path = Path(cert_path).expanduser()
82
+
83
+ try:
84
+ pem_data = path.read_bytes()
85
+ except FileNotFoundError as e:
86
+ raise AuthenticationError(
87
+ f"Certificate file not found: {cert_path}",
88
+ remediation=(
89
+ "Verify the certificate path in your tenant configuration.\n"
90
+ f"Expected path: {path}\n"
91
+ "Run 'teams-phone tenants show <name>' to check the configured cert_path."
92
+ ),
93
+ ) from e
94
+ except PermissionError as e:
95
+ raise AuthenticationError(
96
+ f"Cannot read certificate file: {cert_path}",
97
+ remediation=(
98
+ f"Check that you have read permissions for {path}.\n"
99
+ "The certificate file must be readable by the current user."
100
+ ),
101
+ ) from e
102
+
103
+ # Load certificate for thumbprint extraction
104
+ try:
105
+ cert = x509.load_pem_x509_certificate(pem_data)
106
+ thumbprint = cert.fingerprint(hashes.SHA1()).hex().upper()
107
+ except ValueError as e:
108
+ raise AuthenticationError(
109
+ f"Invalid certificate format in {cert_path}: {e}",
110
+ remediation=(
111
+ "The certificate file must be in PEM format.\n"
112
+ "Ensure it contains a valid X.509 certificate starting with "
113
+ "'-----BEGIN CERTIFICATE-----'."
114
+ ),
115
+ ) from e
116
+
117
+ # Load private key
118
+ try:
119
+ private_key = serialization.load_pem_private_key(pem_data, password=None)
120
+ private_key_pem = private_key.private_bytes(
121
+ encoding=serialization.Encoding.PEM,
122
+ format=serialization.PrivateFormat.PKCS8,
123
+ encryption_algorithm=serialization.NoEncryption(),
124
+ ).decode("utf-8")
125
+ except ValueError as e:
126
+ raise AuthenticationError(
127
+ f"Invalid private key format in {cert_path}: {e}",
128
+ remediation=(
129
+ "The PEM file must contain a valid private key.\n"
130
+ "Ensure it includes a section starting with "
131
+ "'-----BEGIN PRIVATE KEY-----' or '-----BEGIN RSA PRIVATE KEY-----'."
132
+ ),
133
+ ) from e
134
+ except TypeError as e:
135
+ # Raised when password is required but not provided
136
+ raise AuthenticationError(
137
+ f"Encrypted private key in {cert_path} requires a passphrase",
138
+ remediation=(
139
+ "The private key in this certificate is encrypted.\n"
140
+ "Please provide an unencrypted certificate file, or decrypt the key:\n"
141
+ " openssl rsa -in encrypted.pem -out decrypted.pem"
142
+ ),
143
+ ) from e
144
+
145
+ return {
146
+ "private_key": private_key_pem,
147
+ "thumbprint": thumbprint,
148
+ }
149
+
150
+ def _get_client_secret(self) -> str:
151
+ """Get client secret from environment variable.
152
+
153
+ Client-secret authentication is less secure than certificate-based
154
+ authentication and is intended for development/testing environments only.
155
+
156
+ Returns:
157
+ The client secret string from the environment variable.
158
+
159
+ Raises:
160
+ AuthenticationError: If the environment variable is not set or is empty.
161
+ """
162
+ secret = os.environ.get(CLIENT_SECRET_ENV_VAR)
163
+ if not secret: # Handles None and empty string
164
+ raise AuthenticationError(
165
+ f"Client secret not found in environment variable {CLIENT_SECRET_ENV_VAR}",
166
+ remediation=(
167
+ f"Set the {CLIENT_SECRET_ENV_VAR} environment variable:\n"
168
+ f" export {CLIENT_SECRET_ENV_VAR}='your-client-secret'\n"
169
+ "Note: Client-secret auth is for dev/test only. "
170
+ "Use certificate auth in production."
171
+ ),
172
+ )
173
+ return secret
174
+
175
+ def _create_msal_app(self, profile: TenantProfile) -> ConfidentialClientApplication:
176
+ """Create an MSAL ConfidentialClientApplication for authentication.
177
+
178
+ Supports both certificate-based and client-secret authentication methods.
179
+ Certificate auth is recommended for production; client-secret is intended
180
+ for development and testing only.
181
+
182
+ Args:
183
+ profile: Tenant profile with authentication configuration.
184
+
185
+ Returns:
186
+ Configured MSAL ConfidentialClientApplication instance.
187
+
188
+ Raises:
189
+ AuthenticationError: If certificate loading fails, client secret is
190
+ missing, or an unsupported auth method is specified.
191
+ """
192
+ authority = f"https://login.microsoftonline.com/{profile.tenant_id}"
193
+
194
+ if profile.auth_method == AuthMethod.CERTIFICATE:
195
+ if profile.cert_path is None:
196
+ raise AuthenticationError(
197
+ f"Certificate path not configured for tenant '{profile.name}'",
198
+ remediation=(
199
+ "Add cert_path to the tenant configuration:\n"
200
+ f" teams-phone tenants add {profile.name} --cert-path /path/to/cert.pem"
201
+ ),
202
+ )
203
+
204
+ cert_credential = self._load_certificate(profile.cert_path)
205
+
206
+ return ConfidentialClientApplication(
207
+ client_id=str(profile.client_id),
208
+ client_credential=cert_credential,
209
+ authority=authority,
210
+ )
211
+
212
+ elif profile.auth_method == AuthMethod.CLIENT_SECRET:
213
+ client_secret = self._get_client_secret()
214
+
215
+ return ConfidentialClientApplication(
216
+ client_id=str(profile.client_id),
217
+ client_credential=client_secret,
218
+ authority=authority,
219
+ )
220
+
221
+ # Unreachable with current AuthMethod enum, but defensive for future additions
222
+ raise AuthenticationError(
223
+ f"Auth method '{profile.auth_method.value}' is not supported",
224
+ remediation=(
225
+ "Supported auth methods are 'certificate' and 'client-secret'.\n"
226
+ "Update your tenant configuration to use a supported method."
227
+ ),
228
+ )
229
+
230
+ def _resolve_tenant(self, tenant_name: str | None) -> TenantProfile:
231
+ """Resolve tenant name to a TenantProfile.
232
+
233
+ If tenant_name is None, uses the default tenant from configuration.
234
+
235
+ Args:
236
+ tenant_name: The tenant profile name, or None to use default.
237
+
238
+ Returns:
239
+ TenantProfile for the specified or default tenant.
240
+
241
+ Raises:
242
+ ConfigurationError: If tenant_name is None and no default tenant is set.
243
+ NotFoundError: If the specified tenant does not exist.
244
+ """
245
+ if tenant_name is None:
246
+ default_name = self.config_manager.get_default_tenant()
247
+ if default_name is None:
248
+ raise ConfigurationError(
249
+ "No tenant specified and no default tenant configured",
250
+ remediation=(
251
+ "Either specify a tenant name or set a default tenant:\n"
252
+ " teams-phone tenants default <tenant-name>"
253
+ ),
254
+ )
255
+ tenant_name = default_name
256
+
257
+ return self.config_manager.get_tenant(tenant_name)
258
+
259
+ def _needs_refresh(self, token: CachedToken) -> bool:
260
+ """Check if token needs to be refreshed.
261
+
262
+ Returns True if the token is expired or within the buffer period
263
+ before expiration.
264
+
265
+ Args:
266
+ token: The cached token to check.
267
+
268
+ Returns:
269
+ True if token should be refreshed, False otherwise.
270
+ """
271
+ if token.is_expired():
272
+ return True
273
+
274
+ # Check if within buffer period before expiry
275
+ buffer = timedelta(seconds=TOKEN_EXPIRY_BUFFER_SECONDS)
276
+ now = datetime.now(timezone.utc)
277
+ expires = token.expires_at
278
+ if expires.tzinfo is None:
279
+ expires = expires.replace(tzinfo=timezone.utc)
280
+
281
+ return now >= (expires - buffer)
282
+
283
+ def refresh_token(self, tenant_name: str | None = None) -> CachedToken:
284
+ """Acquire a new token via MSAL and save to cache.
285
+
286
+ Performs token acquisition using the configured authentication method
287
+ (certificate or client-secret) and persists the token to cache.
288
+
289
+ Args:
290
+ tenant_name: The tenant profile name, or None to use default.
291
+
292
+ Returns:
293
+ CachedToken containing the new access token.
294
+
295
+ Raises:
296
+ ConfigurationError: If tenant_name is None and no default is set.
297
+ NotFoundError: If the specified tenant does not exist.
298
+ AuthenticationError: If MSAL authentication fails.
299
+ """
300
+ profile = self._resolve_tenant(tenant_name)
301
+ resolved_name = profile.name
302
+
303
+ app = self._create_msal_app(profile)
304
+ result = app.acquire_token_for_client(scopes=SCOPES)
305
+
306
+ if "access_token" not in result:
307
+ error = result.get("error", "unknown_error")
308
+ error_description = result.get(
309
+ "error_description", "No description provided"
310
+ )
311
+
312
+ # Map common MSAL errors to helpful remediation
313
+ remediation = self._get_error_remediation(error, error_description, profile)
314
+
315
+ raise AuthenticationError(
316
+ f"Failed to acquire token: {error} - {error_description}",
317
+ remediation=remediation,
318
+ )
319
+
320
+ # Calculate expiration time
321
+ expires_in = result.get("expires_in", 3600) # Default to 1 hour
322
+ expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
323
+
324
+ # Create and save the cached token
325
+ cached_token = CachedToken(
326
+ access_token=result["access_token"],
327
+ expires_at=expires_at,
328
+ tenant_id=str(profile.tenant_id),
329
+ scopes=SCOPES,
330
+ )
331
+
332
+ self.cache_manager.save_token(resolved_name, cached_token)
333
+
334
+ return cached_token
335
+
336
+ def _get_error_remediation(
337
+ self, error: str, error_description: str, profile: TenantProfile
338
+ ) -> str:
339
+ """Generate remediation guidance for MSAL errors.
340
+
341
+ Args:
342
+ error: The MSAL error code.
343
+ error_description: The MSAL error description.
344
+ profile: The tenant profile being used.
345
+
346
+ Returns:
347
+ Human-readable remediation guidance.
348
+ """
349
+ if error == "invalid_client":
350
+ if profile.auth_method == AuthMethod.CERTIFICATE:
351
+ return (
352
+ "The certificate may be invalid or not registered with the app.\n"
353
+ "Verify that:\n"
354
+ " 1. The certificate is uploaded to Azure AD app registrations\n"
355
+ " 2. The certificate has not expired\n"
356
+ " 3. The thumbprint matches the registered certificate"
357
+ )
358
+ else:
359
+ return (
360
+ "The client secret may be invalid or expired.\n"
361
+ "Verify that:\n"
362
+ " 1. The secret value is correct\n"
363
+ " 2. The secret has not expired in Azure AD\n"
364
+ " 3. You're using the secret value, not the secret ID"
365
+ )
366
+ elif error == "invalid_grant":
367
+ return (
368
+ "The authentication grant is invalid.\n"
369
+ "This may indicate:\n"
370
+ " 1. The tenant ID is incorrect\n"
371
+ " 2. The app registration is not configured for this tenant\n"
372
+ " 3. Required permissions have not been granted admin consent"
373
+ )
374
+ elif error == "unauthorized_client":
375
+ return (
376
+ "The client is not authorized for this operation.\n"
377
+ "Ensure the app registration has:\n"
378
+ " 1. 'Allow public client flows' set appropriately\n"
379
+ " 2. Required API permissions configured\n"
380
+ " 3. Admin consent granted for the required permissions"
381
+ )
382
+ else:
383
+ return (
384
+ f"Authentication failed with error '{error}'.\n"
385
+ "Check the Azure AD app registration configuration and ensure\n"
386
+ "all required permissions have been granted admin consent."
387
+ )
388
+
389
+ def get_token(self, tenant_name: str | None = None) -> str:
390
+ """Get a valid access token, refreshing if necessary.
391
+
392
+ Returns a cached token if still valid, otherwise acquires a new token
393
+ via MSAL. Tokens are proactively refreshed when within 5 minutes of
394
+ expiration to avoid using tokens that might expire mid-request.
395
+
396
+ Args:
397
+ tenant_name: The tenant profile name, or None to use default.
398
+
399
+ Returns:
400
+ A valid access token string.
401
+
402
+ Raises:
403
+ ConfigurationError: If tenant_name is None and no default is set.
404
+ NotFoundError: If the specified tenant does not exist.
405
+ AuthenticationError: If token acquisition fails.
406
+ """
407
+ profile = self._resolve_tenant(tenant_name)
408
+ resolved_name = profile.name
409
+
410
+ # Check for cached token
411
+ cached_token = self.cache_manager.get_token(resolved_name)
412
+
413
+ if cached_token is not None and not self._needs_refresh(cached_token):
414
+ return cached_token.access_token
415
+
416
+ # Token is missing, expired, or within buffer - refresh it
417
+ new_token = self.refresh_token(resolved_name)
418
+ return new_token.access_token
419
+
420
+ def is_authenticated(self, tenant_name: str | None = None) -> bool:
421
+ """Check if currently authenticated with a valid token.
422
+
423
+ Returns True if there is a cached token that doesn't need refresh.
424
+ This method never raises exceptions - it returns False for any error.
425
+
426
+ Args:
427
+ tenant_name: The tenant profile name, or None to use default.
428
+
429
+ Returns:
430
+ True if authenticated with valid token, False otherwise.
431
+ """
432
+ try:
433
+ profile = self._resolve_tenant(tenant_name)
434
+ resolved_name = profile.name
435
+
436
+ cached_token = self.cache_manager.get_token(resolved_name)
437
+ if cached_token is None:
438
+ return False
439
+
440
+ return not self._needs_refresh(cached_token)
441
+ except (ConfigurationError, NotFoundError, AuthenticationError):
442
+ return False
443
+
444
+ def get_status(self, tenant_name: str | None = None) -> AuthStatus:
445
+ """Get the current authentication status for a tenant.
446
+
447
+ Returns detailed status information including authentication state,
448
+ token expiration time, and error messages when applicable.
449
+
450
+ Args:
451
+ tenant_name: The tenant profile name, or None to use default.
452
+
453
+ Returns:
454
+ AuthStatus with current authentication state and details.
455
+
456
+ Raises:
457
+ ConfigurationError: If tenant_name is None and no default is set.
458
+ NotFoundError: If the specified tenant does not exist.
459
+ """
460
+ profile = self._resolve_tenant(tenant_name)
461
+ resolved_name = profile.name
462
+
463
+ cached_token = self.cache_manager.get_token(resolved_name)
464
+
465
+ if cached_token is None:
466
+ return AuthStatus(
467
+ status=AuthStatusType.UNAUTHENTICATED,
468
+ tenant_name=resolved_name,
469
+ )
470
+
471
+ if cached_token.is_expired():
472
+ return AuthStatus(
473
+ status=AuthStatusType.EXPIRED,
474
+ tenant_name=resolved_name,
475
+ tenant_id=cached_token.tenant_id,
476
+ expires_at=cached_token.expires_at,
477
+ )
478
+
479
+ return AuthStatus(
480
+ status=AuthStatusType.AUTHENTICATED,
481
+ tenant_name=resolved_name,
482
+ tenant_id=cached_token.tenant_id,
483
+ expires_at=cached_token.expires_at,
484
+ )
485
+
486
+ def login(self, tenant_name: str | None = None, force: bool = False) -> CachedToken:
487
+ """Login and acquire an authentication token.
488
+
489
+ Returns an existing valid token if already authenticated, unless force
490
+ is True which always acquires a new token.
491
+
492
+ Args:
493
+ tenant_name: The tenant profile name, or None to use default.
494
+ force: If True, acquire a new token even if already authenticated.
495
+
496
+ Returns:
497
+ CachedToken containing the access token.
498
+
499
+ Raises:
500
+ ConfigurationError: If tenant_name is None and no default is set.
501
+ NotFoundError: If the specified tenant does not exist.
502
+ AuthenticationError: If token acquisition fails.
503
+ """
504
+ profile = self._resolve_tenant(tenant_name)
505
+ resolved_name = profile.name
506
+
507
+ if not force:
508
+ cached_token = self.cache_manager.get_token(resolved_name)
509
+ if cached_token is not None and not self._needs_refresh(cached_token):
510
+ return cached_token
511
+
512
+ return self.refresh_token(resolved_name)
513
+
514
+ def logout(self, tenant_name: str | None = None, all_tenants: bool = False) -> None:
515
+ """Logout and clear cached tokens.
516
+
517
+ Clears the cached token for a specific tenant or all tenants.
518
+
519
+ Args:
520
+ tenant_name: The tenant profile name, or None to use default.
521
+ Ignored if all_tenants is True.
522
+ all_tenants: If True, clear tokens for all configured tenants.
523
+
524
+ Raises:
525
+ ConfigurationError: If tenant_name is None, no default is set,
526
+ and all_tenants is False.
527
+ NotFoundError: If the specified tenant does not exist and
528
+ all_tenants is False.
529
+ """
530
+ if all_tenants:
531
+ self.cache_manager.clear_tokens()
532
+ return
533
+
534
+ profile = self._resolve_tenant(tenant_name)
535
+ resolved_name = profile.name
536
+ self.cache_manager.clear_tokens(resolved_name)