Skip to content

Commit 3a2cee1

Browse files
Kilo59JonasKsclaude
authored
Pass through HTTPX Client config (#265)
* pass HTTPX client config * add `trust_env` + `timeout` config * fix imports * remove `timeout` arg results in mypy error (although this seems like a mistake, it would just overwrite the default timeout value if it was provided). * verify can accept bool value * HttpClientConfig docstring * black formatting * use future annotations with typing_extensions `NotRequired` * pyupgrade * pass down http_client_config from AzureAuthorizationCodeBearerBase * refactor: trust the type checker for http_client_config - drop the runtime unknown-key validation; the TypedDict is the contract and type checkers enforce it, so an invalid dict simply surfaces httpx2's own error - narrow verify to ssl.SSLContext | bool: httpx2 deprecates the CA-bundle-path string form, so don't advertise it in a new API -- the docstring shows ssl.create_default_context(cafile=...) instead Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version to 5.3.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jonas Krüger Svensson <jonas@vibber.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d44e1b7 commit 3a2cee1

6 files changed

Lines changed: 132 additions & 6 deletions

File tree

fastapi_azure_auth/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
MultiTenantAzureAuthorizationCodeBearer as MultiTenantAzureAuthorizationCodeBearer,
44
SingleTenantAzureAuthorizationCodeBearer as SingleTenantAzureAuthorizationCodeBearer,
55
)
6+
from fastapi_azure_auth.openid_config import HttpClientConfig as HttpClientConfig # noqa: F401
67

7-
__version__ = '5.2.1'
8+
__version__ = '5.3.0'

fastapi_azure_auth/auth.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
UnauthorizedHttp,
3131
UnauthorizedWebSocket,
3232
)
33-
from fastapi_azure_auth.openid_config import OpenIdConfig
33+
from fastapi_azure_auth.openid_config import HttpClientConfig, OpenIdConfig
3434
from fastapi_azure_auth.user import User
3535
from fastapi_azure_auth.utils import get_unverified_claims, get_unverified_header, is_guest
3636

@@ -58,6 +58,7 @@ def __init__(
5858
openid_config_url: str | None = None,
5959
openapi_description: str | None = None,
6060
scheme_name: str = 'AzureAuthorizationCodeBearerBase',
61+
http_client_config: HttpClientConfig | None = None,
6162
) -> None:
6263
"""
6364
Initialize settings.
@@ -108,6 +109,10 @@ def __init__(
108109
:param scheme_name: str
109110
The name of the security scheme to be used in OpenAPI documentation.
110111
Default is 'AzureAuthorizationCodeBearerBase'.
112+
:param http_client_config: HttpClientConfig
113+
Configuration for the HTTP client used to fetch the OpenID configuration and signing keys,
114+
e.g. a custom SSL context. Note that these endpoints supply the token signing keys;
115+
disabling certificate verification breaks the chain of trust for token validation.
111116
"""
112117
self.auto_error = auto_error
113118
# Validate settings, making sure there's no misconfigured dependencies out there
@@ -124,6 +129,7 @@ def __init__(
124129
multi_tenant=self.multi_tenant,
125130
app_id=app_client_id if openid_config_use_app_id else None,
126131
config_url=openid_config_url or None,
132+
http_client_config=http_client_config,
127133
)
128134

