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.
- teams_phone/__init__.py +3 -0
- teams_phone/__main__.py +7 -0
- teams_phone/cli/__init__.py +8 -0
- teams_phone/cli/api_check.py +267 -0
- teams_phone/cli/auth.py +201 -0
- teams_phone/cli/context.py +108 -0
- teams_phone/cli/helpers.py +65 -0
- teams_phone/cli/locations.py +308 -0
- teams_phone/cli/main.py +99 -0
- teams_phone/cli/numbers.py +1644 -0
- teams_phone/cli/policies.py +893 -0
- teams_phone/cli/tenants.py +364 -0
- teams_phone/cli/users.py +394 -0
- teams_phone/constants.py +97 -0
- teams_phone/exceptions.py +137 -0
- teams_phone/infrastructure/__init__.py +22 -0
- teams_phone/infrastructure/cache_manager.py +274 -0
- teams_phone/infrastructure/config_manager.py +209 -0
- teams_phone/infrastructure/debug_logger.py +321 -0
- teams_phone/infrastructure/graph_client.py +666 -0
- teams_phone/infrastructure/output_formatter.py +234 -0
- teams_phone/models/__init__.py +76 -0
- teams_phone/models/api_responses.py +69 -0
- teams_phone/models/auth.py +100 -0
- teams_phone/models/cache.py +25 -0
- teams_phone/models/config.py +66 -0
- teams_phone/models/location.py +36 -0
- teams_phone/models/number.py +184 -0
- teams_phone/models/policy.py +26 -0
- teams_phone/models/tenant.py +45 -0
- teams_phone/models/user.py +117 -0
- teams_phone/services/__init__.py +21 -0
- teams_phone/services/auth_service.py +536 -0
- teams_phone/services/bulk_operations.py +562 -0
- teams_phone/services/location_service.py +195 -0
- teams_phone/services/number_service.py +489 -0
- teams_phone/services/policy_service.py +330 -0
- teams_phone/services/tenant_service.py +205 -0
- teams_phone/services/user_service.py +435 -0
- teams_phone/utils.py +172 -0
- teams_phone_cli-0.1.2.dist-info/METADATA +15 -0
- teams_phone_cli-0.1.2.dist-info/RECORD +45 -0
- teams_phone_cli-0.1.2.dist-info/WHEEL +4 -0
- teams_phone_cli-0.1.2.dist-info/entry_points.txt +2 -0
- 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)
|