Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions mssql_python/async_query/_auth.py
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(

Copy link
Copy Markdown
Contributor

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 provides aio.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 def would 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.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

§4 of the AQE design explicitly requires azure.identity.aio credentials and await credential.get_token(...). Azure Identity provides async DefaultAzureCredential and ManagedIdentityCredential, but this path runs their synchronous counterparts through asyncio's shared executor. The custom-provider branch also reuses the sync helper that rejects async credentials, so callers cannot supply the credential model required by the design.

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
11 changes: 9 additions & 2 deletions mssql_python/async_query/_connection_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

from typing import Any

from ..auth import extract_auth_type
from ..connection_string_parser import _ConnectionStringParser
from ..constants import _KEY_AUTHENTICATION
from ..exceptions import InterfaceError, NotSupportedError
from ..helpers import connstr_to_pycore_params

Expand All @@ -27,10 +29,15 @@ def build_async_connection_context(connection_str: str, timeout: int) -> dict[st
raise ValueError("SERVER parameter is required in connection string")

authentication = params.get("authentication", "").strip()
if authentication and authentication.lower() != "sqlpassword":
if (
authentication
and authentication.lower() != "sqlpassword"
and not extract_auth_type({_KEY_AUTHENTICATION: authentication})
):
raise NotSupportedError(
f"Authentication mode '{authentication}' is not supported for async queries",
"Use Authentication=SqlPassword or Trusted_Connection for async queries",
"Use SqlPassword, Trusted_Connection, a supported ActiveDirectory mode, "
"or a token_provider.",
)

context = connstr_to_pycore_params(params, strict=True)
Expand Down
37 changes: 35 additions & 2 deletions mssql_python/async_query/async_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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``.
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 aio.DeviceCodeCredential or aio.InteractiveBrowserCredential in the checked versions (1.12.0 and 1.24.0). Those interactive modes need an explicitly approved compatibility exception or a clear unsupported status; they should not prevent genuinely async implementation of the other modes.

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
Expand All @@ -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,
Expand Down
19 changes: 10 additions & 9 deletions tests/AsyncTest/test_002_async_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,16 @@ def test_bcp_conversion_does_not_fall_through_invalid_first_synonym():
assert context == {}


def test_async_connection_rejects_entra_authentication():
with pytest.raises(
NotSupportedError,
match="Authentication mode 'ActiveDirectoryDefault' is not supported",
):
build_async_connection_context(
"Server=test-server.example.invalid;Authentication=ActiveDirectoryDefault",
0,
)
def test_async_connection_context_accepts_entra_authentication_without_acquiring_token():
context = build_async_connection_context(
"Server=test-server.example.invalid;Authentication=ActiveDirectoryDefault",
0,
)

assert context == {
"server": "test-server.example.invalid",
"authentication": "ActiveDirectoryDefault",
}


def test_async_connection_reports_invalid_authentication_mode():
Expand Down
Loading
Loading