mssql-python 1.16.0__cp315-cp315-win_amd64.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.
mssql_python/auth.py ADDED
@@ -0,0 +1,913 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation.
3
+ Licensed under the MIT license.
4
+ This module handles authentication for the mssql_python package.
5
+ """
6
+
7
+ import hashlib
8
+ import inspect
9
+ import platform
10
+ import struct
11
+ import sys
12
+ import threading
13
+ import time
14
+ from typing import Tuple, Dict, NamedTuple, Optional, TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from azure.core.credentials import TokenCredential
18
+
19
+ from mssql_python.logging import logger
20
+ from mssql_python.constants import (
21
+ AuthType,
22
+ _AuthInternal,
23
+ _KEY_AUTHENTICATION,
24
+ _KEY_UID,
25
+ _KEY_PWD,
26
+ _KEY_TRUSTED_CONNECTION,
27
+ )
28
+ from mssql_python.exceptions import InterfaceError, OperationalError
29
+
30
+ # Module-level credential instance cache.
31
+ # Reusing credential objects allows the Azure Identity SDK's built-in
32
+ # in-memory token cache to work, avoiding redundant token acquisitions.
33
+ # See: https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/identity/azure-identity/TOKEN_CACHING.md
34
+ #
35
+ # Cache is keyed on (auth_type, sorted credential_kwargs), which is
36
+ # bounded by the distinct credentials a single process ever uses.
37
+ #
38
+ # CAVEAT (interactive / device-code): the key for keyless interactive auth is
39
+ # just the auth type (e.g. "interactive"), so ALL interactive connections in a
40
+ # process share one credential instance and therefore one signed-in account —
41
+ # the first user to authenticate. This is what keeps pooled reconnects silent,
42
+ # but it means interactive/device-code auth cannot isolate multiple end users
43
+ # within a single process. Multi-user apps must bring their own per-user token
44
+ # provider instead of relying on interactive auth. (Documented for users in the
45
+ # Connection Pooling section of README.md.)
46
+ _credential_cache: Dict[object, object] = {}
47
+ _credential_cache_lock = threading.Lock()
48
+
49
+ # Stable home_account_id captured the first time an interactive/device-code
50
+ # credential runs authenticate(). Lets later *silent* get_token() acquisitions
51
+ # still key the pool on the account without re-authenticating. Keyed like
52
+ # _credential_cache and guarded by the same lock.
53
+ _account_id_cache: Dict[object, Optional[str]] = {}
54
+
55
+ # Canonical keys to strip when handing an Entra-token connection to ODBC.
56
+ _SENSITIVE_KEYS = frozenset({_KEY_UID, _KEY_PWD, _KEY_TRUSTED_CONNECTION, _KEY_AUTHENTICATION})
57
+
58
+
59
+ class TokenInfo(NamedTuple):
60
+ """Result of an access-token acquisition.
61
+
62
+ ``token_struct`` is the SQL Server / ODBC access-token blob (length-prefixed
63
+ UTF-16LE) placed in ``attrs_before``. ``expires_on`` is the POSIX epoch
64
+ second at which the underlying JWT expires, or ``None`` when the credential
65
+ did not surface an expiry (older SDKs / test doubles). ``home_account_id``
66
+ is the stable per-account identifier surfaced by ``authenticate()`` for
67
+ Interactive / Device-code auth (used as the pool's security-context key so
68
+ a silent token refresh reuses the pool but a different account gets its own
69
+ pool); ``None`` for auth types that do not expose it.
70
+ """
71
+
72
+ token_struct: bytes
73
+ expires_on: Optional[int]
74
+ home_account_id: Optional[str] = None
75
+
76
+
77
+ # Azure SQL Database OAuth scope for the Azure **commercial** cloud, requested
78
+ # for all SQL Server / Azure SQL access tokens. Shared by the built-in AADAuth
79
+ # path and the custom token_provider path. Sovereign clouds (Azure US Gov,
80
+ # Azure China, Azure Germany) are out of scope — a token for a different
81
+ # audience is rejected by SQL Server at login.
82
+ _SQL_SCOPE = "https://database.windows.net/.default"
83
+
84
+ # One-time guard so the DefaultAzureCredential multi-user pooling caveat is
85
+ # logged at most once per process rather than on every token acquisition.
86
+ _dac_pooling_warned = False
87
+ _dac_pooling_warned_lock = threading.Lock()
88
+
89
+
90
+ def _warn_default_credential_pooling() -> None:
91
+ """Emit a one-time warning that DefaultAzureCredential is not recommended
92
+ for multi-user connection pooling.
93
+
94
+ DefaultAzureCredential walks a credential chain and can silently resolve to
95
+ a different underlying identity between calls, so we isolate it per token
96
+ (a new pool whenever the token changes) rather than reusing across
97
+ identities. Applications that need stable multi-user pooling should use a
98
+ concrete credential (Managed Identity, Service Principal) or supply their
99
+ own token provider.
100
+ """
101
+ global _dac_pooling_warned # pylint: disable=global-statement
102
+ if _dac_pooling_warned:
103
+ return
104
+ with _dac_pooling_warned_lock:
105
+ if _dac_pooling_warned:
106
+ return
107
+ _dac_pooling_warned = True
108
+ logger.warning(
109
+ "DefaultAzureCredential is not recommended for multi-user connection "
110
+ "pooling: its credential chain can resolve to different identities "
111
+ "between calls, so connections are isolated per token (a new pool per "
112
+ "distinct token). Use a concrete credential (ManagedIdentity / "
113
+ "ServicePrincipal) or a token provider for stable pooled reuse."
114
+ )
115
+
116
+
117
+ # Map Authentication connection-string values to internal short names.
118
+ _AUTH_TYPE_MAP: Dict[str, str] = {
119
+ AuthType.INTERACTIVE.value: _AuthInternal.INTERACTIVE,
120
+ AuthType.DEVICE_CODE.value: _AuthInternal.DEVICE_CODE,
121
+ AuthType.DEFAULT.value: _AuthInternal.DEFAULT,
122
+ AuthType.MSI.value: _AuthInternal.MSI,
123
+ AuthType.SERVICE_PRINCIPAL.value: _AuthInternal.SERVICE_PRINCIPAL,
124
+ }
125
+
126
+
127
+ def _credential_cache_key(auth_type: str, credential_kwargs: Optional[Dict[str, str]]):
128
+ """Build a hashable cache key from auth_type and optional credential kwargs.
129
+
130
+ Returns the plain auth_type string when no kwargs are provided so that
131
+ callers caching by string (the original behavior) keep working. When
132
+ kwargs are present (e.g. user-assigned MSI client_id), the key is a
133
+ tuple of ``(auth_type, sorted_kwargs_items)`` so different kwargs map
134
+ to different cached credentials.
135
+ """
136
+ if not credential_kwargs:
137
+ return auth_type
138
+ return (auth_type, tuple(sorted(credential_kwargs.items())))
139
+
140
+
141
+ class AADAuth:
142
+ """Handles Azure Active Directory authentication"""
143
+
144
+ @staticmethod
145
+ def get_token_struct(token: str) -> bytes:
146
+ """Convert token to SQL Server compatible format"""
147
+ logger.debug(
148
+ "get_token_struct: Converting token to SQL Server format - token_length=%d chars",
149
+ len(token),
150
+ )
151
+ token_bytes = token.encode("UTF-16-LE")
152
+ logger.debug(
153
+ "get_token_struct: Token encoded to UTF-16-LE - byte_length=%d", len(token_bytes)
154
+ )
155
+ return struct.pack(f"<I{len(token_bytes)}s", len(token_bytes), token_bytes)
156
+
157
+ @staticmethod
158
+ def get_token(auth_type: str, credential_kwargs: Optional[Dict[str, str]] = None) -> bytes:
159
+ """Get DDBC token struct for the specified authentication type."""
160
+ token_struct, _, _, _ = AADAuth._acquire_token(auth_type, credential_kwargs)
161
+ return token_struct
162
+
163
+ @staticmethod
164
+ def get_token_info(
165
+ auth_type: str, credential_kwargs: Optional[Dict[str, str]] = None
166
+ ) -> TokenInfo:
167
+ """Acquire a token and return both the ODBC struct and its expiry.
168
+
169
+ Foundation for expiry-aware pool checkout: it surfaces ``expires_on`` so
170
+ a pooled connection's token can eventually be refreshed/discarded near
171
+ expiry. Interactive / Device-code additionally surface
172
+ ``home_account_id`` so the pool can key on a stable account identity
173
+ across silent refreshes.
174
+ """
175
+ token_struct, _, expires_on, home_account_id = AADAuth._acquire_token(
176
+ auth_type, credential_kwargs
177
+ )
178
+ return TokenInfo(
179
+ token_struct=token_struct,
180
+ expires_on=expires_on,
181
+ home_account_id=home_account_id,
182
+ )
183
+
184
+ @staticmethod
185
+ def get_raw_token(auth_type: str, credential_kwargs: Optional[Dict[str, str]] = None) -> str:
186
+ """Acquire a raw JWT for the mssql-py-core connection (bulk copy).
187
+
188
+ Uses the cached credential instance so the Azure Identity SDK's
189
+ built-in token cache can serve a valid token without a round-trip
190
+ when the previous token has not yet expired.
191
+ """
192
+ _, raw_token, _, _ = AADAuth._acquire_token(auth_type, credential_kwargs)
193
+ return raw_token
194
+
195
+ @staticmethod
196
+ def _authenticate_interactive(credential) -> Optional[str]:
197
+ """Run the interactive ``authenticate()`` step and return the resulting
198
+ ``home_account_id``.
199
+
200
+ This is the *fallback* half of the silent-first pattern: it is invoked
201
+ only when :meth:`_acquire_token` catches ``AuthenticationRequiredError``
202
+ from a silent ``get_token()``, i.e. when interactive login is genuinely
203
+ required (first sign-in or a changed account). ``authenticate()``
204
+ performs the interactive step and caches the record on the credential,
205
+ so subsequent silent ``get_token()`` calls for the same account succeed
206
+ without prompting; a different account yields a new ``home_account_id``
207
+ (rotating the pool key). Returns ``None`` when the SDK / test double does
208
+ not expose ``authenticate()`` or ``home_account_id`` — the caller then
209
+ falls back to the token-hash pool key so the safety invariant still holds.
210
+
211
+ Real authentication failures (e.g. a cancelled prompt raising
212
+ ``ClientAuthenticationError``) are allowed to propagate to
213
+ :meth:`_acquire_token`'s handler rather than being swallowed here.
214
+ """
215
+ authenticate = getattr(credential, "authenticate", None)
216
+ if authenticate is None:
217
+ return None
218
+ try:
219
+ record = authenticate(scopes=[_SQL_SCOPE])
220
+ except TypeError:
221
+ # Older/mocked signatures may not accept the scopes keyword.
222
+ record = authenticate()
223
+ return getattr(record, "home_account_id", None)
224
+
225
+ @staticmethod
226
+ def _acquire_token(
227
+ auth_type: str, credential_kwargs: Optional[Dict[str, str]] = None
228
+ ) -> Tuple[bytes, str, Optional[int], Optional[str]]:
229
+ """Internal: acquire token and return
230
+ ``(ddbc_struct, raw_jwt, expires_on, home_account_id)``.
231
+
232
+ ``home_account_id`` is populated only for Interactive / Device-code auth
233
+ (via ``authenticate()``); it is ``None`` for every other auth type.
234
+ """
235
+ # Import Azure libraries inside method to support test mocking
236
+ # pylint: disable=import-outside-toplevel
237
+ try:
238
+ from azure.identity import (
239
+ AuthenticationRequiredError,
240
+ DefaultAzureCredential,
241
+ DeviceCodeCredential,
242
+ InteractiveBrowserCredential,
243
+ ManagedIdentityCredential,
244
+ )
245
+ from azure.core.exceptions import ClientAuthenticationError
246
+ except ImportError as e:
247
+ raise RuntimeError(
248
+ "Azure authentication libraries are not installed. "
249
+ "Please install with: pip install azure-identity azure-core"
250
+ ) from e
251
+
252
+ # Mapping of auth types to credential classes
253
+ credential_map = {
254
+ _AuthInternal.DEFAULT: DefaultAzureCredential,
255
+ _AuthInternal.DEVICE_CODE: DeviceCodeCredential,
256
+ _AuthInternal.INTERACTIVE: InteractiveBrowserCredential,
257
+ _AuthInternal.MSI: ManagedIdentityCredential,
258
+ }
259
+
260
+ credential_class = credential_map.get(auth_type)
261
+ if not credential_class:
262
+ raise ValueError(
263
+ f"Unsupported auth_type '{auth_type}'. " f"Supported: {', '.join(credential_map)}"
264
+ )
265
+ logger.info(
266
+ "get_token: Starting Azure AD authentication - auth_type=%s, credential_class=%s",
267
+ auth_type,
268
+ credential_class.__name__,
269
+ )
270
+
271
+ # DefaultAzureCredential is not recommended for multi-user pooling; warn
272
+ # once so operators understand why these connections isolate per token.
273
+ if auth_type == _AuthInternal.DEFAULT:
274
+ _warn_default_credential_pooling()
275
+
276
+ # Interactive / Device-code follow a silent-first pattern (see the
277
+ # get_token() call below): disable_automatic_authentication makes
278
+ # get_token() acquire silently from the credential's cached account and
279
+ # raise AuthenticationRequiredError (instead of prompting) when
280
+ # interactive login is genuinely required, at which point we call
281
+ # authenticate(). This keeps pooled reconnects prompt-free.
282
+ is_interactive = auth_type in (_AuthInternal.INTERACTIVE, _AuthInternal.DEVICE_CODE)
283
+
284
+ kwargs = credential_kwargs or {}
285
+ cache_key = _credential_cache_key(auth_type, kwargs)
286
+ try:
287
+ with _credential_cache_lock:
288
+ if cache_key not in _credential_cache:
289
+ logger.debug(
290
+ "get_token: Creating new credential instance for auth_type=%s",
291
+ auth_type,
292
+ )
293
+ construct_kwargs: Dict[str, object] = dict(kwargs)
294
+ if is_interactive:
295
+ construct_kwargs["disable_automatic_authentication"] = True
296
+ _credential_cache[cache_key] = credential_class(**construct_kwargs)
297
+ else:
298
+ logger.debug(
299
+ "get_token: Reusing cached credential instance for auth_type=%s",
300
+ auth_type,
301
+ )
302
+ credential = _credential_cache[cache_key]
303
+
304
+ home_account_id: Optional[str] = None
305
+ if is_interactive:
306
+ # Silent-first: try the cached account, and only fall back to
307
+ # the interactive authenticate() step when the SDK signals it is
308
+ # actually required. A browser/device-code prompt on every
309
+ # pooled checkout would defeat pooling for these auth types.
310
+ with _credential_cache_lock:
311
+ home_account_id = _account_id_cache.get(cache_key)
312
+ try:
313
+ access_token = credential.get_token(_SQL_SCOPE)
314
+ except AuthenticationRequiredError:
315
+ home_account_id = AADAuth._authenticate_interactive(credential)
316
+ with _credential_cache_lock:
317
+ _account_id_cache[cache_key] = home_account_id
318
+ access_token = credential.get_token(_SQL_SCOPE)
319
+ else:
320
+ access_token = credential.get_token(_SQL_SCOPE)
321
+
322
+ raw_token = access_token.token
323
+ # expires_on is a POSIX epoch second on azure-identity's AccessToken.
324
+ # getattr keeps older SDKs / test doubles (which may omit it) working.
325
+ expires_on = getattr(access_token, "expires_on", None)
326
+ logger.info(
327
+ "get_token: Azure AD token acquired successfully - token_length=%d chars",
328
+ len(raw_token),
329
+ )
330
+ token_struct = AADAuth.get_token_struct(raw_token)
331
+ return token_struct, raw_token, expires_on, home_account_id
332
+ except ClientAuthenticationError as e:
333
+ logger.error(
334
+ "get_token: Azure AD authentication failed - credential_class=%s, error=%s",
335
+ credential_class.__name__,
336
+ str(e),
337
+ )
338
+ raise RuntimeError(
339
+ f"Azure AD authentication failed for {credential_class.__name__}: {e}. "
340
+ f"This could be due to invalid credentials, missing environment variables, "
341
+ f"user cancellation, network issues, or unsupported configuration."
342
+ ) from e
343
+ except Exception as e:
344
+ logger.error(
345
+ "get_token: Unexpected error during credential creation - credential_class=%s, error=%s",
346
+ credential_class.__name__,
347
+ str(e),
348
+ )
349
+ raise RuntimeError(f"Failed to create {credential_class.__name__}: {e}") from e
350
+
351
+
352
+ _RESERVED_TENANTS = frozenset({"common", "organizations", "consumers"})
353
+
354
+
355
+ def _parse_tenant_id(sts_url: str) -> Optional[str]:
356
+ """Extract tenant ID (GUID or domain) from a FedAuthInfo STS URL.
357
+
358
+ Expected formats:
359
+ https://login.microsoftonline.com/<tenant>/
360
+ https://login.microsoftonline.com/<tenant>/?...
361
+ https://login.microsoftonline.com/<tenant>
362
+ where <tenant> is either a GUID (e.g. ``aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee``)
363
+ or a verified domain (e.g. ``contoso.onmicrosoft.com``). Both forms are
364
+ accepted by ``azure.identity.ClientSecretCredential``.
365
+
366
+ Returns ``None`` for the multi-tenant aliases ``common`` / ``organizations``
367
+ / ``consumers``: confidential clients (SP) cannot authenticate against
368
+ them and AAD responds with a cryptic ``AADSTS50194`` ("application is not
369
+ configured as multi-tenant"). Failing fast in the factory surfaces a
370
+ clearer error than the AAD round-trip would.
371
+ """
372
+ # pylint: disable=import-outside-toplevel
373
+ from urllib.parse import urlparse
374
+
375
+ try:
376
+ parsed = urlparse(sts_url)
377
+ except (ValueError, AttributeError):
378
+ return None
379
+ # Reject anything that isn't an https URL with a netloc. ``urlparse`` will
380
+ # happily put a bare string like ``"tenant-guid"`` into ``path``, which
381
+ # would then look like a valid tenant. Azure AD STS URLs are always https.
382
+ if parsed.scheme != "https" or not parsed.netloc:
383
+ return None
384
+ path = (parsed.path or "").strip("/")
385
+ if not path:
386
+ return None
387
+ first_segment = path.split("/", 1)[0]
388
+ if not first_segment:
389
+ return None
390
+ if first_segment.lower() in _RESERVED_TENANTS:
391
+ return None
392
+ return first_segment
393
+
394
+
395
+ class ServicePrincipalAuth:
396
+ """Builds an ``entra_id_token_factory`` callable for ActiveDirectoryServicePrincipal.
397
+
398
+ The bulkcopy path through mssql-py-core uses callback-based token
399
+ acquisition (FedAuth workflow ``0x02``) because tenant_id is only known
400
+ from the STS URL that the server returns during the TDS handshake.
401
+ """
402
+
403
+ @staticmethod
404
+ def make_token_factory(client_id: str, client_secret: str):
405
+ """Return a callable suitable for ``entra_id_token_factory``.
406
+
407
+ Signature: ``(spn: str, sts_url: str, auth_method: str) -> bytes``.
408
+ Returns the JWT encoded as UTF-16LE bytes (the TDS FedAuth wire format).
409
+
410
+ ``ClientSecretCredential`` instances are reused across calls via the
411
+ module-level ``_credential_cache``, keyed by
412
+ ``("serviceprincipal", tenant_id, client_id)`` so that azure-identity's
413
+ in-memory token cache (which is per-credential-instance) actually
414
+ works across handshake retries, reconnects, and separate bulkcopy
415
+ invocations using the same identity.
416
+ """
417
+ if not client_id:
418
+ raise ValueError("ServicePrincipal auth requires a non-empty client_id (UID)")
419
+ if not client_secret:
420
+ raise ValueError("ServicePrincipal auth requires a non-empty client_secret (PWD)")
421
+
422
+ # Hash once at factory-creation time; client_secret is fixed for the
423
+ # lifetime of the closure so there is no need to recompute per call.
424
+ secret_hash = hashlib.sha256(client_secret.encode("utf-8")).hexdigest()
425
+
426
+ def _factory(spn: str, sts_url: str, _auth_method: str) -> bytes:
427
+ # pylint: disable=import-outside-toplevel
428
+ try:
429
+ from azure.identity import ClientSecretCredential
430
+ from azure.core.exceptions import ClientAuthenticationError
431
+ from azure.core.pipeline.transport import RequestsTransport
432
+ except ImportError as e:
433
+ raise RuntimeError(
434
+ "Azure authentication libraries are not installed. "
435
+ "Please install with: pip install azure-identity azure-core"
436
+ ) from e
437
+
438
+ if not spn:
439
+ raise RuntimeError(
440
+ "ServicePrincipal token factory: empty SPN from server "
441
+ "(cannot construct token scope)"
442
+ )
443
+ tenant_id = _parse_tenant_id(sts_url)
444
+ if not tenant_id:
445
+ raise RuntimeError(f"Could not extract tenant_id from STS URL: {sts_url!r}")
446
+
447
+ try:
448
+ # Reuse the shared credential cache (introduced for MSI in PR #573)
449
+ # so SP credentials get the same per-instance token reuse semantics
450
+ # as the other AD methods. secret_hash is computed once in the
451
+ # outer scope (make_token_factory) so rotation of client_secret
452
+ # produces a distinct cache key without rehashing per call.
453
+ cache_key = _credential_cache_key(
454
+ _AuthInternal.SERVICE_PRINCIPAL,
455
+ {
456
+ "tenant_id": tenant_id,
457
+ "client_id": client_id,
458
+ "secret_hash": secret_hash,
459
+ },
460
+ )
461
+ with _credential_cache_lock:
462
+ credential = _credential_cache.get(cache_key)
463
+ if credential is None:
464
+ # Evict any stale entry for the same identity but a
465
+ # different secret_hash (secret was rotated). Prevents
466
+ # unbounded growth and removes the old secret from
467
+ # process memory sooner.
468
+ stale = [
469
+ k
470
+ for k in _credential_cache
471
+ if isinstance(k, tuple)
472
+ and len(k) == 2
473
+ and k[0] == _AuthInternal.SERVICE_PRINCIPAL
474
+ and dict(k[1]).get("tenant_id") == tenant_id
475
+ and dict(k[1]).get("client_id") == client_id
476
+ and dict(k[1]).get("secret_hash") != secret_hash
477
+ ]
478
+ for k in stale:
479
+ del _credential_cache[k]
480
+ # Bound the AAD network round-trip. Without explicit
481
+ # timeouts, azure-identity's defaults can let an
482
+ # unreachable / slow STS endpoint block the calling
483
+ # thread for tens of seconds. The factory runs on a
484
+ # mssql-py-core blocking-pool worker (tokio
485
+ # spawn_blocking), so a stuck callback ties that
486
+ # worker up for the duration. SP is non-interactive
487
+ # and token issuance is typically <1s; 10s/15s is
488
+ # generous and still bounded.
489
+ transport = RequestsTransport(
490
+ connection_timeout=10,
491
+ read_timeout=15,
492
+ )
493
+ # KNOWN LIMITATION: ``authority=`` is not passed,
494
+ # so this defaults to the public-cloud authority
495
+ # (login.microsoftonline.com). Sovereign clouds
496
+ # (Azure US Gov, Azure China) are not supported on
497
+ # this code path today: AAD will fail with
498
+ # "tenant not found" because the tenant lives in
499
+ # the sovereign cloud's AAD, not the public one.
500
+ # Tracked as a follow-up; the fix is to derive
501
+ # ``authority`` from ``urlparse(sts_url).netloc``.
502
+ # Out of scope for the initial #534 work.
503
+ credential = ClientSecretCredential(
504
+ tenant_id=tenant_id,
505
+ client_id=client_id,
506
+ client_secret=client_secret,
507
+ transport=transport,
508
+ )
509
+ _credential_cache[cache_key] = credential
510
+ # mssql-tds passes the resource SPN; azure-identity wants a scope.
511
+ scope = spn if spn.endswith("/.default") else spn.rstrip("/") + "/.default"
512
+ token = credential.get_token(scope).token
513
+ logger.info(
514
+ "ServicePrincipal token factory: token acquired, length=%d chars",
515
+ len(token),
516
+ )
517
+ # Return bare UTF-16LE JWT bytes. Do NOT length-prefix like
518
+ # AADAuth.get_token_struct does for the access_token path;
519
+ # py-core handles the FedAuth length-prefix wrapping itself.
520
+ return token.encode("utf-16-le")
521
+ except ClientAuthenticationError as e:
522
+ # Keep the detailed provider error in debug logs only. The
523
+ # surfaced message is intentionally generic so that any
524
+ # secret-bearing provider text never reaches the user-facing
525
+ # exception chain.
526
+ logger.error(
527
+ "ServicePrincipal authentication failed: tenant=%s, error=%s",
528
+ tenant_id,
529
+ str(e),
530
+ )
531
+ raise RuntimeError(
532
+ "ServicePrincipal authentication failed; " "see error logs for provider details"
533
+ ) from None
534
+
535
+ return _factory
536
+
537
+
538
+ def process_auth_parameters(parsed_params: Dict[str, str]) -> Optional[str]:
539
+ """
540
+ Extract authentication type from parsed connection parameters.
541
+
542
+ Returns the internal auth type string needed for token acquisition,
543
+ or None when the driver should handle authentication natively
544
+ (e.g. Windows Interactive).
545
+
546
+ Args:
547
+ parsed_params: Dictionary of normalized connection parameters
548
+
549
+ Returns:
550
+ Optional[str]: Authentication type string or None
551
+ """
552
+ auth_type = extract_auth_type(parsed_params)
553
+ if not auth_type:
554
+ return None
555
+
556
+ # On Windows, Interactive auth is handled natively by the ODBC driver.
557
+ if auth_type == _AuthInternal.INTERACTIVE and platform.system().lower() == "windows":
558
+ logger.debug("process_auth_parameters: Windows platform - using native AADInteractive")
559
+ return None
560
+
561
+ # ServicePrincipal: ODBC (msodbcsql 17.3+) handles this natively for
562
+ # regular queries, so return None to let ODBC own the query path. Bulkcopy
563
+ # still needs the auth type (Connection.__init__ falls back to
564
+ # extract_auth_type for that), and the cursor.bulkcopy() callback branch
565
+ # registers an entra_id_token_factory because tenant_id is only known
566
+ # from the STS URL the server returns during the FedAuth handshake.
567
+ if auth_type == _AuthInternal.SERVICE_PRINCIPAL:
568
+ logger.debug("process_auth_parameters: ServicePrincipal - ODBC handles natively")
569
+ return None
570
+
571
+ logger.debug("process_auth_parameters: auth_type=%s", auth_type)
572
+ return auth_type
573
+
574
+
575
+ def remove_sensitive_params(parsed_params: Dict[str, str]) -> Dict[str, str]:
576
+ """Return a copy of *parsed_params* without credentials / auth keys."""
577
+ return {k: v for k, v in parsed_params.items() if k not in _SENSITIVE_KEYS}
578
+
579
+
580
+ def get_auth_token_info(
581
+ auth_type: str, credential_kwargs: Optional[Dict[str, str]] = None
582
+ ) -> Optional[TokenInfo]:
583
+ """Acquire an access token and return both its ODBC struct and expiry.
584
+
585
+ Returns ``None`` only when there is no auth type or when the driver handles
586
+ authentication natively (Windows Interactive), so callers can treat a
587
+ ``None`` result as "no Python-acquired token to place in attrs_before".
588
+
589
+ For every other (token-backed) auth type this **fails closed**: an
590
+ acquisition error from :meth:`AADAuth.get_token_info` (``ValueError`` for an
591
+ unsupported auth type, ``RuntimeError`` for a credential/network/auth
592
+ failure) is allowed to propagate. Swallowing it and returning ``None`` would
593
+ let the caller fall through to an ODBC connect with ``Authentication=``
594
+ stripped and no token attached, which either surfaces a confusing login
595
+ error or — worse — authenticates as an unintended identity.
596
+ """
597
+ logger.debug("get_auth_token_info: Starting - auth_type=%s", auth_type)
598
+ if not auth_type:
599
+ return None
600
+
601
+ if auth_type == _AuthInternal.INTERACTIVE and platform.system().lower() == "windows":
602
+ logger.debug("get_auth_token_info: Windows interactive auth - delegating to native handler")
603
+ return None
604
+
605
+ info = AADAuth.get_token_info(auth_type, credential_kwargs)
606
+ logger.info("get_auth_token_info: Token acquired successfully - auth_type=%s", auth_type)
607
+ return info
608
+
609
+
610
+ def compute_token_identity(token_struct: Optional[bytes]) -> Optional[str]:
611
+ """Return the ``"tok:<sha256>"`` pool identity for a raw token struct.
612
+
613
+ The raw-token / DefaultAzureCredential / custom ``token_provider=`` paths
614
+ hold a token but have no param-derivable identity, so their pool key is
615
+ bound to the token itself: distinct tokens hash to distinct pools and the
616
+ same token reuses one pool. This is correct but not expiry-aware — a rotated
617
+ token opens a new bucket that the native idle sweep later evicts.
618
+
619
+ Returns ``None`` when *token_struct* is empty/absent, signalling the caller
620
+ to acquire a token first and recompute.
621
+ """
622
+ if not token_struct:
623
+ return None
624
+ return "tok:" + hashlib.sha256(token_struct).hexdigest()
625
+
626
+
627
+ def compute_identity_key(
628
+ auth_type: Optional[str],
629
+ credential_kwargs: Optional[Dict[str, str]] = None,
630
+ token_struct: Optional[bytes] = None,
631
+ home_account_id: Optional[str] = None,
632
+ ) -> Optional[str]:
633
+ """Build the per-identity discriminator appended to the native pool key.
634
+
635
+ This is the heart of identity-aware pooling: today the pool
636
+ keys only on the sanitized connection string, so two callers using
637
+ different Entra identities but the same server collide onto one pool. The
638
+ discriminator returned here is combined with the connection string to keep
639
+ distinct identities in distinct pools.
640
+
641
+ Design invariant: when a token is present the pool key must NEVER be the
642
+ bare connection string; the fail-safe is the token hash.
643
+
644
+ Return values:
645
+ * ``None`` — no token auth (SQL / trusted / native ServicePrincipal /
646
+ Windows Interactive). The connection string alone already isolates the
647
+ identity, so the pool key is unchanged (fully backward compatible).
648
+ * ``"msi:client:<client_id>"`` / ``"msi:system"`` — Managed Identity,
649
+ derived from connection params *without* acquiring a token (enables
650
+ skipping token acquisition on a pool hit). System- and user-assigned
651
+ identities use disjoint prefixes so they can never collide.
652
+ * ``"acct:<home_account_id>"`` — Interactive / Device-code, keyed on the
653
+ stable account id from ``authenticate()`` so a silent token refresh
654
+ reuses the pool but a different signed-in account gets its own pool.
655
+ * ``"tok:<sha256>"`` — fail-safe hash of the token struct (via
656
+ :func:`compute_token_identity`) for auth types whose identity is not
657
+ derivable from params (DefaultAzureCredential, raw token, and
658
+ interactive / device-code when ``home_account_id`` is unavailable).
659
+ Requires ``token_struct``; when it is not supplied the function returns
660
+ ``None`` to signal "acquire a token, then recompute".
661
+ """
662
+ if not auth_type:
663
+ return None
664
+
665
+ if auth_type == _AuthInternal.MSI:
666
+ client_id = (credential_kwargs or {}).get("client_id")
667
+ # Normalize so the same identity maps to one pool regardless of GUID
668
+ # casing or optional surrounding {braces} in the connection string
669
+ # (e.g. "{ABCD...}" and "abcd..." are the same managed identity).
670
+ if client_id:
671
+ client_id = client_id.strip().strip("{}").lower()
672
+ # System- vs user-assigned MSI must never share a pool bucket. A
673
+ # user-assigned identity whose client_id is the literal "system" would
674
+ # collide with system-assigned MSI under a bare ``msi:<client_id>``
675
+ # scheme (and once UID is stripped the connection strings match too), so
676
+ # user-assigned identities are namespaced under the disjoint
677
+ # ``msi:client:`` prefix.
678
+ return f"msi:client:{client_id}" if client_id else "msi:system"
679
+
680
+ # Interactive / Device-code: prefer the stable account id when available so
681
+ # silent refreshes reuse the pool; fall back to the token hash otherwise.
682
+ if auth_type in (_AuthInternal.INTERACTIVE, _AuthInternal.DEVICE_CODE) and home_account_id:
683
+ return f"acct:{home_account_id}"
684
+
685
+ if token_struct is not None:
686
+ return compute_token_identity(token_struct)
687
+
688
+ # A token-backed auth type whose key needs the token, but none was
689
+ # supplied yet. Signal the caller to acquire first, then recompute.
690
+ return None
691
+
692
+
693
+ def extract_auth_type(parsed_params: Dict[str, str]) -> Optional[str]:
694
+ """Map the Authentication connection-string value to an internal type name.
695
+
696
+ Returns ``"interactive"``, ``"devicecode"``, ``"default"``, ``"msi"``,
697
+ or *None* for unrecognised / absent values. This is a pure mapping with
698
+ no platform checks — use :func:`process_auth_parameters` when you need
699
+ the Windows-Interactive suppression logic.
700
+ """
701
+ auth_value = parsed_params.get(_KEY_AUTHENTICATION, "").strip().lower()
702
+ return _AUTH_TYPE_MAP.get(auth_value)
703
+
704
+
705
+ def _user_facing_stacklevel() -> int:
706
+ """Return a :func:`warnings.warn` ``stacklevel`` that points at the first
707
+ caller outside the ``mssql_python`` package.
708
+
709
+ This module's warnings are emitted at different call depths — the public
710
+ ``connect()`` path is several frames deeper than ``Cursor.bulkcopy`` — so no
711
+ fixed ``stacklevel`` lands on user code for both. Walk outward from the
712
+ ``warnings.warn`` call site and return the level of the first frame whose
713
+ module is outside ``mssql_python`` so the warning always points at the
714
+ caller's own code.
715
+ """
716
+ # sys._getframe(1) is this helper's caller — the warnings.warn call site,
717
+ # which corresponds to stacklevel=1.
718
+ frame = sys._getframe(1)
719
+ level = 1
720
+ while frame is not None:
721
+ if not frame.f_globals.get("__name__", "").startswith("mssql_python"):
722
+ return level
723
+ frame = frame.f_back
724
+ level += 1
725
+ # Every frame was internal (not expected in practice); fall back to the
726
+ # outermost real frame rather than blaming this helper.
727
+ return max(level - 1, 1)
728
+
729
+
730
+ def _get_token_from_credential(
731
+ credential: "TokenCredential",
732
+ ) -> Tuple[str, Optional[int]]:
733
+ """Internal: call credential.get_token() and return ``(raw_jwt, expires_on)``.
734
+
735
+ Centralises the token-acquisition + error-wrapping logic that both
736
+ :func:`acquire_token_from_credential` and
737
+ :func:`acquire_raw_token_from_credential` need.
738
+
739
+ ``expires_on`` is the POSIX timestamp (seconds) at which the token
740
+ expires, taken from the credential's ``AccessToken`` result when present
741
+ (it is ``None`` if the provider does not supply one). It is captured so
742
+ callers can log it and reason about token lifetime; the access token
743
+ itself is a *pre-connect* ODBC attribute and cannot be refreshed on a
744
+ live connection (see the module docs on token lifecycle).
745
+
746
+ The already-expired-token warning is attributed to the first non-internal
747
+ caller via :func:`_user_facing_stacklevel`, so it points at user code
748
+ regardless of how deep this helper is called.
749
+
750
+ Note:
751
+ The scope is hard-coded to the Azure **commercial** cloud
752
+ (``https://database.windows.net/.default``). Sovereign clouds
753
+ (Azure US Government, Azure China, Azure Germany) are **out of
754
+ scope** for the ``token_provider`` path — for those, supply a
755
+ pre-acquired token via ``attrs_before[SQL_COPT_SS_ACCESS_TOKEN]``.
756
+
757
+ Raises:
758
+ InterfaceError: If the provider returns no valid ``.token`` string.
759
+ OperationalError: If the underlying ``get_token()`` call fails.
760
+ """
761
+ start_time = time.perf_counter()
762
+ try:
763
+ token_result = credential.get_token(_SQL_SCOPE)
764
+ except TypeError as e:
765
+ # A TypeError from get_token(scope) can mean two very different things,
766
+ # and only one is about the scope argument:
767
+ # 1. the signature can't accept the positional scope -- a *binding*
768
+ # error raised before the function body runs (e.g. a zero-arg or
769
+ # keyword-only get_token); or
770
+ # 2. the user's get_token body itself raised TypeError (e.g. "a" + 1).
771
+ # A binding error's traceback stops at this frame (tb_next is None); a
772
+ # TypeError raised inside get_token has at least one deeper frame. Only
773
+ # blame the scope for case 1 -- for case 2, keep the original message
774
+ # and fall through to the generic OperationalError so the user sees
775
+ # their real bug instead of being sent to fix the scope argument.
776
+ if e.__traceback__ is not None and e.__traceback__.tb_next is None:
777
+ raise InterfaceError(
778
+ driver_error=(
779
+ "token_provider.get_token() must accept a scope positional "
780
+ "argument, e.g. get_token(scope)."
781
+ ),
782
+ ddbc_error=str(e),
783
+ ) from e
784
+ logger.error(
785
+ "_get_token_from_credential: get_token() failed - credential=%s, error=%s",
786
+ type(credential).__name__,
787
+ str(e),
788
+ )
789
+ raise OperationalError(
790
+ driver_error=(f"Failed to acquire token from credential ({type(credential).__name__})"),
791
+ ddbc_error=str(e),
792
+ ) from e
793
+ except Exception as e:
794
+ logger.error(
795
+ "_get_token_from_credential: get_token() failed - credential=%s, error=%s",
796
+ type(credential).__name__,
797
+ str(e),
798
+ )
799
+ # Preserve the original credential exception (e.g. azure-identity
800
+ # ClientAuthenticationError) as __cause__ for programmatic handling.
801
+ raise OperationalError(
802
+ driver_error=(f"Failed to acquire token from credential ({type(credential).__name__})"),
803
+ ddbc_error=str(e),
804
+ ) from e
805
+
806
+ # azure.identity.aio (async) credentials return a coroutine from a
807
+ # synchronous get_token() call. Detect it and fail with an async-specific
808
+ # message rather than tripping over a missing .token attribute — and close
809
+ # the coroutine so it doesn't emit a "coroutine was never awaited" warning.
810
+ if inspect.iscoroutine(token_result):
811
+ token_result.close()
812
+ raise InterfaceError(
813
+ driver_error=(
814
+ "token_provider.get_token() returned a coroutine, which indicates "
815
+ "an async credential (e.g. from azure.identity.aio). Use a "
816
+ "synchronous credential instead."
817
+ ),
818
+ ddbc_error=f"got coroutine from {type(credential).__name__}.get_token()",
819
+ )
820
+
821
+ raw_token = getattr(token_result, "token", None)
822
+ if not isinstance(raw_token, str) or not raw_token:
823
+ raise InterfaceError(
824
+ driver_error=(
825
+ "token_provider.get_token() must return an object with a non-empty "
826
+ "string '.token' attribute."
827
+ ),
828
+ ddbc_error=f"got .token of type {type(raw_token).__name__}",
829
+ )
830
+
831
+ expires_on = getattr(token_result, "expires_on", None)
832
+ # Log (don't fail) if the credential handed back an already-expired token:
833
+ # the server enforces expiry and will reject the login, so surfacing it here
834
+ # points at the real cause instead of an opaque later failure. A log record
835
+ # (rather than warnings.warn) is used so this diagnostic is never promoted to
836
+ # an exception under ``-W error``. Only numeric POSIX timestamps are checked;
837
+ # bools are excluded to avoid false positives.
838
+ if (
839
+ isinstance(expires_on, (int, float))
840
+ and not isinstance(expires_on, bool)
841
+ and expires_on < time.time()
842
+ ):
843
+ logger.warning(
844
+ "token_provider returned a token that is already expired "
845
+ "(expires_on=%s is in the past). The server will likely "
846
+ "reject the connection.",
847
+ expires_on,
848
+ )
849
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
850
+ logger.info(
851
+ "_get_token_from_credential: Token acquired from %s - length=%d chars, "
852
+ "expires_on=%s, duration_ms=%.2f",
853
+ type(credential).__name__,
854
+ len(raw_token),
855
+ expires_on,
856
+ elapsed_ms,
857
+ )
858
+ return raw_token, expires_on
859
+
860
+
861
+ def acquire_token_from_credential(credential: "TokenCredential") -> Tuple[bytes, Optional[int]]:
862
+ """Acquire an ODBC token struct from a user-supplied credential object.
863
+
864
+ The credential must follow the Azure ``TokenCredential`` protocol — i.e.
865
+ have a ``.get_token(scope)`` method returning an object with a ``.token``
866
+ attribute (a raw JWT string).
867
+
868
+ .. note::
869
+ The scope is fixed to the Azure **commercial** cloud
870
+ (``https://database.windows.net/.default``). Sovereign clouds (Azure
871
+ US Government, Azure China, Azure Germany) are **out of scope** — for
872
+ those, supply a pre-acquired token via
873
+ ``attrs_before[SQL_COPT_SS_ACCESS_TOKEN]`` instead.
874
+
875
+ Args:
876
+ credential: Any object with a ``.get_token(scope)`` method.
877
+
878
+ Returns:
879
+ Tuple[bytes, Optional[int]]: The ODBC token struct for
880
+ ``SQL_COPT_SS_ACCESS_TOKEN`` and the token's ``expires_on`` POSIX
881
+ timestamp (``None`` if the provider does not supply one).
882
+
883
+ Raises:
884
+ InterfaceError: If the provider returns no valid ``.token`` string.
885
+ OperationalError: If the underlying ``get_token()`` call fails.
886
+ """
887
+ raw_token, expires_on = _get_token_from_credential(credential)
888
+ return AADAuth.get_token_struct(raw_token), expires_on
889
+
890
+
891
+ def acquire_raw_token_from_credential(credential: "TokenCredential") -> Tuple[str, Optional[int]]:
892
+ """Acquire a raw JWT string from a user-supplied credential object.
893
+
894
+ Used by bulk copy, which needs the raw JWT rather than the ODBC struct.
895
+
896
+ .. note::
897
+ The scope is fixed to the Azure **commercial** cloud. Sovereign
898
+ clouds are **out of scope** — see
899
+ :func:`acquire_token_from_credential`.
900
+
901
+ Args:
902
+ credential: Any object with a ``.get_token(scope)`` method.
903
+
904
+ Returns:
905
+ Tuple[str, Optional[int]]: The raw JWT token string and the token's
906
+ ``expires_on`` POSIX timestamp (``None`` if the provider does not
907
+ supply one).
908
+
909
+ Raises:
910
+ InterfaceError: If the provider returns no valid ``.token`` string.
911
+ OperationalError: If the underlying ``get_token()`` call fails.
912
+ """
913
+ return _get_token_from_credential(credential)