Repository navigation
test(auth): regression coverage for azure-ad-hybrid OAuth state + id_token verification - #3071
failsafesecurity wants to merge 2 commits into
Conversation
…d callback The azure-ad-hybrid login callback accepted an OAuth code and an id_token straight from the browser, never validated the OAuth state cookie, and never verified the id_token before forwarding it to the application oauth_callback. An unauthenticated attacker could forge an alg=none id_token for any identity and be issued a session cookie for that principal. This change: - adds a required state form field and calls validate_oauth_state_cookie, matching the generic OAuth callback; - verifies the id_token signature (tenant JWKS), aud, iss/exp and nonce before it is trusted or forwarded; - generates a per-login OIDC nonce bound to the browser and verifies it against the returned id_token, instead of a nonce frozen on a provider instance constructed once at module import. Finding: azure-ad-hybrid OAuth callback skips state validation and trusts a client-supplied id_token (CVSS 9.3, CWE-347).
There was a problem hiding this comment.
9 issues found across 4 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="backend/chainlit/oauth_providers.py">
<violation number="1" location="backend/chainlit/oauth_providers.py:296">
P2: Removing the initializer's only `random_secret` call leaves its import unused, causing the configured backend lint check to fail with F401; remove the import.</violation>
<violation number="2" location="backend/chainlit/oauth_providers.py:296">
P2: Removing the static `nonce` from `AzureADHybridOAuthProvider.authorize_params` breaks the existing `test_azure_ad_hybrid_provider_initialization` test, which asserts `"nonce" in provider.authorize_params` (backend/tests/test_oauth_providers.py:678). That test is not updated in this PR, so the backend suite fails. Update it to assert the nonce is generated per login instead of stored on the provider.</violation>
</file>
<file name="backend/chainlit/server.py">
<violation number="1" location="backend/chainlit/server.py:621">
P2: `_azure_hf_jwks_url()` always targets the configured tenant, but `OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT` decides whether the actual flow runs against the tenant or the `common` endpoint. In the default multi-tenant mode, foreign-tenant users get id_tokens signed with keys Microsoft publishes per-tenant (not guaranteed in the common set), so signature verification can fail and their login breaks. Make the JWKS authority follow the same flag.</violation>
<violation number="2" location="backend/chainlit/server.py:636">
P2: `jwt.decode` is called without `issuer`, so PyJWT never validates the `iss` claim (its issuer check returns early when `issuer is None`), even though the docstring above states `iss` is validated. Verify `iss` against the authority plus the token's `tid` claim, or correct the docstring.</violation>
<violation number="3" location="backend/chainlit/server.py:800">
P1: Azure returns this callback as a cross-site `form_post`, while `oauth_state` defaults to `SameSite=Lax` (`auth/cookie.py`). Browsers omit that cookie on POST, so this check rejects default-config logins; make the OAuth cookies usable on this POST, such as with `SameSite=None; Secure`.</violation>
</file>
<file name="backend/chainlit/auth/cookie.py">
<violation number="1" location="backend/chainlit/auth/cookie.py:217">
P1: Azure AD hybrid returns via a cross-site `form_post`, so browsers omit this nonce cookie when the default `SameSite=Lax` setting is used and the callback rejects the login. Ensure this flow's nonce and state cookies use `SameSite=None; Secure`, or make that configuration requirement explicit.</violation>
</file>
<file name="backend/tests/test_server.py">
<violation number="1" location="backend/tests/test_server.py:1333">
P2: This test omits the state cookie and supplies an invalid `id_token`, so it still redirects if the missing-form-state check is removed; cookie validation or token verification can reject the request instead. Set a matching `oauth_state` cookie and make token verification succeed to isolate rejection of the missing form `state`.</violation>
<violation number="2" location="backend/tests/test_server.py:1352">
P2: This test omits `oauth_nonce`, so it can pass when the missing-`id_token` guard is removed because verification can reject for the absent nonce or token. Provide a nonce and stub verification to accept, then assert the missing token stops the flow before verification or the callback.</violation>
<violation number="3" location="backend/tests/test_server.py:1435">
P2: This test forces `uses_per_login_nonce` on the mock, so it passes even if `AzureADHybridOAuthProvider` stops enabling per-login nonce generation. Exercise the real provider or derive the mock flag from its class so this test catches that wiring regression.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Turn on auto-fix | Re-trigger cubic
| return _get_oauth_redirect_error(request, "oauthSignin", status_code=303) | ||
|
|
||
| try: | ||
| validate_oauth_state_cookie(request, state) |
There was a problem hiding this comment.
P1: Azure returns this callback as a cross-site form_post, while oauth_state defaults to SameSite=Lax (auth/cookie.py). Browsers omit that cookie on POST, so this check rejects default-config logins; make the OAuth cookies usable on this POST, such as with SameSite=None; Secure.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/chainlit/server.py, line 800:
<comment>Azure returns this callback as a cross-site `form_post`, while `oauth_state` defaults to `SameSite=Lax` (`auth/cookie.py`). Browsers omit that cookie on POST, so this check rejects default-config logins; make the OAuth cookies usable on this POST, such as with `SameSite=None; Secure`.</comment>
<file context>
@@ -741,6 +790,27 @@ async def oauth_azure_hf_callback(
+ return _get_oauth_redirect_error(request, "oauthSignin", status_code=303)
+
+ try:
+ validate_oauth_state_cookie(request, state)
+ except Exception as e:
+ logger.warning("Unable to validate oauth state: %s", e, exc_info=True)
</file context>
| _nonce_cookie_name, | ||
| token, | ||
| httponly=True, | ||
| samesite=_cookie_samesite, |
There was a problem hiding this comment.
P1: Azure AD hybrid returns via a cross-site form_post, so browsers omit this nonce cookie when the default SameSite=Lax setting is used and the callback rejects the login. Ensure this flow's nonce and state cookies use SameSite=None; Secure, or make that configuration requirement explicit.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/chainlit/auth/cookie.py, line 217:
<comment>Azure AD hybrid returns via a cross-site `form_post`, so browsers omit this nonce cookie when the default `SameSite=Lax` setting is used and the callback rejects the login. Ensure this flow's nonce and state cookies use `SameSite=None; Secure`, or make that configuration requirement explicit.</comment>
<file context>
@@ -207,6 +208,28 @@ def validate_oauth_state_cookie(request: Request, state: str):
+ _nonce_cookie_name,
+ token,
+ httponly=True,
+ samesite=_cookie_samesite,
+ secure=_cookie_secure,
+ max_age=_state_cookie_lifetime,
</file context>
| self.client_id = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_ID") | ||
| self.client_secret = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET") | ||
| nonce = random_secret(16) | ||
| # NOTE: the OIDC ``nonce`` is intentionally NOT set here. Provider |
There was a problem hiding this comment.
P2: Removing the initializer's only random_secret call leaves its import unused, causing the configured backend lint check to fail with F401; remove the import.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/chainlit/oauth_providers.py, line 296:
<comment>Removing the initializer's only `random_secret` call leaves its import unused, causing the configured backend lint check to fail with F401; remove the import.</comment>
<file context>
@@ -288,7 +293,12 @@ class AzureADHybridOAuthProvider(OAuthProvider):
self.client_id = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET")
- nonce = random_secret(16)
+ # NOTE: the OIDC ``nonce`` is intentionally NOT set here. Provider
+ # instances are constructed once at module import, so a nonce stored on
+ # ``self.authorize_params`` would be identical for every login for the
</file context>
| response = test_client.post( | ||
| "/auth/oauth/azure-ad-hybrid/callback", | ||
| data={"code": "fake_code", "state": "valid_state"}, | ||
| cookies={"oauth_state": "valid_state"}, |
There was a problem hiding this comment.
P2: This test omits oauth_nonce, so it can pass when the missing-id_token guard is removed because verification can reject for the absent nonce or token. Provide a nonce and stub verification to accept, then assert the missing token stops the flow before verification or the callback.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/tests/test_server.py, line 1352:
<comment>This test omits `oauth_nonce`, so it can pass when the missing-`id_token` guard is removed because verification can reject for the absent nonce or token. Provide a nonce and stub verification to accept, then assert the missing token stops the flow before verification or the callback.</comment>
<file context>
@@ -1314,6 +1315,140 @@ def test_oauth_azure_hf_callback_get_token_error_redirects(
+ response = test_client.post(
+ "/auth/oauth/azure-ad-hybrid/callback",
+ data={"code": "fake_code", "state": "valid_state"},
+ cookies={"oauth_state": "valid_state"},
+ follow_redirects=False,
+ )
</file context>
| test_config.code.oauth_callback = callback | ||
| response = test_client.post( | ||
| "/auth/oauth/azure-ad-hybrid/callback", | ||
| data={"code": "fake_code", "id_token": "a.b.c"}, |
There was a problem hiding this comment.
P2: This test omits the state cookie and supplies an invalid id_token, so it still redirects if the missing-form-state check is removed; cookie validation or token verification can reject the request instead. Set a matching oauth_state cookie and make token verification succeed to isolate rejection of the missing form state.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/tests/test_server.py, line 1333:
<comment>This test omits the state cookie and supplies an invalid `id_token`, so it still redirects if the missing-form-state check is removed; cookie validation or token verification can reject the request instead. Set a matching `oauth_state` cookie and make token verification succeed to isolate rejection of the missing form `state`.</comment>
<file context>
@@ -1314,6 +1315,140 @@ def test_oauth_azure_hf_callback_get_token_error_redirects(
+ test_config.code.oauth_callback = callback
+ response = test_client.post(
+ "/auth/oauth/azure-ad-hybrid/callback",
+ data={"code": "fake_code", "id_token": "a.b.c"},
+ follow_redirects=False,
+ )
</file context>
| mock_oauth_provider.authorize_url = ( | ||
| "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" | ||
| ) | ||
| mock_oauth_provider.uses_per_login_nonce = True |
There was a problem hiding this comment.
P2: This test forces uses_per_login_nonce on the mock, so it passes even if AzureADHybridOAuthProvider stops enabling per-login nonce generation. Exercise the real provider or derive the mock flag from its class so this test catches that wiring regression.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/tests/test_server.py, line 1435:
<comment>This test forces `uses_per_login_nonce` on the mock, so it passes even if `AzureADHybridOAuthProvider` stops enabling per-login nonce generation. Exercise the real provider or derive the mock flag from its class so this test catches that wiring regression.</comment>
<file context>
@@ -1314,6 +1315,140 @@ def test_oauth_azure_hf_callback_get_token_error_redirects(
+ mock_oauth_provider.authorize_url = (
+ "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
+ )
+ mock_oauth_provider.uses_per_login_nonce = True
+
+ nonces = []
</file context>
| self.client_id = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_ID") | ||
| self.client_secret = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET") | ||
| nonce = random_secret(16) | ||
| # NOTE: the OIDC ``nonce`` is intentionally NOT set here. Provider |
There was a problem hiding this comment.
P2: Removing the static nonce from AzureADHybridOAuthProvider.authorize_params breaks the existing test_azure_ad_hybrid_provider_initialization test, which asserts "nonce" in provider.authorize_params (backend/tests/test_oauth_providers.py:678). That test is not updated in this PR, so the backend suite fails. Update it to assert the nonce is generated per login instead of stored on the provider.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/chainlit/oauth_providers.py, line 296:
<comment>Removing the static `nonce` from `AzureADHybridOAuthProvider.authorize_params` breaks the existing `test_azure_ad_hybrid_provider_initialization` test, which asserts `"nonce" in provider.authorize_params` (backend/tests/test_oauth_providers.py:678). That test is not updated in this PR, so the backend suite fails. Update it to assert the nonce is generated per login instead of stored on the provider.</comment>
<file context>
@@ -288,7 +293,12 @@ class AzureADHybridOAuthProvider(OAuthProvider):
self.client_id = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_ID")
self.client_secret = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET")
- nonce = random_secret(16)
+ # NOTE: the OIDC ``nonce`` is intentionally NOT set here. Provider
+ # instances are constructed once at module import, so a nonce stored on
+ # ``self.authorize_params`` would be identical for every login for the
</file context>
| decoded = jwt.decode( | ||
| id_token, | ||
| signing_key.key, | ||
| algorithms=["RS256"], | ||
| audience=provider.client_id, |
There was a problem hiding this comment.
P2: jwt.decode is called without issuer, so PyJWT never validates the iss claim (its issuer check returns early when issuer is None), even though the docstring above states iss is validated. Verify iss against the authority plus the token's tid claim, or correct the docstring.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/chainlit/server.py, line 636:
<comment>`jwt.decode` is called without `issuer`, so PyJWT never validates the `iss` claim (its issuer check returns early when `issuer is None`), even though the docstring above states `iss` is validated. Verify `iss` against the authority plus the token's `tid` claim, or correct the docstring.</comment>
<file context>
@@ -611,6 +615,40 @@ async def header_auth(request: Request):
+
+ jwks_client = jwt.PyJWKClient(_azure_hf_jwks_url())
+ signing_key = jwks_client.get_signing_key_from_jwt(id_token)
+ decoded = jwt.decode(
+ id_token,
+ signing_key.key,
</file context>
| decoded = jwt.decode( | |
| id_token, | |
| signing_key.key, | |
| algorithms=["RS256"], | |
| audience=provider.client_id, | |
| decoded = jwt.decode( | |
| id_token, | |
| signing_key.key, | |
| algorithms=["RS256"], | |
| audience=provider.client_id, | |
| ) | |
| authority = os.environ.get( | |
| "OAUTH_AZURE_AD_HYBRID_AUTHORITY", "https://login.microsoftonline.com" | |
| ) | |
| expected_iss = f"{authority.rstrip('/')}/{decoded.get('tid', '')}/v2.0" | |
| if decoded.get("iss") != expected_iss: | |
| raise Exception("id_token issuer does not match its tenant") |
| def _azure_hf_jwks_url() -> str: | ||
| tenant = os.environ.get("OAUTH_AZURE_AD_HYBRID_TENANT_ID", "common") | ||
| base = os.environ.get("OAUTH_AZURE_AD_HYBRID_AUTHORITY", "https://login.microsoftonline.com") | ||
| return f"{base.rstrip('/')}/{tenant}/discovery/v2.0/keys" |
There was a problem hiding this comment.
P2: _azure_hf_jwks_url() always targets the configured tenant, but OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT decides whether the actual flow runs against the tenant or the common endpoint. In the default multi-tenant mode, foreign-tenant users get id_tokens signed with keys Microsoft publishes per-tenant (not guaranteed in the common set), so signature verification can fail and their login breaks. Make the JWKS authority follow the same flag.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At backend/chainlit/server.py, line 621:
<comment>`_azure_hf_jwks_url()` always targets the configured tenant, but `OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT` decides whether the actual flow runs against the tenant or the `common` endpoint. In the default multi-tenant mode, foreign-tenant users get id_tokens signed with keys Microsoft publishes per-tenant (not guaranteed in the common set), so signature verification can fail and their login breaks. Make the JWKS authority follow the same flag.</comment>
<file context>
@@ -611,6 +615,40 @@ async def header_auth(request: Request):
+def _azure_hf_jwks_url() -> str:
+ tenant = os.environ.get("OAUTH_AZURE_AD_HYBRID_TENANT_ID", "common")
+ base = os.environ.get("OAUTH_AZURE_AD_HYBRID_AUTHORITY", "https://login.microsoftonline.com")
+ return f"{base.rstrip('/')}/{tenant}/discovery/v2.0/keys"
+
+
</file context>
| return f"{base.rstrip('/')}/{tenant}/discovery/v2.0/keys" | |
| if not os.environ.get("OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT"): | |
| tenant = "common" | |
| return f"{base.rstrip('/')}/{tenant}/discovery/v2.0/keys" |
test: regression coverage for azure-ad-hybrid OAuth state +
id_tokenverificationCompanion test PR to the fix in #3070 for the CRITICAL finding "azure-ad-hybrid OAuth callback skips state validation and trusts a client-supplied id_token" (CVSS 9.3, CWE-347).
Depends on / pairs with the fix branch
fix/azure-ad-hybrid-oauth-state-idtoken.Why
The vulnerability was silent: the callback happily minted a session for a forged, unsigned (
alg=none)id_tokenand never touched the OAuthstatecookie, yet no test asserted either behaviour. These tests lock the guardrails in so the flaw cannot regress.What these tests assert
In
backend/tests/test_server.py:test_azure_hf_callback_missing_state_redirectsstateform field is rejected before the appoauth_callbackruns.test_azure_hf_callback_missing_id_token_redirectsid_tokenis rejected.test_azure_hf_callback_rejects_forged_id_tokenid_tokenverification fails, the request redirects to the login error and noaccess_tokencookie is set.test_azure_hf_callback_verifies_id_token_with_login_nonceid_tokenand the nonce bound to the login (oauth_noncecookie).test_azure_hf_login_nonce_differs_per_requestNote on the nonce test
test_azure_hf_login_nonce_differs_per_requestexercises the fix in #3070 where the per-login nonce moved from a module-import-frozen provider instance intooauth_login. It will fail againstmainand pass once the fix is merged.Scan
Scan ID:
cmuwxf0uj0073lf01nna76pt1· Finding:cmux90q9j00cvlf01x8qc9wnaSummary by cubic
Adds regression tests for the azure-ad-hybrid OAuth callback hardening from #3070, which closed a vulnerability where the callback skipped state validation and trusted a client-supplied, forged id_token.
The new tests assert that:
oauth_callbackruns.access_tokencookie.oauth_noncecookie).test_azure_hf_login_nonce_differs_per_requestfails againstmainand passes once the fix in #3070 is merged. Tests are additive only.Written for commit 1c68b24. Summary will update on new commits.