Skip to content
Open
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
391 changes: 391 additions & 0 deletions docs/Guidance-for-Higher-Level-SDKs-to-Consume-MSAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,391 @@
# Guidance for Higher-Level SDKs to Consume MSAL mTLS PoP

> **Prerequisites:** This document describes the API surface introduced by
> [PR #931](https://github.com/AzureAD/microsoft-authentication-library-for-python/pull/931).
> The types and parameters referenced below (`WindowsCertificate`, `MsiV2Error`,
> `mtls_proof_of_possession`, `with_attestation_support`, and the extended return
> value contract) are available once that PR is merged.
>
> This file is standalone design guidance for SDK authors and is not part of the
> Sphinx-rendered documentation site (`source_suffix = '.rst'`).

## Purpose

This document explains how higher-level SDKs (e.g., `azure-identity`, `azure-sdk-for-python`)
can consume MSAL Python's MSI v2 mTLS Proof-of-Possession API to provide seamless mTLS
token-bound authentication to end users.
Comment thread
gladjohn marked this conversation as resolved.

## What MSAL Python Exposes

### Public API Surface

```python
import msal
import requests

# 1. Create ManagedIdentityClient (existing API)
client = msal.ManagedIdentityClient(
msal.SystemAssignedManagedIdentity(),
http_client=requests.Session(),
)

# 2. Acquire mTLS-bound token (new parameters)
result = client.acquire_token_for_client(
resource="https://vault.azure.net",
mtls_proof_of_possession=True, # Always routes to MSI v2 (hard fail if unsupported)
with_attestation_support=True, # KeyGuard tier (attested); False = Software tier
)
Comment thread
gladjohn marked this conversation as resolved.
```

### Return Value Contract

```python
{
"access_token": "eyJ0eXAi...", # mTLS-bound PoP token
"token_type": "mtls_pop", # Token type (use in auth header)
"expires_in": 86399, # Seconds until expiry
"binding_certificate": WindowsCertificate(...), # Opaque cert+key handle
"cert_thumbprint_sha256": "buc7x...", # Base64url SHA-256 thumbprint
"cert_pem": "-----BEGIN CERTIFICATE-----\n...", # Public cert (PEM)
"cert_der_b64": "MIIC...", # Public cert (Base64 DER)
}
Comment thread
gladjohn marked this conversation as resolved.
```

### Key Objects

#### `WindowsCertificate`

Python equivalent of .NET's `X509Certificate2`. Wraps a non-exportable private key
(NCRYPT_KEY_HANDLE) and the associated X.509 certificate.

```python
from msal import WindowsCertificate

cert: WindowsCertificate = result["binding_certificate"]

Comment thread
gladjohn marked this conversation as resolved.
# Properties
cert.thumbprint_sha256 # str: hex SHA-256 thumbprint (uppercase, 64 chars)
cert.x5t_s256 # str: base64url SHA-256 (matches cnf.x5t#S256 in JWT)
cert.public_certificate_pem # str: PEM-encoded public certificate
cert.public_certificate_der # bytes: DER-encoded public certificate
cert.has_private_key # bool: True if key handle is live
cert.key_name # str: CNG key name
cert.store_path # str: cert store path

# Methods
cert.create_cert_context() # -> PCCERT_CONTEXT (for WinHTTP/SChannel)
cert.close() # Free native handles (or use as context manager)
```

#### `MsiV2Error`

```python
from msal import MsiV2Error

try:
result = client.acquire_token_for_client(...)
except MsiV2Error as e:
# MSI v2 specific error (attestation failure, IMDS error, etc.)
# No silent fallback to v1 — matches MSAL .NET behavior.
pass
Comment thread
gladjohn marked this conversation as resolved.
```

---

## Integration Pattern for Azure SDK

### Architecture

```
┌──────────────────────────────────────────────────────────────┐
│ Your SDK (e.g., azure-identity) │
│ │
│ ManagedIdentityCredential │
│ ├── calls MSAL acquire_token_for_client(mtls_pop=True) │
│ ├── receives binding_certificate + access_token │
│ └── returns AccessToken + metadata to pipeline │
├──────────────────────────────────────────────────────────────┤
│ Transport Layer (e.g., azure-core) │
│ │
│ MtlsTransport (new, SChannel-based) │
│ ├── receives WindowsCertificate from credential │
│ ├── calls cert.create_cert_context() for TLS handshake │
│ └── presents cert during client-auth in mTLS │
├──────────────────────────────────────────────────────────────┤
│ Resource SDK (e.g., azure-keyvault-secrets) │
│ └── unaware of mTLS — just uses credential + transport │
└──────────────────────────────────────────────────────────────┘
```

### Step-by-Step Integration

#### Step 1: Token Acquisition (in your Credential class)

```python
# Inside azure-identity ManagedIdentityCredential
import msal

class ManagedIdentityCredential:
def __init__(self, *, mtls_pop: bool = False, **kwargs):
self._mtls_pop = mtls_pop
self._msal_client = msal.ManagedIdentityClient(
msal.SystemAssignedManagedIdentity(),
http_client=self._http_client,
)
self._binding_certificate = None

def get_token(self, *scopes, **kwargs) -> AccessToken:
if self._mtls_pop:
result = self._msal_client.acquire_token_for_client(
resource=scopes[0].removesuffix("/.default"),
mtls_proof_of_possession=True,
with_attestation_support=True,
)
# Store the binding certificate for the transport layer
self._binding_certificate = result["binding_certificate"]
# Store token_type for the auth policy (AccessToken has no token_type field)
self._token_type = result["token_type"] # "mtls_pop"

return AccessToken(
token=result["access_token"],
expires_on=int(time.time()) + result["expires_in"],
)
Comment thread
gladjohn marked this conversation as resolved.
else:
# Standard MSI v1 path
...

@property
def binding_certificate(self):
"""Transport layer reads this to configure mTLS."""
return self._binding_certificate
```

#### Step 2: Authorization Header Construction

The token type dictates the auth header format. Since `azure-core`'s `AccessToken`
does not carry a `token_type` field, read it from the credential:

```python
# In your BearerTokenPolicy or equivalent:
def on_request(self, request):
token = self._credential.get_token(self._scopes)

# Read token_type from credential (not from AccessToken which lacks it)
token_type = getattr(self._credential, "_token_type", "Bearer")
request.headers["Authorization"] = f"{token_type} {token.token}"

# For mTLS PoP to token-binding-aware services:
if token_type == "mtls_pop":
request.headers["x-ms-tokenboundauth"] = "true"
```

#### Step 3: mTLS Transport (presenting the certificate)

Standard Python `requests` **cannot** use non-exportable keys. You need a
SChannel/WinHTTP transport.

`SchannelSession` takes the certificate in its constructor (not per-request):

```python
# Using msal-schannel-transport
from msal_schannel_transport import SchannelSession

# Certificate is bound at session construction time
session = SchannelSession(client_certificate=credential.binding_certificate)

# Then make requests — certificate is presented automatically
response = session.get(url, headers=headers)
response = session.post(url, headers=headers, body=data)
```

For azure-core integration, build a custom `HttpTransport`:

```python
from azure.core.pipeline.transport import HttpTransport

class SchannelTransport(HttpTransport):

@kashifkhan Kashif Khan (kashifkhan) Aug 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This particular Schannel Transport will basically have to mirror the functionality provided by requests and aiohttp ( for async ) . You will have to create a full WinHTTP based HTTP Client making sure we get the following features out from it :

  • request/response serialization and a conformant  azure.core.rest.HttpResponse  (streaming/ iter_bytes / read /encoding) -> for folks who are using storage, cosmos, etc etc that have different needs from a http client
  • connection pooling / keep-alive (the sketch opens a fresh mTLS handshake per request)
  • redirects, proxies, timeouts, retries, TLS config, and error→exception mapping — all of which WinHTTP does differently than requests/aiohttp, so they'd have to be reconciled to keep behavior consistent across platforms
  • a separate async transport entirely, since  aiohttp  can't present the key and WinHTTP here is sync

A few thoughts on differences -

  • does the transport need to worry about differences between Windows versions for eg
  • does winhttp support reading certs in the same manner as requests/aiohttp ?
  • Will standard proxy env vars ( HTTP_PROXY / HTTPS_PROXY / NO_PROXY ) be honored
  • Diagnostics diverge too:  urllib3 / aiohttp  debug logging (and standard support steps) won't work on WinHTTP — its tracing lives in Windows ETW — and policy-level logs may not reflect WinHTTP's internal redirects/headers.

Just this itself greatly increases the effort and maintenance cost and the security concerns around this too

"""WinHTTP/SChannel transport for mTLS with non-exportable keys."""

def __init__(self, credential):
self._credential = credential

def send(self, request, **kwargs):
cert = self._credential.binding_certificate
if cert and cert.has_private_key:
# Create SchannelSession with the certificate
session = SchannelSession(client_certificate=cert)
response = session._request(
request.method, request.url,
headers=dict(request.headers), body=request.body)
return self._wrap_response(response)
else:
# Fall back to standard requests transport
...
```

#### Step 4: End-User Experience (the goal)

```python
from azure.identity import ManagedIdentityCredential
from azure.keyvault.secrets import SecretClient

# One line to enable mTLS PoP
credential = ManagedIdentityCredential(mtls_pop=True)

# Standard SDK usage — no mTLS awareness needed by the developer
client = SecretClient(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

credential and transport are separate in azure-core. So doesn't SecretClient still need updating to hand the credential to a WinHTTP transport and use it

vault_url="https://tokenbinding.vault.azure.net",
credential=credential,
)
secret = client.get_secret("boundsecret")
print(secret.value) # "secretme"
```

---

## Key Design Decisions

### 1. MSAL Only Acquires Tokens

MSAL never makes downstream API calls. It returns the `binding_certificate` object
and the access token. The higher-level SDK is responsible for:
- Constructing the authorization header
- Presenting the certificate during TLS handshake
- Managing the transport layer

### 2. `WindowsCertificate` is the Bridge

The `WindowsCertificate` object is the contract between MSAL (token acquisition)
and the transport layer (mTLS presentation). It:
- Holds the live NCRYPT_KEY_HANDLE (non-exportable private key)
- Creates thread-safe CERT_CONTEXT instances for concurrent requests
- Manages handle lifecycle (reference counting, cleanup)

### 3. No Silent Fallback

`mtls_proof_of_possession=True` always routes to MSI v2. If the host does not
support MSI v2 (e.g., not on a KeyGuard-enabled VM), MSAL raises `MsiV2Error`.
This matches MSAL .NET's behavior (`MtlsPopTokenNotSupportedinImdsV1`).

A developer who asks for mTLS PoP will never silently receive a Bearer token.

### 4. Transport is Pluggable

Higher-level SDKs can choose:
- **`msal-schannel-transport`** — ready-to-use WinHTTP session
- **Custom transport** — build your own using `cert.create_cert_context()`
- **Future: OpenSSL provider** — when available, standard `requests` will work

### 5. Certificate Lifecycle

```python
# WindowsCertificate is valid for the lifetime of the issued certificate
# (typically 8 hours for IMDS-issued certs). MSAL caches internally.

# Pattern: acquire once, reuse for multiple requests
result = client.acquire_token_for_client(...)
cert = result["binding_certificate"]

# Make many requests with the same cert
with SchannelSession(client_certificate=cert) as session:
for url in urls:
session.get(url, headers=headers)

# When done (optional — GC handles this too):
cert.close()
```

---

## Why Standard `requests` Doesn't Work

```python
# This FAILS with non-exportable keys:
requests.get(url, cert=("cert.pem", "key.pem"))
# ^^^^^^^^
# KeyGuard/TPM keys cannot be exported to a file!
```

Python's `ssl` module → OpenSSL → requires raw private key bytes.
KeyGuard/VBS keys are hardware-isolated and never leave the security boundary.

**Solution:** Use WinHTTP/SChannel which integrates natively with Windows
certificate stores and NCRYPT_KEY_HANDLEs.

---

## Comparison with .NET

| Concept | .NET | Python |
|---------|------|--------|
| Token acquisition | `.WithMtlsProofOfPossession()` | `mtls_proof_of_possession=True` |
| Attestation opt-in | `.WithAttestationSupport(provider)` | `with_attestation_support=True` |
| Certificate object | `X509Certificate2` | `WindowsCertificate` |
| Downstream mTLS | `HttpClientHandler.ClientCertificates` | `SchannelSession(client_certificate=cert)` |
| Key isolation | Automatic (SChannel) | Automatic (WinHTTP/SChannel) |
| Auth header | `$"{tokenType} {token}"` | `f"{result['token_type']} {result['access_token']}"` |
| Fallback behavior | Hard fail (`MtlsPopTokenNotSupportedinImdsV1`) | Hard fail (`MsiV2Error`) |
| Capability discovery | `GetManagedIdentityCapabilitiesAsync()` | Future |

---

## Minimum Integration Example

For SDK authors who want the fastest path to integration:

```python
"""Minimal integration — full mTLS PoP."""
import msal
from msal_schannel_transport import SchannelSession

# Acquire token
client = msal.ManagedIdentityClient(
msal.SystemAssignedManagedIdentity(),
http_client=__import__("requests").Session(),
)
result = client.acquire_token_for_client(
resource="https://vault.azure.net",
mtls_proof_of_possession=True,
with_attestation_support=True,
)

# Downstream call — cert goes in constructor
session = SchannelSession(client_certificate=result["binding_certificate"])
response = session.get(
"https://tokenbinding.vault.azure.net/secrets/boundsecret/?api-version=7.5",
headers={
"Authorization": f"{result['token_type']} {result['access_token']}",
"x-ms-tokenboundauth": "true",
},
)
print(response.status_code, response.text)
```

---

## Package Dependencies

| Package | Role | Required? |
|---------|------|-----------|
| `msal` | Token acquisition + `WindowsCertificate` | Yes |
| `msal-key-attestation` | MAA attestation (KeyGuard proof) | Yes for `with_attestation_support=True` |
| `msal-schannel-transport` | WinHTTP-based downstream mTLS | Yes (or build your own) |
| `requests` | HTTP client for MSAL's IMDS calls | Yes |

---

## Future: OpenSSL 3 CNG Provider (Strategic Path)

When a Microsoft-supported OpenSSL 3 CNG Provider becomes available, the

@kashifkhan Kashif Khan (kashifkhan) Aug 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I want to flag that this "strategic path," as written, isn't reachable today and likely not for a long while. I checked with the Python core devs on whether hardware-key mTLS would become reachable from the stdlib ssl  module /  SSLContext , and the answer was "not coming any time soon."

As y'all know, ssl.SSLContext has no way to reference a provider/PKCS#11 key, load_cert_chain  only accepts key material as files, and there's no signing-callback or provider-key hook. So even if a Microsoft-supported OpenSSL 3 CNG provider existed, requests/aiohttp  still couldn't use it, because both sit on stdlib ssl, which can't point at a provider-backed key by thumbprint.

The snippet in this section ( create_mtls_context(thumbprint=...)  → plain  requests.get(...) ) would require exactly the CPython feature that doesn't exist.

The proper channel for this is a new TLS module (the PEP 543 /  tlslib  effort), and its native-backend work (incl. SChannel) is currently stalled for lack of contributors https://discuss.python.org/t/pre-pep-discussion-revival-of-pep-543-a-unified-tls-api-for-python/51263

They are looking for people who can drive this however :)

transport layer simplifies to:

```python
from azure_identity_mtls import create_mtls_context
import requests

ctx = create_mtls_context(thumbprint=result["cert_thumbprint_sha256"])
response = requests.get(url, headers=headers) # standard requests, no WinHTTP needed
```

This is a future investment. The current WinHTTP approach is production-ready
and provides identical security guarantees.
Loading