129135
self.leeway: int = leeway
@@ -303,6 +309,7 @@ def __init__(
303309
openapi_token_url: str | None = None,
304310
openapi_description: str | None = None,
305311
scheme_name: str = 'AzureAD_PKCE_single_tenant',
312+
http_client_config: HttpClientConfig | None = None,
306313
) -> None:
307314
"""
308315
Initialize settings for a single tenant application.
@@ -341,6 +348,10 @@ def __init__(
341348
:param scheme_name: str
342349
The name of the security scheme to be used in OpenAPI documentation.
343350
Default is 'AzureAD_PKCE_single_tenant'.
351+
:param http_client_config: HttpClientConfig
352+
Configuration for the HTTP client used to fetch the OpenID configuration and signing keys,
353+
e.g. a custom SSL context. Note that these endpoints supply the token signing keys;
354+
disabling certificate verification breaks the chain of trust for token validation.
344355
"""
345356
super().__init__(
346357
app_client_id=app_client_id,
@@ -353,6 +364,7 @@ def __init__(
353364
openapi_authorization_url=openapi_authorization_url,
354365
openapi_token_url=openapi_token_url,
355366
openapi_description=openapi_description,
367+
http_client_config=http_client_config,
356368
)
357369
self.scheme_name: str = scheme_name
358370

@@ -372,6 +384,7 @@ def __init__(
372384
openapi_token_url: str | None = None,
373385
openapi_description: str | None = None,
374386
scheme_name: str = 'AzureAD_PKCE_multi_tenant',
387+
http_client_config: HttpClientConfig | None = None,
375388
) -> None:
376389
"""
377390
Initialize settings for a multi-tenant application.
@@ -415,6 +428,10 @@ def __init__(
415428
:param scheme_name: str
416429
The name of the security scheme to be used in OpenAPI documentation.
417430
Default is 'AzureAD_PKCE_multi_tenant'.
431+
:param http_client_config: HttpClientConfig
432+
Configuration for the HTTP client used to fetch the OpenID configuration and signing keys,
433+
e.g. a custom SSL context. Note that these endpoints supply the token signing keys;
434+
disabling certificate verification breaks the chain of trust for token validation.
418435
"""
419436
super().__init__(
420437
app_client_id=app_client_id,
@@ -429,6 +446,7 @@ def __init__(
429446
openapi_authorization_url=openapi_authorization_url,
430447
openapi_token_url=openapi_token_url,
431448
openapi_description=openapi_description,
449+
http_client_config=http_client_config,
432450
)
433451
self.scheme_name: str = scheme_name
434452

@@ -448,6 +466,7 @@ def __init__(
448466
openapi_token_url: str | None = None,
449467
openapi_description: str | None = None,
450468
scheme_name: str = 'AzureAD_PKCE_B2C_multi_tenant',
469+
http_client_config: HttpClientConfig | None = None,
451470
) -> None:
452471
"""
453472
Initialize settings for a B2C multi-tenant application.
@@ -486,6 +505,10 @@ def __init__(
486505
:param scheme_name: str
487506
The name of the security scheme to be used in OpenAPI documentation.
488507
Default is 'AzureAD_PKCE_B2C_multi_tenant'.
508+
:param http_client_config: HttpClientConfig
509+
Configuration for the HTTP client used to fetch the OpenID configuration and signing keys,
510+
e.g. a custom SSL context. Note that these endpoints supply the token signing keys;
511+
disabling certificate verification breaks the chain of trust for token validation.
489512
"""
490513
super().__init__(
491514
app_client_id=app_client_id,
@@ -501,5 +524,6 @@ def __init__(
501524
openapi_authorization_url=openapi_authorization_url,
502525
openapi_token_url=openapi_token_url,
503526
openapi_description=openapi_description,
527+
http_client_config=http_client_config,
504528
)
505529
self.scheme_name: str = scheme_name

fastapi_azure_auth/openid_config.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
2+
import ssl
23
from datetime import datetime, timedelta
3-
from typing import TYPE_CHECKING, Any
4+
from typing import TYPE_CHECKING, Any, NotRequired, TypedDict
45

56
import jwt
67
from fastapi import HTTPException, status
@@ -12,20 +13,51 @@
1213
log = logging.getLogger('fastapi_azure_auth')
1314

1415

16+
class HttpClientConfig(TypedDict):
17+
"""
18+
Configuration for the HTTP client used to fetch the OpenID configuration and signing keys.
19+
20+
verify - (optional) `True` (the default) verifies TLS certificates against the OS trust store,
21+
an `ssl.SSLContext` uses a custom trust configuration (e.g.
22+
`ssl.create_default_context(cafile='path/to/ca-bundle.pem')`), and `False`
23+
disables verification entirely.
24+
25+
.. warning::
26+
This endpoint supplies the keys used to validate every access token. Disabling
27+
verification allows an attacker who can intercept traffic to inject their own
28+
signing keys, which breaks the entire chain of trust.
29+
trust_env - (optional) Enables or disables reading proxy/SSL configuration from environment variables.
30+
timeout - (optional) Request timeout in seconds. Defaults to 10.
31+
"""
32+
33+
verify: NotRequired[ssl.SSLContext | bool]
34+
trust_env: NotRequired[bool]
35+
timeout: NotRequired[float]
36+
37+
1538
class OpenIdConfig:
1639
def __init__(
1740
self,
1841
tenant_id: str | None = None,
1942
multi_tenant: bool = False,
2043
app_id: str | None = None,
2144
config_url: str | None = None,
45+
http_client_config: HttpClientConfig | None = None,
2246
) -> None:
2347
self.tenant_id: str | None = tenant_id
2448
self._config_timestamp: datetime | None = None
2549
self.multi_tenant: bool = multi_tenant
2650
self.app_id = app_id
2751
self.config_url = config_url
2852

53+
if http_client_config is not None and http_client_config.get('verify') is False:
54+
log.warning(
55+
'TLS certificate verification is disabled for the OpenID configuration endpoint. '
56+
'This endpoint supplies the token signing keys; only disable verification in development.'
57+
)
58+
# Store a copy so a dict mutated by the caller can't change behaviour at the next config refresh.
59+
self.http_client_config: HttpClientConfig = http_client_config.copy() if http_client_config else {}
60+
2961
self.authorization_endpoint: str
3062
self.signing_keys: dict[str, AllowedPublicKeys]
3163
self.token_endpoint: str
@@ -72,7 +104,8 @@ async def _load_openid_config(self) -> None:
72104
if self.app_id:
73105
config_url += f'?appid={self.app_id}'
74106

75-
async with AsyncClient(timeout=10) as client:
107+
client_config: dict[str, Any] = {'timeout': 10, **self.http_client_config}
108+
async with AsyncClient(**client_config) as client:
76109
log.info('Fetching OpenID Connect config from %s', config_url)
77110
openid_response = await client.get(config_url)
78111
openid_response.raise_for_status()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-azure-auth"
3-
version = "5.2.1" # Remember to change in __init__.py as well
3+
version = "5.3.0" # Remember to change in __init__.py as well
44
description = "Easy and secure implementation of Azure Entra ID for your FastAPI APIs"
55
authors = [{ name = "Jonas Krüger Svensson", email = "jonas@vibber.ai" }]
66
requires-python = ">=3.11"

tests/test_provider_config.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from datetime import datetime, timedelta
23

34
import pytest
@@ -6,6 +7,7 @@
67

78
from demo_project.api.dependencies import azure_scheme
89
from demo_project.main import app
10+
from fastapi_azure_auth import HttpClientConfig, SingleTenantAzureAuthorizationCodeBearer
911
from fastapi_azure_auth.openid_config import OpenIdConfig
1012
from tests.utils import build_access_token, build_openid_keys, openid_configuration
1113

@@ -68,3 +70,69 @@ async def test_custom_config_id(httpx2_mock):
6870
)
6971
await openid_config.load_config()
7072
assert len(openid_config.signing_keys) == 2
73+
74+
75+
def _capture_client_kwargs(mocker):
76+
"""Patch the AsyncClient used for config fetching so the kwargs it receives can be asserted."""
77+
import fastapi_azure_auth.openid_config as openid_config_module
78+
79+
captured = {}
80+
real_client = openid_config_module.AsyncClient
81+
82+
def capture(**kwargs):
83+
captured.update(kwargs)
84+
return real_client(**kwargs)
85+
86+
mocker.patch.object(openid_config_module, 'AsyncClient', side_effect=capture)
87+
return captured
88+
89+
90+
@pytest.mark.anyio
91+
async def test_http_client_config_passed_to_client(httpx2_mock, mocker):
92+
captured = _capture_client_kwargs(mocker)
93+
openid_config = OpenIdConfig('vibber_tenant', http_client_config={'trust_env': False, 'timeout': 30})
94+
httpx2_mock.get('https://login.microsoftonline.com/vibber_tenant/v2.0/.well-known/openid-configuration').respond(
95+
json=openid_configuration()
96+
)
97+
httpx2_mock.get('https://login.microsoftonline.com/vibber_tenant/discovery/v2.0/keys').respond(
98+
json=build_openid_keys()
99+
)
100+
await openid_config.load_config()
101+
assert captured == {'timeout': 30, 'trust_env': False}
102+
assert len(openid_config.signing_keys) == 2
103+
104+
105+
@pytest.mark.anyio
106+
async def test_http_client_config_defaults_to_ten_second_timeout(httpx2_mock, mocker):
107+
captured = _capture_client_kwargs(mocker)
108+
openid_config = OpenIdConfig('vibber_tenant')
109+
httpx2_mock.get('https://login.microsoftonline.com/vibber_tenant/v2.0/.well-known/openid-configuration').respond(
110+
json=openid_configuration()
111+
)
112+
httpx2_mock.get('https://login.microsoftonline.com/vibber_tenant/discovery/v2.0/keys').respond(
113+
json=build_openid_keys()
114+
)
115+
await openid_config.load_config()
116+
assert captured == {'timeout': 10}
117+
118+
119+
def test_http_client_config_verify_false_warns(caplog):
120+
with caplog.at_level(logging.WARNING, logger='fastapi_azure_auth'):
121+
OpenIdConfig('vibber_tenant', http_client_config={'verify': False})
122+
assert 'TLS certificate verification is disabled' in caplog.text
123+
124+
125+
def test_http_client_config_is_copied_at_construction():
126+
config: HttpClientConfig = {'trust_env': False}
127+
openid_config = OpenIdConfig('vibber_tenant', http_client_config=config)
128+
config['trust_env'] = True
129+
assert openid_config.http_client_config == {'trust_env': False}
130+
131+
132+
def test_http_client_config_passed_through_scheme():
133+
scheme = SingleTenantAzureAuthorizationCodeBearer(
134+
app_client_id='client-id',
135+
tenant_id='vibber_tenant',
136+
http_client_config={'trust_env': False},
137+
)
138+
assert scheme.openid_config.http_client_config == {'trust_env': False}

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)