Visitar URL original
test(auth): regression coverage for azure-ad-hybrid OAuth state + id_token verification by failsafesecurity · Pull Request #3071 · Chainlit/chainlit · GitHub
Skip to content
This repository was archived by the owner on Oct 15, 2026. It is now read-only.

test(auth): regression coverage for azure-ad-hybrid OAuth state + id_token verification - #3071

Open
failsafesecurity wants to merge 2 commits into
Chainlit:mainfrom
failsafesecurity:test/azure-ad-hybrid-oauth-hardening
Open

failsafesecurity wants to merge 2 commits into
Chainlit:mainfrom
failsafesecurity:test/azure-ad-hybrid-oauth-hardening

Conversation

@failsafesecurity

@failsafesecurity failsafesecurity commented Oct 7, 2026 •

Copy link
Copy Markdown

test: regression coverage for azure-ad-hybrid OAuth state + id_token verification

Companion 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_token and never touched the OAuth state cookie, 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 Assertion
test_azure_hf_callback_missing_state_redirects A callback with no state form field is rejected before the app oauth_callback runs.
test_azure_hf_callback_missing_id_token_redirects A callback with no id_token is rejected.
test_azure_hf_callback_rejects_forged_id_token When id_token verification fails, the request redirects to the login error and no access_token cookie is set.
test_azure_hf_callback_verifies_id_token_with_login_nonce Verification receives the exact id_token and the nonce bound to the login (oauth_nonce cookie).
test_azure_hf_login_nonce_differs_per_request Two independent authorization requests produce different nonces (no process-lifetime reuse).

Note on the nonce test

test_azure_hf_login_nonce_differs_per_request exercises the fix in #3070 where the per-login nonce moved from a module-import-frozen provider instance into oauth_login. It will fail against main and pass once the fix is merged.

Scan

Scan ID: cmuwxf0uj0073lf01nna76pt1 · Finding: cmux90q9j00cvlf01x8qc9wna

⚠️ Automated security PR. Tests are additive only.


Summary 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:

  • Callbacks missing the state or id_token form field are rejected before the app oauth_callback runs.
  • A forged id_token that fails verification redirects to the login error and sets no access_token cookie.
  • id_token verification receives the exact token and the nonce bound to the login (oauth_nonce cookie).
  • Two independent authorization requests produce different nonces.

test_azure_hf_login_nonce_differs_per_request fails against main and passes once the fix in #3070 is merged. Tests are additive only.

Written for commit 1c68b24. Summary will update on new commits.

Review in cubic

failsafesecurity added 2 commits October 6, 2026 17:06
…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).

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

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)

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.

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,

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.

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

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.

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"},

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.

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"},

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.

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

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.

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

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.

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>

Comment on lines +636 to +640
decoded = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
audience=provider.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.

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>
Suggested change
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"

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.

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>
Suggested change
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"

This branch has not been deployed

No deployments
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant