From 3ae1e7404372051a4e2590c3e93d2c9fed2dfc1f Mon Sep 17 00:00:00 2001
From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com>
Date: Fri, 15 Aug 2025 22:34:40 +0200
Subject: [PATCH 1/7] Consider URL prefix when adding public routes.
---
dash_auth/public_routes.py | 7 +++++++
1 file changed, 7 insertions(+)
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
From 515dd7c5f62e99c9af223a321a10faf1e2243d1f Mon Sep 17 00:00:00 2001
From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com>
Date: Fri, 15 Aug 2025 22:36:18 +0200
Subject: [PATCH 2/7] Consider URL prefix when checking for public routes and
callbacks.
---
dash_auth/auth.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/dash_auth/auth.py b/dash_auth/auth.py
index 862bb5e..ce9a223 100644
--- a/dash_auth/auth.py
+++ b/dash_auth/auth.py
@@ -50,11 +50,15 @@ 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
From 727cf0bd9505cc660392af6a65dc201603abbab4 Mon Sep 17 00:00:00 2001
From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com>
Date: Fri, 15 Aug 2025 22:37:04 +0200
Subject: [PATCH 3/7] Consider URL prefix when redirecting from OIDC login and
logout.
---
dash_auth/oidc_auth.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/dash_auth/oidc_auth.py b/dash_auth/oidc_auth.py
index 86e6c7e..ad85ff9 100644
--- a/dash_auth/oidc_auth.py
+++ b/dash_auth/oidc_auth.py
@@ -242,7 +242,9 @@ 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 "/"
+ base_url = (self.app.config.get("url_base_pathname")
+ or self.app.config.get("routes_pathname_prefix")
+ or "/")
page = self.logout_page or f"""
@@ -288,7 +290,9 @@ 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."""
From 76d616633be5104f0afce8973e12084c6424d783 Mon Sep 17 00:00:00 2001
From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com>
Date: Fri, 15 Aug 2025 22:39:03 +0200
Subject: [PATCH 4/7] Parametrize unit tests to work with and without
'url_base_pathname' and 'routes_pathname_prefix'.
---
tests/test_basic_auth_integration.py | 42 +++++++++++++---
tests/test_oidc_auth.py | 73 ++++++++++++++++++++++------
2 files changed, 93 insertions(+), 22 deletions(-)
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..e9ee97e 100644
--- a/tests/test_oidc_auth.py
+++ b/tests/test_oidc_auth.py
@@ -9,6 +9,7 @@
protected_callback,
OIDCAuth,
)
+import pytest
def valid_authorize_redirect(_, redirect_uri, *args, **kwargs):
@@ -27,10 +28,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 +99,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 +116,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 +146,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 +162,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 +206,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")
From 524f12ff5ee2955b5670875dbb68531b727076d6 Mon Sep 17 00:00:00 2001
From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com>
Date: Fri, 15 Aug 2025 22:51:16 +0200
Subject: [PATCH 5/7] Add fixes for 'url_base_pathname' and
'routes_pathname_prefix' to changelog.
---
CHANGELOG.md | 5 +++++
1 file changed, 5 insertions(+)
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
From f64dceebc29e199afe3a496c00fa503c3e145a7e Mon Sep 17 00:00:00 2001
From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com>
Date: Fri, 15 Aug 2025 23:09:49 +0200
Subject: [PATCH 6/7] Flake8 fixes.
---
dash_auth/auth.py | 21 +++++++++++----------
dash_auth/oidc_auth.py | 36 ++++++++++++++++++++----------------
2 files changed, 31 insertions(+), 26 deletions(-)
diff --git a/dash_auth/auth.py b/dash_auth/auth.py
index ce9a223..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,12 +46,13 @@ 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",""))
+ 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
@@ -70,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 ad85ff9..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,16 +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")
+ base_url = (
+ self.app.config.get("url_base_pathname")
or self.app.config.get("routes_pathname_prefix")
- or "/")
- page = self.logout_page or f"""
+ or "/"
+ )
+ page = (
+ self.logout_page
+ or f"""
"""
+ )
return page
def callback(self, idp: str): # pylint: disable=C0116
@@ -271,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:
@@ -290,9 +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")
+ return redirect(
+ self.app.config.get("url_base_pathname")
or self.app.config.get("routes_pathname_prefix")
- or "/")
+ or "/"
+ )
def is_authorized(self): # pylint: disable=C0116
"""Check whether ther user is authenticated."""
From f5f19e91b07acee9dba34eece215ac7f073ba706 Mon Sep 17 00:00:00 2001
From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com>
Date: Fri, 15 Aug 2025 23:10:40 +0200
Subject: [PATCH 7/7] Remove unused import.
---
tests/test_oidc_auth.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/tests/test_oidc_auth.py b/tests/test_oidc_auth.py
index e9ee97e..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