From 88ea6a75a111019731d4d7d4f0e8158a80543528 Mon Sep 17 00:00:00 2001 From: Chad Palmer Date: Thu, 20 Aug 2026 01:03:54 +0000 Subject: [PATCH] fix-logout-route - fixed logout 404 error in dev environment. --- application/single_app/config.py | 2 +- .../route_frontend_authentication.py | 4 + ...VELOPMENT_LOGOUT_EASY_AUTH_REDIRECT_FIX.md | 24 +++ .../test_app_service_easy_auth_logout.py | 157 ++++++++++++++++-- 4 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 docs/explanation/fixes/DEVELOPMENT_LOGOUT_EASY_AUTH_REDIRECT_FIX.md diff --git a/application/single_app/config.py b/application/single_app/config.py index 5393dc20a..6abbbcb76 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,7 +96,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.224" +VERSION = "0.250.225" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/route_frontend_authentication.py b/application/single_app/route_frontend_authentication.py index c07b51519..51cba1e72 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 IS_DEVELOPMENT 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, @@ -43,6 +44,9 @@ 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.""" + if IS_DEVELOPMENT: + return False + if not os.getenv('WEBSITE_HOSTNAME'): return False diff --git a/docs/explanation/fixes/DEVELOPMENT_LOGOUT_EASY_AUTH_REDIRECT_FIX.md b/docs/explanation/fixes/DEVELOPMENT_LOGOUT_EASY_AUTH_REDIRECT_FIX.md new file mode 100644 index 000000000..a92933ee3 --- /dev/null +++ b/docs/explanation/fixes/DEVELOPMENT_LOGOUT_EASY_AUTH_REDIRECT_FIX.md @@ -0,0 +1,24 @@ +# Development Logout Easy Auth Redirect Fix + +Fixed/Implemented in version: **0.250.225** + +## Issue Description + +In the development environment, user-initiated logout and idle-timeout logout could redirect to `/.auth/logout?post_logout_redirect_uri=%2Flogin`. That platform Easy Auth URL returned a 404 when the development deployment was not actually serving App Service Easy Auth logout endpoints. + +## Root Cause Analysis + +The logout route detected Azure hosting variables and Easy Auth-related signals, then routed local logout through `/.auth/logout`. Development deployments can still expose those hosting signals even when the Easy Auth endpoint is unavailable for the current custom-domain path. + +## Technical Details + +- Modified `application/single_app/route_frontend_authentication.py` so Easy Auth logout routing is skipped when `IS_DEVELOPMENT` is enabled. +- Preserved the existing Easy Auth logout path for non-development Azure App Service deployments with Easy Auth signals. +- Updated `application/single_app/config.py` to version `0.250.225`. +- Added regression coverage in `functional_tests/test_app_service_easy_auth_logout.py` for the development-mode fallback. + +## Validation + +- Ran `functional_tests/test_app_service_easy_auth_logout.py`. +- Confirmed production-style Easy Auth local and full logout still redirect through `/.auth/logout`. +- Confirmed development-mode local logout avoids `/.auth/logout` and redirects to the local app index after clearing the Flask session. \ No newline at end of file diff --git a/functional_tests/test_app_service_easy_auth_logout.py b/functional_tests/test_app_service_easy_auth_logout.py index 569202a87..c3acc0452 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 +Version: 0.250.225 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. +the Flask login flow, while development-mode deployments avoid a missing +/.auth/logout platform endpoint. """ 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, "IS_DEVELOPMENT", 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, "IS_DEVELOPMENT", 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,43 @@ def test_full_logout_uses_app_service_easy_auth_logout(): print("App Service Easy Auth full logout redirects through /.auth/logout") +def test_development_mode_does_not_use_app_service_easy_auth_logout(): + """Verify development mode skips Easy Auth logout even when Azure hosting variables exist.""" + print("Testing development-mode logout avoids App Service Easy Auth redirect...") + + app = _build_test_app() + + with patch.dict( + os.environ, + { + "WEBSITE_HOSTNAME": "oigchat-dev.dhs-oig.gov", + "WEBSITE_AUTH_AAD_ALLOWED_TENANTS": "tenant-id", + }, + clear=False, + ), patch.object(route_module, "IS_DEVELOPMENT", True), patch.object(route_module, "get_settings", Mock(return_value={})): + with app.test_request_context( + "/logout/local", + base_url="https://oigchat-dev.dhs-oig.gov", + 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 development local logout redirect: {response.headers.get('Location')}" + ) + assert "user" not in session, f"Expected Flask session to be cleared, got {dict(session)}" + + print("Development-mode local logout avoids /.auth/logout") + + if __name__ == "__main__": tests = [ test_local_logout_uses_app_service_easy_auth_logout, test_full_logout_uses_app_service_easy_auth_logout, + test_development_mode_does_not_use_app_service_easy_auth_logout, ] results = []