diff --git a/CHANGELOG.md b/CHANGELOG.md index 0444d3c..92a0198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [Unreleased] - 2025-08-15 +### Fixed +- Fix public routes being protected when passing `url_base_pathname` or `routes_pathname_prefix` to app +- Fix OIDC redirects after login and logout when passing `url_base_pathname` or `routes_pathname_prefix` to app + ## [2.3.0] - 2024-03-18 ### Added - OIDCAuth allows to authenticate via OIDC diff --git a/dash_auth/auth.py b/dash_auth/auth.py index 862bb5e..3a472bc 100644 --- a/dash_auth/auth.py +++ b/dash_auth/auth.py @@ -6,16 +6,15 @@ from flask import request from .public_routes import ( - add_public_routes, get_public_callbacks, get_public_routes + add_public_routes, + get_public_callbacks, + get_public_routes, ) class Auth(ABC): def __init__( - self, - app: Dash, - public_routes: Optional[list] = None, - **obsolete + self, app: Dash, public_routes: Optional[list] = None, **obsolete ): """Auth base class for authentication in Dash. @@ -47,14 +46,19 @@ def _protect(self): @server.before_request def before_request_auth(): - public_routes = get_public_routes(self.app) public_callbacks = get_public_callbacks(self.app) + url_base = ( + self.app.config.get("url_base_pathname", "") + or self.app.config.get("requests_pathname_prefix", "") + or self.app.config.get("routes_pathname_prefix", "") + ) # Handle Dash's callback route: # * Check whether the callback is marked as public # * Check whether the callback is performed on route change in # which case the path should be checked against the public routes - if request.path == "/_dash-update-component": + callback_path = f"{url_base.rstrip('/')}/_dash-update-component" + if request.path == callback_path: body = request.get_json() # Check whether the callback is marked as public @@ -66,7 +70,8 @@ def before_request_auth(): # should be checked against the public routes pathname = next( ( - inp.get("value") for inp in body["inputs"] + inp.get("value") + for inp in body["inputs"] if isinstance(inp, dict) and inp.get("property") == "pathname" ), diff --git a/dash_auth/oidc_auth.py b/dash_auth/oidc_auth.py index 86e6c7e..a1e678a 100644 --- a/dash_auth/oidc_auth.py +++ b/dash_auth/oidc_auth.py @@ -12,7 +12,8 @@ if TYPE_CHECKING: from authlib.integrations.flask_client.apps import ( - FlaskOAuth1App, FlaskOAuth2App + FlaskOAuth1App, + FlaskOAuth2App, ) @@ -175,18 +176,16 @@ def register_provider(self, idp_name: str, **kwargs): ) client_kwargs = kwargs.pop("client_kwargs", {}) client_kwargs.setdefault("scope", "openid email") - self.oauth.register( - idp_name, client_kwargs=client_kwargs, **kwargs - ) + self.oauth.register(idp_name, client_kwargs=client_kwargs, **kwargs) def get_oauth_client(self, idp: str): """Get the OAuth client.""" if idp not in self.oauth._registry: raise ValueError(f"'{idp}' is not a valid registered idp") - client: Union[FlaskOAuth1App, FlaskOAuth2App] = ( - self.oauth.create_client(idp) - ) + client: Union[ + FlaskOAuth1App, FlaskOAuth2App + ] = self.oauth.create_client(idp) return client def get_oauth_kwargs(self, idp: str): @@ -194,9 +193,7 @@ def get_oauth_kwargs(self, idp: str): if idp not in self.oauth._registry: raise ValueError(f"'{idp}' is not a valid registered idp") - kwargs: dict = ( - self.oauth._registry[idp][1] - ) + kwargs: dict = self.oauth._registry[idp][1] return kwargs def _create_redirect_uri(self, idp: str): @@ -242,14 +239,21 @@ def login_request(self, idp: str = None): def logout(self): # pylint: disable=C0116 """Logout the user.""" session.clear() - base_url = self.app.config.get("url_base_pathname") or "/" - page = self.logout_page or f""" + base_url = ( + self.app.config.get("url_base_pathname") + or self.app.config.get("routes_pathname_prefix") + or "/" + ) + page = ( + self.logout_page + or f"""
Logged out successfully
Go back
""" + ) return page def callback(self, idp: str): # pylint: disable=C0116 @@ -269,7 +273,7 @@ def callback(self, idp: str): # pylint: disable=C0116 user = token.get("userinfo") return self.after_logged_in(user, idp, token) - def after_logged_in(self, user: Optional[dict], idp: str, token: dict): + def after_logged_in(self, user: Optional[dict], idp: str, token: dict): """ Post-login actions after successful OIDC authentication. For example, allows to pass custom attributes to the user session: @@ -288,7 +292,11 @@ def after_logged_in(self, user, idp, token): if self.log_signins: logging.info("User %s is logging in.", user.get("email")) - return redirect(self.app.config.get("url_base_pathname") or "/") + return redirect( + self.app.config.get("url_base_pathname") + or self.app.config.get("routes_pathname_prefix") + or "/" + ) def is_authorized(self): # pylint: disable=C0116 """Check whether ther user is authenticated.""" diff --git a/dash_auth/public_routes.py b/dash_auth/public_routes.py index 5c9540c..349645a 100644 --- a/dash_auth/public_routes.py +++ b/dash_auth/public_routes.py @@ -48,11 +48,18 @@ def add_public_routes(app: Dash, routes: list): """ public_routes = get_public_routes(app) + url_base = ( + app.config.get("url_base_pathname", "") + or app.config.get("requests_pathname_prefix", "") + or app.config.get("routes_pathname_prefix", "") + ) if not public_routes.map._rules: routes = BASE_PUBLIC_ROUTES + routes for route in routes: + if url_base and not route.startswith(url_base): + route = url_base.rstrip("/") + route public_routes.map.add(Rule(route)) app.server.config[PUBLIC_ROUTES] = public_routes diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py index 1a6a534..1e5efc7 100644 --- a/tests/test_basic_auth_integration.py +++ b/tests/test_basic_auth_integration.py @@ -1,6 +1,6 @@ from dash import Dash, Input, Output, dcc, html import requests - +import pytest from dash_auth import BasicAuth, add_public_routes, protected @@ -15,8 +15,17 @@ } -def test_ba001_basic_auth_login_flow(dash_br, dash_thread_server): - app = Dash(__name__) +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"url_base_pathname": "/app/"}, + {"routes_pathname_prefix": "/app/"}, + {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"}, + ], +) +def test_ba001_basic_auth_login_flow(dash_br, dash_thread_server, kwargs): + app = Dash(__name__, **kwargs) app.layout = html.Div([ dcc.Input(id="input", value="initial value"), html.Div(id="output") @@ -30,7 +39,12 @@ def update_output(new_value): add_public_routes(app, ["/user//public"]) dash_thread_server(app) - base_url = dash_thread_server.url + path_prefix = ( + app.config.get("url_base_pathname", "") + or app.config.get("requests_pathname_prefix", "") + or app.config.get("routes_pathname_prefix", "") + ) + base_url = dash_thread_server.url + path_prefix def test_failed_views(url): assert requests.get(url).status_code == 401 @@ -60,8 +74,17 @@ def test_successful_views(url): dash_br.wait_for_text_to_equal("#output", "initial value") -def test_ba002_basic_auth_groups(dash_br, dash_thread_server): - app = Dash(__name__) +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"url_base_pathname": "/app/"}, + {"routes_pathname_prefix": "/app/"}, + {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"}, + ], +) +def test_ba002_basic_auth_groups(dash_br, dash_thread_server, kwargs): + app = Dash(__name__, **kwargs) app.layout = html.Div([ dcc.Input(id="input", value="initial value"), html.Div(id="output") @@ -89,7 +112,12 @@ def update_output(new_value): ) dash_thread_server(app) - base_url = dash_thread_server.url + path_prefix = ( + app.config.get("url_base_pathname", "") + or app.config.get("requests_pathname_prefix", "") + or app.config.get("routes_pathname_prefix", "") + ) + base_url = dash_thread_server.url + path_prefix for user, password in TEST_USERS["valid"]: # login using the URL instead of the alert popup diff --git a/tests/test_oidc_auth.py b/tests/test_oidc_auth.py index 5442a67..4c43a3b 100644 --- a/tests/test_oidc_auth.py +++ b/tests/test_oidc_auth.py @@ -1,4 +1,3 @@ -import os from unittest.mock import patch import requests @@ -9,6 +8,7 @@ protected_callback, OIDCAuth, ) +import pytest def valid_authorize_redirect(_, redirect_uri, *args, **kwargs): @@ -27,10 +27,19 @@ def valid_authorize_access_token(*args, **kwargs): } +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"url_base_pathname": "/app/"}, + {"routes_pathname_prefix": "/app/"}, + {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"}, + ], +) @patch("authlib.integrations.flask_client.apps.FlaskOAuth2App.authorize_redirect", valid_authorize_redirect) @patch("authlib.integrations.flask_client.apps.FlaskOAuth2App.authorize_access_token", valid_authorize_access_token) -def test_oa001_oidc_auth_login_flow_success(dash_br, dash_thread_server): - app = Dash(__name__) +def test_oa001_oidc_auth_login_flow_success(dash_br, dash_thread_server, kwargs): + app = Dash(__name__, **kwargs) app.layout = html.Div([ dcc.Input(id="input", value="initial value"), html.Div(id="output1"), @@ -89,7 +98,12 @@ def update_output5(new_value): server_metadata_url="https://idp.com/oidc/2/.well-known/openid-configuration", ) dash_thread_server(app) - base_url = dash_thread_server.url + path_prefix = ( + app.config.get("url_base_pathname", "") + or app.config.get("requests_pathname_prefix", "") + or app.config.get("routes_pathname_prefix", "") + ) + base_url = dash_thread_server.url + path_prefix assert requests.get(base_url).status_code == 200 @@ -101,9 +115,18 @@ def update_output5(new_value): dash_br.wait_for_text_to_equal("#output5", "initial value") +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"url_base_pathname": "/app/"}, + {"routes_pathname_prefix": "/app/"}, + {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"}, + ], +) @patch("authlib.integrations.flask_client.apps.FlaskOAuth2App.authorize_redirect", invalid_authorize_redirect) -def test_oa002_oidc_auth_login_fail(dash_thread_server): - app = Dash(__name__) +def test_oa002_oidc_auth_login_fail(dash_thread_server, kwargs): + app = Dash(__name__, **kwargs) app.layout = html.Div([ dcc.Input(id="input", value="initial value"), html.Div(id="output") @@ -122,7 +145,12 @@ def update_output(new_value): server_metadata_url="https://idp.com/oidc/2/.well-known/openid-configuration", ) dash_thread_server(app) - base_url = dash_thread_server.url + path_prefix = ( + app.config.get("url_base_pathname", "") + or app.config.get("requests_pathname_prefix", "") + or app.config.get("routes_pathname_prefix", "") + ) + base_url = dash_thread_server.url + path_prefix def test_unauthorized(url): r = requests.get(url) @@ -133,13 +161,22 @@ def test_authorized(url): assert requests.get(url).status_code == 200 test_unauthorized(base_url) - test_authorized(os.path.join(base_url, "public")) + test_authorized("/".join([base_url, "public"])) +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"url_base_pathname": "/app/"}, + {"routes_pathname_prefix": "/app/"}, + {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"}, + ], +) @patch("authlib.integrations.flask_client.apps.FlaskOAuth2App.authorize_redirect", valid_authorize_redirect) @patch("authlib.integrations.flask_client.apps.FlaskOAuth2App.authorize_access_token", valid_authorize_access_token) -def test_oa003_oidc_auth_login_several_idp(dash_br, dash_thread_server): - app = Dash(__name__) +def test_oa003_oidc_auth_login_several_idp(dash_br, dash_thread_server, kwargs): + app = Dash(__name__, **kwargs) app.layout = html.Div([ dcc.Input(id="input", value="initial value"), html.Div(id="output1"), @@ -168,21 +205,26 @@ def update_output1(new_value): ) dash_thread_server(app) + path_prefix = ( + app.config.get("url_base_pathname", "") + or app.config.get("requests_pathname_prefix", "") + or app.config.get("routes_pathname_prefix", "") + ) base_url = dash_thread_server.url - + base_url_prefix = (base_url + path_prefix).strip("/") assert requests.get(base_url).status_code == 400 # Login with IDP1 - assert requests.get(os.path.join(base_url, "oidc/idp1/login")).status_code == 200 + assert requests.get(base_url + "/oidc/idp1/login").status_code == 200 # Logout - assert requests.get(os.path.join(base_url, "oidc/logout")).status_code == 200 + assert requests.get(base_url + "/oidc/logout").status_code == 200 assert requests.get(base_url).status_code == 400 # Login with IDP2 - assert requests.get(os.path.join(base_url, "oidc/idp2/login")).status_code == 200 + assert requests.get(base_url + "/oidc/idp2/login").status_code == 200 - dash_br.driver.get(os.path.join(base_url, "oidc/idp2/login")) - dash_br.driver.get(base_url) + dash_br.driver.get(base_url + "/oidc/idp2/login") + dash_br.driver.get(base_url_prefix) dash_br.wait_for_text_to_equal("#output1", "initial value")