diff --git a/application/single_app/config.py b/application/single_app/config.py index 0007e45b..d043daca 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,9 +97,17 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.001" +VERSION = "0.261.002" 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..cfd27ef2 --- /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.261.002** + +## 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.261.002`. +- 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 7746705a..5b3d6269 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,24 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.002)** + +#### 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.261.001)** #### New Features @@ -3795,4 +3813,4 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver --- -Release notes for versions before v0.230.001 have moved to the [archived release notes](/explanation/archive_release_notes/). +Release notes for versions before v0.230.001 have moved to the [archived release notes](/explanation/archive_release_notes/). \ No newline at end of file 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..f832cd1f 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.261.002 +Implemented in: 0.261.002 + +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"