mssql-python 1.16.0__cp315-cp315-win_arm64.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/__init__.py +662 -0
- mssql_python/async_query/__init__.py +40 -0
- mssql_python/async_query/_connection_context.py +39 -0
- mssql_python/async_query/_native.py +23 -0
- mssql_python/async_query/async_connection.py +160 -0
- mssql_python/async_query/async_cursor.py +230 -0
- mssql_python/async_query/async_execute.py +115 -0
- mssql_python/async_query/async_fetch.py +134 -0
- mssql_python/async_query/exception_translator.py +114 -0
- mssql_python/auth.py +913 -0
- mssql_python/connection.py +2357 -0
- mssql_python/connection_string_builder.py +114 -0
- mssql_python/connection_string_parser.py +424 -0
- mssql_python/constants.py +701 -0
- mssql_python/cursor.py +4188 -0
- mssql_python/db_connection.py +98 -0
- mssql_python/ddbc_bindings.cp315-arm64.pyd +0 -0
- mssql_python/ddbc_bindings.py +215 -0
- mssql_python/decimal_config.py +114 -0
- mssql_python/exceptions.py +588 -0
- mssql_python/helpers.py +416 -0
- mssql_python/logging.py +714 -0
- mssql_python/mssql_python.pyi +481 -0
- mssql_python/msvcp140.dll +0 -0
- mssql_python/odbc_provider.py +251 -0
- mssql_python/parameter_helper.py +488 -0
- mssql_python/perf_timer.py +286 -0
- mssql_python/pooling.py +151 -0
- mssql_python/py.typed +1 -0
- mssql_python/row.py +398 -0
- mssql_python/type.py +160 -0
- mssql_python-1.16.0.dist-info/METADATA +102 -0
- mssql_python-1.16.0.dist-info/RECORD +36 -0
- mssql_python-1.16.0.dist-info/WHEEL +5 -0
- mssql_python-1.16.0.dist-info/licenses/LICENSE +47 -0
- mssql_python-1.16.0.dist-info/top_level.txt +1 -0
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)
|