diff --git a/application/single_app/config.py b/application/single_app/config.py index bfeb46da..d57f7c87 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -99,6 +99,14 @@ VERSION = "0.260.028" IS_DEVELOPMENT = is_development_env_enabled() +# Opt-out for deployments where App Service Easy Auth is active but the platform +# /.auth/logout endpoint is not reachable on the public host (for example, when a +# custom domain or gateway does not route /.auth/* to the App Service origin). +DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT = os.getenv( + 'DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT', + '' +).strip().lower() == 'true' + SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() != 'false' SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true' diff --git a/application/single_app/example.env b/application/single_app/example.env index 7803e0b3..ca4e052c 100644 --- a/application/single_app/example.env +++ b/application/single_app/example.env @@ -20,4 +20,12 @@ AZURE_ENVIRONMENT="public" # Optional Graph overrides (for cross-cloud identity/Graph scenarios) # Example values: # CUSTOM_GRAPH_URL_VALUE="https://graph.microsoft.com" -# CUSTOM_GRAPH_AUTHORITY_URL_VALUE="https://login.microsoftonline.com" \ No newline at end of file +# CUSTOM_GRAPH_AUTHORITY_URL_VALUE="https://login.microsoftonline.com" + +# Logout behavior on Azure App Service +# SimpleChat detects App Service Easy Auth from the X-MS-CLIENT-PRINCIPAL request headers +# the platform injects, and routes logout through /.auth/logout so the platform session is +# cleared. Set this to "true" only if Easy Auth is active but /.auth/logout is not reachable +# on your public host, for example when a custom domain or gateway does not route /.auth/* +# to the App Service origin. Has no effect when running locally. +# DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT="true" diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index c07b5151..db795d24 100644 --- a/application/single_app/route_frontend_authentication.py +++ b/application/single_app/route_frontend_authentication.py @@ -6,6 +6,7 @@ import requests from config import * +from config import DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT from functions_activity_logging import log_user_login, record_user_login_session_activity from functions_terms_of_use import ( apply_pending_pre_auth_terms_of_use, @@ -42,16 +43,36 @@ def build_front_door_urls(front_door_url): def _use_app_service_easy_auth_logout(): - """Return True when the current request is running behind App Service Easy Auth.""" + """ + Determine whether logout should route through the App Service Easy Auth endpoint. + + Args: + None. + + Returns: + bool: True when the current request is being served behind App Service Easy Auth. + Raises: + None. + """ + if DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT: + debug_print("Easy Auth logout disabled by DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT; using local logout.") + return False + if not os.getenv('WEBSITE_HOSTNAME'): return False + # Easy Auth injects these headers only on requests it actually intercepts, so they are + # the reliable per-request signal that /.auth/logout is being served for this host. easy_auth_headers = ( request.headers.get('X-MS-CLIENT-PRINCIPAL'), request.headers.get('X-MS-CLIENT-PRINCIPAL-ID'), request.headers.get('X-MS-CLIENT-PRINCIPAL-NAME'), ) - return any(easy_auth_headers) or bool(os.getenv('WEBSITE_AUTH_AAD_ALLOWED_TENANTS')) + if not any(easy_auth_headers): + debug_print("No App Service Easy Auth principal headers on this request; using local logout.") + return False + + return True def _build_app_service_easy_auth_logout_url(): diff --git a/docs/explanation/fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md b/docs/explanation/fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md new file mode 100644 index 00000000..c1383fc0 --- /dev/null +++ b/docs/explanation/fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md @@ -0,0 +1,85 @@ +# Easy Auth Logout Detection Fix + +Fixed/Implemented in version: **0.260.019** + +## Issue Description + +User-initiated logout and idle-timeout logout could redirect to +`/.auth/logout?post_logout_redirect_uri=%2Flogin` and return a 404 on Azure App Service +deployments that were not actually serving App Service Easy Auth. The problem was first +reported on a development custom domain, but it was never limited to development +environments; any deployment matching the same conditions was affected, including +production. + +## Root Cause Analysis + +`_use_app_service_easy_auth_logout()` in +`application/single_app/route_frontend_authentication.py` decided that Easy Auth was active +when either the `X-MS-CLIENT-PRINCIPAL` request headers were present **or** the +`WEBSITE_AUTH_AAD_ALLOWED_TENANTS` environment variable was set: + +```python +return any(easy_auth_headers) or bool(os.getenv('WEBSITE_AUTH_AAD_ALLOWED_TENANTS')) +``` + +That environment variable is not evidence that Easy Auth is running. SimpleChat's own +advanced configuration guidance in +`application/single_app/example_advance_edit_environment_variables.json` instructs +operators to set it by hand, so any deployment that followed those instructions without +enabling Easy Auth was misdetected. Logout then redirected to a platform endpoint that the +App Service was not serving, producing the 404. + +A secondary case exists where Easy Auth genuinely is enabled but `/.auth/*` is not routed +through to the App Service origin, for example behind a custom domain, gateway or front +door with restrictive path routing. Request-based detection cannot distinguish that case, +so it needs an explicit opt-out. + +## Technical Details + +- Modified `application/single_app/route_frontend_authentication.py` so Easy Auth detection + relies only on the `X-MS-CLIENT-PRINCIPAL`, `X-MS-CLIENT-PRINCIPAL-ID` and + `X-MS-CLIENT-PRINCIPAL-NAME` headers that App Service injects into requests it actually + intercepts. The `WEBSITE_AUTH_AAD_ALLOWED_TENANTS` fallback was removed. +- Added `debug_print` output on both non-Easy-Auth paths so the logout routing decision and + its reason are visible with `FLASK_DEBUG=1`. +- Added the `DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT` environment flag in + `application/single_app/config.py` for deployments where Easy Auth is active but + `/.auth/logout` is unreachable on the public host. +- Documented the flag in `application/single_app/example.env` and added a + "Logout Behavior Across Environments" section to + `docs/explanation/running_simplechat_locally.md` covering the behavior per environment + and the troubleshooting steps for a logout 404. +- Updated `application/single_app/config.py` to version `0.260.019`. +- Reworked regression coverage in `functional_tests/test_app_service_easy_auth_logout.py`. + +### Behavior by environment + +| Environment | Easy Auth headers | Logout path | +| --- | --- | --- | +| Local machine (`python app.py`) | No | Local logout | +| App Service with Easy Auth enabled | Yes | Easy Auth logout via `/.auth/logout` | +| App Service without Easy Auth enabled | No | Local logout | +| Easy Auth enabled, `/.auth/*` not routed | Yes | Local logout after setting the opt-out flag | + +Local development was never affected by the original defect, because `WEBSITE_HOSTNAME` is +not set outside App Service and the function returned early. + +## Validation + +- `functional_tests/test_app_service_easy_auth_logout.py` — 5/5 passing, covering Easy Auth + local logout, Easy Auth full logout, the reported no-headers case, preservation of Easy + Auth logout on a non-production host, and the opt-out flag. +- `functional_tests/test_idle_logout_timeout.py` — 4/4 passing. The idle-timeout path routes + through `local_logout`, so it inherits the corrected behavior. +- Confirmed deployments genuinely behind Easy Auth still redirect through `/.auth/logout`, + so the upstream platform session continues to be cleared. +- Confirmed deployments with Azure hosting variables but no Easy Auth headers now perform a + local logout instead of redirecting to a missing platform endpoint. + +## Notes + +The previous iteration of this fix skipped Easy Auth logout whenever the `is_development` +environment flag was set. That approach was replaced because it left the underlying +detection defect in place for production, it reused a flag documented for Latest Features +navigation to control session termination, and it disabled platform logout even in +development environments where Easy Auth was genuinely active and working. diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md index a7c05cdc..5557504c 100644 --- a/docs/explanation/fixes/index.md +++ b/docs/explanation/fixes/index.md @@ -37,6 +37,7 @@ category: Version History - [New Chat Conversation Documents Drawer Reset Fix](NEW_CHAT_CONVERSATION_DOCUMENTS_DRAWER_RESET_FIX.md) - [Collaboration Mention Tab Autocomplete Fix](COLLABORATION_MENTION_TAB_AUTOCOMPLETE_FIX.md) - [Generated Artifact Paging, Truncation, and Guidance Carry-Forward Fix](GENERATED_ARTIFACT_PAGING_AND_GUIDANCE_FIX.md) +- [Easy Auth Logout Detection Fix](EASY_AUTH_LOGOUT_DETECTION_FIX.md) - [Admin Settings Pane Variable Scope Fix](ADMIN_SETTINGS_PANE_VARIABLE_SCOPE_FIX.md) - [Inline Media Cited-Only Gating Fix](INLINE_MEDIA_CITED_ONLY_GATING_FIX.md) - [Agent Actions With Workspace Evidence Fix](AGENT_ACTIONS_WITH_WORKSPACE_EVIDENCE_FIX.md) diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 5c27acd5..0fe2c88b 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -118,6 +118,24 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver * The Cosmos DB tab checked the wrong thing for the debug setting, so the backfill controls, shadow validation metrics and reset option stayed hidden even after an admin turned the setting on. * (Ref: `admin/_panes/cosmos.html`, `enable_dai_debug`) +### **(v0.260.019)** + +#### Bug Fixes + +* **Logout No Longer Redirects To A Missing Easy Auth Endpoint** + * Logout could redirect to `/.auth/logout?post_logout_redirect_uri=%2Flogin` and return a 404 on Azure App Service deployments that were not actually serving App Service Easy Auth. This affected production deployments as well as development ones. + * The root cause was Easy Auth detection treating the manually configured `WEBSITE_AUTH_AAD_ALLOWED_TENANTS` application setting as proof that Easy Auth was intercepting requests. SimpleChat's own advanced environment variable guidance instructs operators to set that value by hand, so it was never a reliable signal. + * Detection now relies only on the `X-MS-CLIENT-PRINCIPAL` request headers that App Service Easy Auth injects on requests it actually intercepts, so deployments genuinely behind Easy Auth still clear the upstream platform session, and everyone else gets a clean local logout. + * Idle-timeout logout uses the same local logout path, so automatic session expiration follows the corrected behavior as well. + * (Ref: `route_frontend_authentication.py`, `_use_app_service_easy_auth_logout`, `test_app_service_easy_auth_logout.py`, [Easy Auth Logout Detection Fix](fixes/EASY_AUTH_LOGOUT_DETECTION_FIX.md)) + +#### New Features + +* **Opt-Out For App Service Easy Auth Logout** + * Added the `DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT` environment variable for deployments where Easy Auth is genuinely active but the platform `/.auth/logout` endpoint is not reachable on the public host, such as when a custom domain or gateway does not route `/.auth/*` to the App Service origin. + * Setting it to `true` keeps logout on the local path instead of redirecting to the platform endpoint. Logout routing decisions are now also traced through debug logging, so `FLASK_DEBUG=1` shows which path was taken and why. + * (Ref: `DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT`, `config.py`, `example.env`, [Running SimpleChat Locally](running_simplechat_locally.md)) + ### **(v0.260.018)** #### Bug Fixes diff --git a/docs/explanation/running_simplechat_locally.md b/docs/explanation/running_simplechat_locally.md index b1d70f58..3a5181a6 100644 --- a/docs/explanation/running_simplechat_locally.md +++ b/docs/explanation/running_simplechat_locally.md @@ -181,6 +181,53 @@ If you want to test the scheduler separately, run: python simplechat_scheduler.py ``` +## Logout Behavior Across Environments + +Logout takes one of two paths, chosen per request. Knowing which one you are on explains +most unexpected logout behavior. + +- **Local logout** clears the Flask session and redirects to the app home page. +- **Easy Auth logout** first redirects through the App Service platform endpoint + `/.auth/logout` so the platform sign-in session is cleared too, then returns to the + SimpleChat login page. + +SimpleChat picks Easy Auth logout only when the request carries the +`X-MS-CLIENT-PRINCIPAL` headers that App Service Easy Auth injects into requests it +intercepts. That gives the following behavior: + +| Where you are running | Easy Auth headers present | Logout path | +| --- | --- | --- | +| Local machine (`python app.py`) | No | Local logout | +| App Service with Easy Auth enabled | Yes | Easy Auth logout | +| App Service without Easy Auth enabled | No | Local logout | + +Running locally, you always get local logout, because `WEBSITE_HOSTNAME` is not set and no +platform headers exist. There is nothing to configure for local development. + +### Troubleshooting a 404 on logout + +If logout lands on a 404 at `/.auth/logout`, the app believed Easy Auth was serving that +host but the endpoint was not reachable. Work through the following: + +1. Run with `FLASK_DEBUG=1` and sign out again. The logout path decision is written to the + debug log, including the reason when local logout is chosen. +2. Confirm whether Easy Auth is actually enabled for the App Service. Setting only the + `WEBSITE_AUTH_AAD_ALLOWED_TENANTS` application setting does not enable it. +3. If Easy Auth is enabled, confirm that `/.auth/*` is routed through to the App Service + origin. A custom domain, gateway or front door that does not forward those paths will + return a 404 even though Easy Auth is running. + +If Easy Auth must stay enabled and `/.auth/*` cannot be routed, keep logout on the local +path with: + +```bash +DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT=true +``` + +Use that only when the routing issue cannot be fixed. While it is set, signing out no +longer clears the App Service platform session, so the next sign-in can complete without +prompting for credentials. + ## Practical Guidance - Create `.venv` with Python 3.12 and select it in VS Code before installing dependencies. diff --git a/functional_tests/test_app_service_easy_auth_logout.py b/functional_tests/test_app_service_easy_auth_logout.py index 569202a8..89e1701a 100644 --- a/functional_tests/test_app_service_easy_auth_logout.py +++ b/functional_tests/test_app_service_easy_auth_logout.py @@ -1,20 +1,23 @@ # test_app_service_easy_auth_logout.py """ -Functional test for Azure App Service Easy Auth logout recovery. -Version: 0.241.095 -Implemented in: 0.241.095 - -This test ensures Azure-hosted logout routes clear the upstream App Service -authentication session by redirecting through /.auth/logout before re-entering -the Flask login flow. +Functional test for Azure App Service Easy Auth logout detection. +Version: 0.260.019 +Implemented in: 0.260.019 + +This test ensures logout routes through /.auth/logout only when App Service Easy Auth is +actually serving the request, so deployments that are not behind Easy Auth no longer hit a +404 on the platform logout endpoint, while deployments that are behind it still clear the +upstream platform session. """ from pathlib import Path +import importlib import os import sys -from unittest.mock import patch +import types +from unittest.mock import patch, Mock -from flask import Flask, session +from flask import Blueprint, Flask, session ROOT = Path(__file__).resolve().parents[1] @@ -24,7 +27,103 @@ sys.path.insert(0, str(APP_DIR)) -import route_frontend_authentication as route_module # noqa: E402 +class FakeConfigCosmosContainer: + """Minimal Cosmos container stand-in for config.py import-time setup.""" + + def read(self): + return {} + + +class FakeConfigCosmosDatabase: + """Minimal Cosmos database stand-in for importing config.py without live I/O.""" + + def __init__(self): + self.containers = {} + + def create_container_if_not_exists(self, id, **kwargs): + if id not in self.containers: + self.containers[id] = FakeConfigCosmosContainer() + return self.containers[id] + + def get_container_client(self, id): + return self.containers.setdefault(id, FakeConfigCosmosContainer()) + + +class FakeConfigCosmosClient: + """Minimal Cosmos client stand-in for config.py import-time container setup.""" + + def __init__(self, *args, **kwargs): + self.database = FakeConfigCosmosDatabase() + + def create_database_if_not_exists(self, *args, **kwargs): + return self.database + + +def import_module_without_live_cosmos(module_name): + """Import app modules without letting config.py connect to live Cosmos.""" + if module_name in sys.modules: + return sys.modules[module_name] + + import azure.cosmos as azure_cosmos + + original_cosmos_client = azure_cosmos.CosmosClient + azure_cosmos.CosmosClient = FakeConfigCosmosClient + stub_modules = _install_route_dependency_stubs() + try: + return importlib.import_module(module_name) + finally: + azure_cosmos.CosmosClient = original_cosmos_client + for stub_name in stub_modules: + sys.modules.pop(stub_name, None) + + +def _install_route_dependency_stubs(): + """Install lightweight stubs for dependencies unrelated to logout routing.""" + stub_modules = {} + + functions_activity_logging = types.ModuleType("functions_activity_logging") + functions_activity_logging.log_user_login = Mock() + functions_activity_logging.record_user_login_session_activity = Mock() + stub_modules["functions_activity_logging"] = functions_activity_logging + + functions_terms_of_use = types.ModuleType("functions_terms_of_use") + functions_terms_of_use.apply_pending_pre_auth_terms_of_use = Mock() + functions_terms_of_use.get_terms_of_use_config = Mock(return_value={"enabled": False}) + functions_terms_of_use.has_terms_of_use_acceptance = Mock(return_value=True) + stub_modules["functions_terms_of_use"] = functions_terms_of_use + + functions_authentication = types.ModuleType("functions_authentication") + functions_authentication._build_msal_app = Mock() + functions_authentication._load_cache = Mock(return_value=None) + functions_authentication._save_cache = Mock() + functions_authentication.clear_requested_oauth_scopes = Mock() + functions_authentication.create_ci_bearer_session = Mock(return_value=("", 204)) + functions_authentication.get_graph_authority = Mock(return_value="https://graph.microsoft.com") + functions_authentication.get_graph_endpoint = Mock(side_effect=lambda path: f"https://graph.microsoft.com/v1.0{path}") + functions_authentication.get_requested_oauth_scopes = Mock(return_value=[]) + stub_modules["functions_authentication"] = functions_authentication + + functions_debug = types.ModuleType("functions_debug") + functions_debug.debug_print = Mock() + stub_modules["functions_debug"] = functions_debug + + functions_settings = types.ModuleType("functions_settings") + functions_settings.get_settings = Mock(return_value={}) + functions_settings.sanitize_settings_for_user = Mock(side_effect=lambda settings: settings) + stub_modules["functions_settings"] = functions_settings + + swagger_wrapper = types.ModuleType("swagger_wrapper") + swagger_wrapper.swagger_route = Mock(side_effect=lambda *args, **kwargs: (lambda function: function)) + swagger_wrapper.get_auth_security = Mock(return_value=[]) + stub_modules["swagger_wrapper"] = swagger_wrapper + + for stub_name, stub_module in stub_modules.items(): + sys.modules[stub_name] = stub_module + + return stub_modules + + +route_module = import_module_without_live_cosmos("route_frontend_authentication") EXPECTED_EASY_AUTH_LOGOUT = "/.auth/logout?post_logout_redirect_uri=%2Flogin" @@ -34,11 +133,14 @@ def _build_test_app(): app = Flask(__name__) app.secret_key = "test-secret" - @app.route("/") def index(): return "ok" - route_module.register_route_frontend_authentication(app) + app.add_url_rule("/", endpoint="public_app.index", view_func=index) + + auth_blueprint = Blueprint("frontend_authentication", __name__) + route_module.register_route_frontend_authentication(auth_blueprint) + app.register_blueprint(auth_blueprint) return app @@ -55,7 +157,7 @@ def test_local_logout_uses_app_service_easy_auth_logout(): "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "tenant-id", }, clear=False, - ): + ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", False): with app.test_request_context( "/logout/local", base_url="https://example.azurewebsites.net", @@ -63,7 +165,7 @@ def test_local_logout_uses_app_service_easy_auth_logout(): ): session["user"] = {"name": "Test User"} - response = app.view_functions["local_logout"]() + response = app.view_functions["frontend_authentication.local_logout"]() assert response.status_code == 302, f"Expected redirect response, got {response.status_code}" assert response.headers.get("Location") == EXPECTED_EASY_AUTH_LOGOUT, ( @@ -87,7 +189,7 @@ def test_full_logout_uses_app_service_easy_auth_logout(): "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "tenant-id", }, clear=False, - ): + ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", False): with app.test_request_context( "/logout", base_url="https://example.azurewebsites.net", @@ -98,7 +200,7 @@ def test_full_logout_uses_app_service_easy_auth_logout(): "preferred_username": "user@example.com", } - response = app.view_functions["logout"]() + response = app.view_functions["frontend_authentication.logout"]() assert response.status_code == 302, f"Expected redirect response, got {response.status_code}" assert response.headers.get("Location") == EXPECTED_EASY_AUTH_LOGOUT, ( @@ -109,10 +211,123 @@ def test_full_logout_uses_app_service_easy_auth_logout(): print("App Service Easy Auth full logout redirects through /.auth/logout") +def test_logout_skips_easy_auth_when_platform_headers_absent(): + """Verify logout stays local when Easy Auth is not actually serving the request. + + This is the reported failure: an App Service deployment that sets + WEBSITE_AUTH_AAD_ALLOWED_TENANTS by hand without Easy Auth enabled used to be + redirected to /.auth/logout, which returned a 404. + """ + print("Testing logout avoids /.auth/logout when Easy Auth headers are absent...") + + app = _build_test_app() + + with patch.dict( + os.environ, + { + "WEBSITE_HOSTNAME": "example-dev.contoso.com", + "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "tenant-id", + }, + clear=False, + ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", False), patch.object( + route_module, "get_settings", Mock(return_value={}) + ): + with app.test_request_context( + "/logout/local", + base_url="https://example-dev.contoso.com", + ): + session["user"] = {"name": "Test User"} + + response = app.view_functions["frontend_authentication.local_logout"]() + + assert response.status_code == 302, f"Expected redirect response, got {response.status_code}" + assert response.headers.get("Location") == "/", ( + f"Unexpected local logout redirect: {response.headers.get('Location')}" + ) + assert "user" not in session, f"Expected Flask session to be cleared, got {dict(session)}" + + print("Logout without Easy Auth headers avoids /.auth/logout") + + +def test_easy_auth_logout_still_used_when_headers_present(): + """Verify Easy Auth logout is preserved wherever Easy Auth genuinely intercepts requests. + + Detection is per request, so a non-production host behind Easy Auth still clears the + upstream platform session instead of leaving it alive. + """ + print("Testing Easy Auth logout is preserved on a non-production host...") + + app = _build_test_app() + + with patch.dict( + os.environ, + { + "WEBSITE_HOSTNAME": "example-dev.contoso.com", + }, + clear=False, + ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", False): + with app.test_request_context( + "/logout/local", + base_url="https://example-dev.contoso.com", + headers={"X-MS-CLIENT-PRINCIPAL-ID": "user-oid"}, + ): + session["user"] = {"name": "Test User"} + + response = app.view_functions["frontend_authentication.local_logout"]() + + assert response.status_code == 302, f"Expected redirect response, got {response.status_code}" + assert response.headers.get("Location") == EXPECTED_EASY_AUTH_LOGOUT, ( + f"Unexpected local logout redirect: {response.headers.get('Location')}" + ) + assert "user" not in session, f"Expected Flask session to be cleared, got {dict(session)}" + + print("Easy Auth logout preserved when platform headers are present") + + +def test_logout_override_disables_easy_auth_logout(): + """Verify DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT keeps logout local. + + This is the documented escape hatch for deployments where Easy Auth is active but + /.auth/* is not routed through to the App Service origin. + """ + print("Testing DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT override...") + + app = _build_test_app() + + with patch.dict( + os.environ, + { + "WEBSITE_HOSTNAME": "example-dev.contoso.com", + }, + clear=False, + ), patch.object(route_module, "DISABLE_APP_SERVICE_EASY_AUTH_LOGOUT", True), patch.object( + route_module, "get_settings", Mock(return_value={}) + ): + with app.test_request_context( + "/logout/local", + base_url="https://example-dev.contoso.com", + headers={"X-MS-CLIENT-PRINCIPAL-ID": "user-oid"}, + ): + session["user"] = {"name": "Test User"} + + response = app.view_functions["frontend_authentication.local_logout"]() + + assert response.status_code == 302, f"Expected redirect response, got {response.status_code}" + assert response.headers.get("Location") == "/", ( + f"Unexpected overridden logout redirect: {response.headers.get('Location')}" + ) + assert "user" not in session, f"Expected Flask session to be cleared, got {dict(session)}" + + print("Override keeps logout on the local path") + + if __name__ == "__main__": tests = [ test_local_logout_uses_app_service_easy_auth_logout, test_full_logout_uses_app_service_easy_auth_logout, + test_logout_skips_easy_auth_when_platform_headers_absent, + test_easy_auth_logout_still_used_when_headers_present, + test_logout_override_disables_easy_auth_logout, ] results = [] diff --git a/functional_tests/test_idle_logout_timeout.py b/functional_tests/test_idle_logout_timeout.py index 10d388ba..0fc47ddd 100644 --- a/functional_tests/test_idle_logout_timeout.py +++ b/functional_tests/test_idle_logout_timeout.py @@ -15,6 +15,7 @@ import traceback from test_support.templates import compose_if_admin_settings +from test_support.versioning import assert_app_version_at_least sys.path.append(os.path.dirname(os.path.abspath(__file__))) @@ -302,12 +303,10 @@ def test_server_idle_timeout_wiring(): has_heartbeat_refresh_call = True assert has_heartbeat_refresh_call, "Missing get_idle_timeout_settings(get_request_settings()) in session_heartbeat" - required_config_markers = [ - "VERSION = \"0.250.004\"" - ] - - missing_config_markers = [marker for marker in required_config_markers if marker not in config_content] - assert not missing_config_markers, f"Missing config markers: {missing_config_markers}" + assert_app_version_at_least( + "0.250.004", + reason="Idle session auto-logout wiring was implemented in 0.250.004.", + ) auth_register_def = _find_top_level_function(auth_tree, "register_route_frontend_authentication") assert auth_register_def is not None, "Missing register_route_frontend_authentication function"