Repository navigation
FEAT: Entra id authentication support for AQE #870
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
aced722
cd4301f
e54575b
b8696f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| """Entra authentication for individual py-core async connections. | ||
|
|
||
| Only credential instances are cached, never connections or raw tokens. Interactive | ||
| credentials consequently share a signed-in account within this process; multi-user | ||
| applications should supply their own per-user token providers. | ||
| """ | ||
|
|
||
| import asyncio | ||
| import threading | ||
| import warnings | ||
| from typing import TYPE_CHECKING, Any, Optional | ||
|
|
||
| from ..auth import ( | ||
| ServicePrincipalAuth, | ||
| acquire_raw_token_from_credential, | ||
| extract_auth_type, | ||
| ) | ||
| from ..constants import _AuthInternal, _KEY_AUTHENTICATION | ||
| from ..exceptions import InterfaceError, OperationalError | ||
| from ..logging import logger | ||
|
|
||
| if TYPE_CHECKING: | ||
| from azure.core.credentials import TokenCredential | ||
|
|
||
| from ..connection import TokenProvider | ||
|
|
||
|
|
||
| _credential_cache: dict[tuple[str, Optional[str]], "TokenCredential"] = {} | ||
| _credential_cache_lock = threading.Lock() | ||
|
|
||
|
|
||
| def _get_credential(auth_type: str, client_id: Optional[str]) -> "TokenCredential": | ||
| try: | ||
| from azure.identity import ( | ||
| DefaultAzureCredential, | ||
| DeviceCodeCredential, | ||
| InteractiveBrowserCredential, | ||
| ManagedIdentityCredential, | ||
| ) | ||
| except ImportError as error: | ||
| raise OperationalError( | ||
| "Azure authentication libraries are not installed. Install azure-identity.", | ||
| str(error), | ||
| ) from error | ||
|
|
||
| credential_classes = { | ||
| _AuthInternal.DEFAULT: DefaultAzureCredential, | ||
| _AuthInternal.DEVICE_CODE: DeviceCodeCredential, | ||
| _AuthInternal.INTERACTIVE: InteractiveBrowserCredential, | ||
| _AuthInternal.MSI: ManagedIdentityCredential, | ||
| } | ||
| credential_class = credential_classes[auth_type] | ||
| key = (auth_type, client_id) | ||
| with _credential_cache_lock: | ||
| credential = _credential_cache.get(key) | ||
| if credential is None: | ||
| try: | ||
| # Azure Identity's default interactive behavior is silent-first, | ||
| # prompting only when its cached account cannot supply a token. | ||
| credential = ( | ||
| credential_class(client_id=client_id) if client_id else credential_class() | ||
| ) | ||
| except Exception as error: | ||
| logger.error( | ||
| "Async authentication: credential initialization failed for %s", | ||
| credential_class.__name__, | ||
| ) | ||
| raise OperationalError( | ||
| f"Failed to create {credential_class.__name__}", | ||
| str(error), | ||
| ) from error | ||
| _credential_cache[key] = credential | ||
| return credential | ||
|
|
||
|
|
||
| def _acquire_builtin_token(auth_type: str, client_id: Optional[str]) -> str: | ||
| credential = _get_credential(auth_type, client_id) | ||
| token, _ = acquire_raw_token_from_credential(credential) | ||
| return token | ||
|
|
||
|
|
||
| async def prepare_async_authentication( | ||
| context: dict[str, Any], | ||
| token_provider: Optional["TokenProvider"] = None, | ||
| ) -> dict[str, Any]: | ||
| """Return a TDS login context without blocking the Python event loop. | ||
|
|
||
| ``token_provider`` accepts synchronous credentials only. Cancellation stops | ||
| connection setup but cannot interrupt an already-running credential call in | ||
| a worker thread. Service Principal callbacks are offloaded by py-core itself. | ||
| """ | ||
| authenticated = dict(context) | ||
| auth_type = extract_auth_type({_KEY_AUTHENTICATION: context.get("authentication", "")}) | ||
|
|
||
| if token_provider is not None: | ||
| if "authentication" in context: | ||
| raise InterfaceError( | ||
| "Cannot specify both 'token_provider' and 'Authentication'. Use one token source.", | ||
| "", | ||
| ) | ||
| if not callable(getattr(token_provider, "get_token", None)): | ||
| raise InterfaceError( | ||
| "token_provider must have a callable .get_token(scope) method.", "" | ||
| ) | ||
| dropped = [ | ||
| name for name in ("user_name", "password", "trusted_connection") if name in context | ||
| ] | ||
| if dropped: | ||
| warnings.warn( | ||
| "token_provider is set, so the following connection credentials " | ||
| f"are ignored: {', '.join(dropped)}.", | ||
| UserWarning, | ||
| stacklevel=3, | ||
| ) | ||
| raw_token, _ = await asyncio.to_thread(acquire_raw_token_from_credential, token_provider) | ||
| elif auth_type == _AuthInternal.SERVICE_PRINCIPAL: | ||
| try: | ||
| authenticated["entra_id_token_factory"] = ServicePrincipalAuth.make_token_factory( | ||
| context.get("user_name", ""), context.get("password", "") | ||
| ) | ||
| except ValueError as error: | ||
| raise InterfaceError( | ||
| "ActiveDirectoryServicePrincipal requires UID (client_id) and PWD (client_secret).", | ||
| str(error), | ||
| ) from error | ||
| # py-core resolves the callback's authentication method from these | ||
| # fields, then clears the credentials from its native ClientContext. | ||
| return authenticated | ||
| elif auth_type: | ||
| client_id = None | ||
| if auth_type == _AuthInternal.MSI: | ||
| client_id = (context.get("user_name") or "").strip() or None | ||
| raw_token = await asyncio.to_thread(_acquire_builtin_token, auth_type, client_id) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. §4 of the AQE design explicitly requires Please use async acquisition for modes with supported SDK implementations and accept async token providers, preserving token validation and DB-API error mapping. Give driver-owned async credentials an explicit event-loop-compatible lifetime and await their cleanup; leave caller-owned providers caller-owned. Any necessary synchronous compatibility paths should be separate and explicitly documented rather than making every mode thread-backed. |
||
| else: | ||
| return authenticated | ||
|
|
||
| for key in ("authentication", "user_name", "password", "trusted_connection"): | ||
| authenticated.pop(key, None) | ||
| authenticated["access_token"] = raw_token | ||
| return authenticated | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,9 +6,10 @@ | |
| may change without notice. | ||
| """ | ||
|
|
||
| from typing import Any, Optional | ||
| from typing import TYPE_CHECKING, Any, Optional | ||
|
|
||
| from ..logging import logger | ||
| from ._auth import prepare_async_authentication | ||
| from ._native import load_py_core | ||
| from ._connection_context import build_async_connection_context | ||
| from .async_cursor import _AsyncCursor # pyright: ignore[reportPrivateUsage] | ||
|
|
@@ -26,6 +27,9 @@ | |
| translate_py_core_exceptions, | ||
| ) | ||
|
|
||
| if TYPE_CHECKING: | ||
| from ..connection import TokenProvider | ||
|
|
||
|
|
||
| class _AsyncConnection: | ||
| """Thin Python wrapper over ``mssql_py_core.PyAsyncConnection``. | ||
|
|
@@ -56,8 +60,34 @@ async def connect( | |
| autocommit: bool = False, | ||
| timeout: int = 0, | ||
| python_logger: Optional[Any] = None, | ||
| *, | ||
| token_provider: Optional["TokenProvider"] = None, | ||
| ) -> "_AsyncConnection": | ||
| """Establish an asynchronous connection from an ODBC connection string.""" | ||
| """Establish a TDS connection using connection-string options. | ||
|
|
||
| Entra modes: ActiveDirectoryDefault, ActiveDirectoryMsi (optional UID | ||
| selects a user-assigned identity), ActiveDirectoryDeviceCode, | ||
| ActiveDirectoryInteractive, and client-secret ActiveDirectoryServicePrincipal. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please document a per-mode async capability matrix before describing this feature as end-to-end async. Azure Identity's public async API supports Default, Managed Identity and client-secret authentication, but does not expose The matrix should also cover Default credential-chain behavior on supported event loops: the SDK's CLI credential has a synchronous fallback on Windows Selector loops. Please ensure the selected chain remains nonblocking, or explicitly approve and document that limitation. Finally, declare the async HTTP transport requirement and credential ownership/cleanup policy. Azure Identity does not install an async transport automatically. Confirm how that prerequisite fits the design's “no new PyPI dep” requirement rather than relying on an incidental development-environment installation. |
||
| Interactive authentication uses Python credentials on every platform. | ||
|
|
||
| Alternatively, supply a synchronous credential as ``token_provider``. | ||
| It must expose ``get_token(scope)`` returning an object with a non-empty | ||
| string ``token`` attribute; it cannot be combined with Authentication. | ||
| Async credentials (azure.identity.aio) are not accepted. | ||
|
|
||
| Token acquisition runs off the event loop. Each connect opens a new | ||
| physical connection; there is no connection pool, per-query refresh, | ||
| automatic query replay, or background refresh. Built-in credentials are | ||
| cached and may serve cached tokens. Interactive credentials share one | ||
| account per process; use per-user providers for multi-user applications. | ||
|
|
||
| The pre-acquired-token scope targets Azure commercial SQL. Service | ||
| Principal also currently uses the public-cloud authority. Sovereign | ||
| clouds and CAE claims challenges are not supported by these helpers. | ||
| ``timeout`` bounds native login, not preceding token acquisition. | ||
| Cancelling acquisition cannot stop a credential call already running in | ||
| a worker thread, but prevents subsequent native connection creation. | ||
| """ | ||
| logger_bridge = python_logger | ||
| if logger_bridge is None and logger.is_debug_enabled: | ||
| logger_bridge = logger | ||
|
|
@@ -73,6 +103,9 @@ async def connect( | |
| with translate_py_core_exceptions(): | ||
| client_context_dict = build_async_connection_context(connection_str, timeout) | ||
| py_core = load_py_core() | ||
| client_context_dict = await prepare_async_authentication( | ||
| client_context_dict, token_provider | ||
| ) | ||
| py_core_async_connection = await py_core.PyAsyncConnection.connect( | ||
| client_context_dict, | ||
| python_logger=logger_bridge, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This wires the synchronous bulk-copy token factory into the new async ServicePrincipal path. That factory calls synchronous
ClientSecretCredential.get_token(), although Azure Identity providesaio.ClientSecretCredential. Offloading the factory prevents event-loop blocking, but still occupies a worker throughout token acquisition.Please make this path await async acquisition after the server supplies the tenant/SPN, coordinating any required native callback-contract and dependency-pin changes. The inspected native bridge expects immediate bytes, so simply changing the Python factory to
async defwould not work. Preserve the existing synchronous bulk-copy behavior. If an awaitable callback cannot be supported in this slice, explicitly gate and document ServicePrincipal as an approved blocking exception rather than claiming end-to-end async compliance.The native bridge was inspected at upstream commit
bf415d10; this does not establish the exact repository-pinned wheel's implementation. The Python factory's synchronous token acquisition is directly verifiable here.