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 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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
From ac2c5256a25f5ba4e6724e8086943e049331a1b4 Mon Sep 17 00:00:00 2001
From: Jonas <119843859+joschrag@users.noreply.github.com>
Date: Tue, 9 Jun 2026 21:57:56 +0200
Subject: [PATCH 08/10] Update usage.py to use secret_key and run instead of
run_server.
---
usage.py | 58 +++++++++++++++++++++++++++-----------------------------
1 file changed, 28 insertions(+), 30 deletions(-)
diff --git a/usage.py b/usage.py
index 72b6e20..9bd76e7 100644
--- a/usage.py
+++ b/usage.py
@@ -2,10 +2,8 @@
import dash_auth
# Keep this out of source code repository - save in a file or a database
-VALID_USERNAME_PASSWORD_PAIRS = {
- 'hello': 'world'
-}
-
+VALID_USERNAME_PASSWORD_PAIRS = {"hello": "world"}
+SECRET = "Test!"
# Authorization function defined by developer
# (can be used instead of VALID_USERNAME_PASSWORD_PAIRS [Example 2 below])
@@ -16,42 +14,42 @@ def authorization_function(username, password):
return False
-external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
+external_stylesheets = ["https://codepen.io/chriddyp/pen/bWLwgP.css"]
app = Dash(__name__, external_stylesheets=external_stylesheets)
# Example 1 (using username/password map)
-auth = dash_auth.BasicAuth(app, VALID_USERNAME_PASSWORD_PAIRS)
+auth = dash_auth.BasicAuth(
+ app, VALID_USERNAME_PASSWORD_PAIRS, secret_key=SECRET
+)
# Example 2 (using authorization function)
# auth = dash_auth.BasicAuth(app, auth_func=authorization_function)
-app.layout = html.Div([
- html.H1('Welcome to the app'),
- html.H3('You are successfully authorized'),
- dcc.Dropdown(
- id='dropdown',
- options=[{'label': i, 'value': i} for i in ['A', 'B']],
- value='A'
- ),
- dcc.Graph(id='graph')
-], className="container")
-
-
-@app.callback(Output('graph', 'figure'), [Input('dropdown', 'value')])
+app.layout = html.Div(
+ [
+ html.H1("Welcome to the app"),
+ html.H3("You are successfully authorized"),
+ dcc.Dropdown(
+ id="dropdown",
+ options=[{"label": i, "value": i} for i in ["A", "B"]],
+ value="A",
+ ),
+ dcc.Graph(id="graph"),
+ ],
+ className="container",
+)
+
+
+@app.callback(Output("graph", "figure"), [Input("dropdown", "value")])
def update_graph(dropdown_value):
return {
- 'layout': {
- 'title': 'Graph of {}'.format(dropdown_value),
- 'margin': {
- 'l': 20,
- 'b': 20,
- 'r': 10,
- 't': 60
- }
+ "layout": {
+ "title": "Graph of {}".format(dropdown_value),
+ "margin": {"l": 20, "b": 20, "r": 10, "t": 60},
},
- 'data': [{'x': [1, 2, 3], 'y': [4, 1, 2]}]
+ "data": [{"x": [1, 2, 3], "y": [4, 1, 2]}],
}
-if __name__ == '__main__':
- app.run_server(debug=True)
+if __name__ == "__main__":
+ app.run(debug=True)
From a7888ef6ffd788764d6578faf847d31a1a9768c0 Mon Sep 17 00:00:00 2001
From: Jonas <119843859+joschrag@users.noreply.github.com>
Date: Tue, 9 Jun 2026 22:02:35 +0200
Subject: [PATCH 09/10] Add shared function to determine correct url prefixes.
---
dash_auth/auth.py | 7 ++-----
dash_auth/oidc_auth.py | 39 ++++++++++++++++++--------------------
dash_auth/public_routes.py | 36 +++++++++++++++++++++++++----------
3 files changed, 46 insertions(+), 36 deletions(-)
diff --git a/dash_auth/auth.py b/dash_auth/auth.py
index 3a472bc..42c2c50 100644
--- a/dash_auth/auth.py
+++ b/dash_auth/auth.py
@@ -9,6 +9,7 @@
add_public_routes,
get_public_callbacks,
get_public_routes,
+ get_url_base,
)
@@ -48,11 +49,7 @@ def _protect(self):
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 = get_url_base(self.app)
# Handle Dash's callback route:
# * Check whether the callback is marked as public
# * Check whether the callback is performed on route change in
diff --git a/dash_auth/oidc_auth.py b/dash_auth/oidc_auth.py
index a1e678a..dbe948d 100644
--- a/dash_auth/oidc_auth.py
+++ b/dash_auth/oidc_auth.py
@@ -7,6 +7,7 @@
from authlib.integrations.base_client import OAuthError
from authlib.integrations.flask_client import OAuth
from dash_auth.auth import Auth
+from dash_auth.public_routes import get_url_base
from flask import Response, redirect, request, session, url_for
from werkzeug.routing import Map, Rule
@@ -63,6 +64,15 @@ def __init__(
callback_route : str, optional
The route for the OIDC redirect URI, it requires a
placeholder, by default "/oidc//callback".
+
+ NOTE: login_route, logout_route, and callback_route are
+ registered directly on the Flask server at the paths given here,
+ regardless of any ``url_base_pathname`` or
+ ``routes_pathname_prefix`` set on the Dash app. If your app is
+ deployed under a prefix (e.g. ``url_base_pathname="/app/"``), the
+ OIDC routes still live at the server root (e.g.
+ ``/oidc//callback``), NOT under the prefix. Configure your
+ IDP's redirect URI accordingly.
idp_selection_route : str, optional
The route for the IDP selection function, by default None
log_signins : bool, optional
@@ -103,8 +113,7 @@ def __init__(
app.server.secret_key = secret_key
if app.server.secret_key is None:
- raise RuntimeError(
- """
+ raise RuntimeError("""
app.server.secret_key is missing.
Generate a secret key in your Python session
with the following commands:
@@ -117,8 +126,7 @@ def __init__(
Note that you should not do this dynamically:
you should create a key and then assign the value of
that key in your code/via a secret.
- """
- )
+ """)
if secure_session:
app.server.config["SESSION_COOKIE_SECURE"] = True
@@ -183,9 +191,9 @@ def get_oauth_client(self, idp: str):
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):
@@ -239,21 +247,14 @@ 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 self.app.config.get("routes_pathname_prefix")
- or "/"
- )
- page = (
- self.logout_page
- or f"""
+ base_url = get_url_base(self.app) or "/"
+ page = self.logout_page or f"""
"""
- )
return page
def callback(self, idp: str): # pylint: disable=C0116
@@ -292,11 +293,7 @@ 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 self.app.config.get("routes_pathname_prefix")
- or "/"
- )
+ return redirect(get_url_base(self.app) 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 349645a..09ac736 100644
--- a/dash_auth/public_routes.py
+++ b/dash_auth/public_routes.py
@@ -6,7 +6,6 @@
from dash import get_app
from werkzeug.routing import Map, MapAdapter, Rule
-
DASH_PUBLIC_ASSETS_EXTENSIONS = "js,css"
BASE_PUBLIC_ROUTES = [
f"/assets/.{ext}"
@@ -25,6 +24,26 @@
PUBLIC_CALLBACKS = "PUBLIC_CALLBACKS"
+def get_url_base(app: Dash) -> str:
+ """Return the URL prefix configured for the Dash app (e.g. '/app/').
+
+ Returns '' when no prefix is configured. Checks url_base_pathname first,
+ then requests_pathname_prefix, then routes_pathname_prefix. In normal Dash
+ usage these three values are always kept in sync by Dash itself; the
+ fallback order only matters in advanced deployments.
+
+ This reads from app.config at call time, so it must be invoked after the
+ Dash app's URL config is fully initialised. In particular, calling
+ add_public_routes() before url_base_pathname is set on the app will store
+ routes without the prefix and they will never match at request time.
+ """
+ return (
+ app.config.get("url_base_pathname")
+ or app.config.get("routes_pathname_prefix")
+ or ""
+ )
+
+
def add_public_routes(app: Dash, routes: list):
"""Add routes to the public routes list.
@@ -48,11 +67,7 @@ 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", "")
- )
+ url_base = get_url_base(app)
if not public_routes.map._rules:
routes = BASE_PUBLIC_ROUTES + routes
@@ -79,16 +94,17 @@ def decorator(func):
wrapped_func = callback(*callback_args, **callback_kwargs)(func)
callback_id = next(
(
- k for k, v in GLOBAL_CALLBACK_MAP.items()
+ k
+ for k, v in GLOBAL_CALLBACK_MAP.items()
if inspect.getsource(v["callback"]) == inspect.getsource(func)
),
None,
)
try:
app = get_app()
- app.server.config[PUBLIC_CALLBACKS] = (
- get_public_callbacks(app) + [callback_id]
- )
+ app.server.config[PUBLIC_CALLBACKS] = get_public_callbacks(app) + [
+ callback_id
+ ]
except Exception:
print(
"Could not set up the public callback as the Dash object "
From c64b0bd06cd4fe1cf89950ae46d776bfa726d35e Mon Sep 17 00:00:00 2001
From: Jonas <119843859+joschrag@users.noreply.github.com>
Date: Tue, 9 Jun 2026 22:14:00 +0200
Subject: [PATCH 10/10] Black formatting and adding a second url_base_pathname
case.
---
tests/test_basic_auth_integration.py | 49 ++++----
.../test_basic_auth_integration_auth_func.py | 34 +++---
tests/test_group_protection.py | 26 ++++-
tests/test_oidc_auth.py | 110 ++++++++++++------
4 files changed, 135 insertions(+), 84 deletions(-)
diff --git a/tests/test_basic_auth_integration.py b/tests/test_basic_auth_integration.py
index 1e5efc7..572d138 100644
--- a/tests/test_basic_auth_integration.py
+++ b/tests/test_basic_auth_integration.py
@@ -3,15 +3,9 @@
import pytest
from dash_auth import BasicAuth, add_public_routes, protected
-
TEST_USERS = {
- "valid": [
- ["hello", "world"],
- ["hello2", "wo:rld"]
- ],
- "invalid": [
- ["hello", "password"]
- ],
+ "valid": [["hello", "world"], ["hello2", "wo:rld"]],
+ "invalid": [["hello", "password"]],
}
@@ -20,16 +14,18 @@
[
{},
{"url_base_pathname": "/app/"},
- {"routes_pathname_prefix": "/app/"},
- {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"},
+ {"url_base_pathname": "/sub/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")
- ])
+ app.layout = html.Div(
+ [dcc.Input(id="input", value="initial value"), html.Div(id="output")]
+ )
@app.callback(Output("output", "children"), Input("input", "value"))
def update_output(new_value):
@@ -50,9 +46,14 @@ def test_failed_views(url):
assert requests.get(url).status_code == 401
def test_successful_views(url):
- assert requests.get(url.strip("/") + "/_dash-layout").status_code == 200
- assert requests.get(url.strip("/") + "/home").status_code == 200
- assert requests.get(url.strip("/") + "/user/john123/public").status_code == 200
+ assert (
+ requests.get(url.rstrip("/") + "/_dash-layout").status_code == 200
+ )
+ assert requests.get(url.rstrip("/") + "/home").status_code == 200
+ assert (
+ requests.get(url.rstrip("/") + "/user/john123/public").status_code
+ == 200
+ )
test_failed_views(base_url)
test_successful_views(base_url)
@@ -79,16 +80,18 @@ def test_successful_views(url):
[
{},
{"url_base_pathname": "/app/"},
- {"routes_pathname_prefix": "/app/"},
- {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"},
+ {"url_base_pathname": "/sub/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")
- ])
+ app.layout = html.Div(
+ [dcc.Input(id="input", value="initial value"), html.Div(id="output")]
+ )
@app.callback(
Output("output", "children"),
diff --git a/tests/test_basic_auth_integration_auth_func.py b/tests/test_basic_auth_integration_auth_func.py
index 653770e..364d9a9 100644
--- a/tests/test_basic_auth_integration_auth_func.py
+++ b/tests/test_basic_auth_integration_auth_func.py
@@ -5,13 +5,8 @@
from dash_auth import basic_auth
TEST_USERS = {
- "valid": [
- ["hello", "world"],
- ["hello2", "wo:rld"]
- ],
- "invalid": [
- ["hello", "password"]
- ],
+ "valid": [["hello", "world"], ["hello2", "wo:rld"]],
+ "invalid": [["hello", "password"]],
}
@@ -25,10 +20,9 @@ def auth_function(username, password):
def test_ba002_basic_auth_login_flow(dash_br, dash_thread_server):
app = Dash(__name__)
- app.layout = html.Div([
- dcc.Input(id="input", value="initial value"),
- html.Div(id="output")
- ])
+ app.layout = html.Div(
+ [dcc.Input(id="input", value="initial value"), html.Div(id="output")]
+ )
@app.callback(Output("output", "children"), Input("input", "value"))
def update_output(new_value):
@@ -41,7 +35,9 @@ def update_output(new_value):
def test_failed_views(url):
assert requests.get(url).status_code == 401
- assert requests.get(url.strip("/") + "/_dash-layout").status_code == 401
+ assert (
+ requests.get(url.strip("/") + "/_dash-layout").status_code == 401
+ )
test_failed_views(base_url)
@@ -64,10 +60,9 @@ def test_failed_views(url):
# Test incorrect initialization of BasicAuth
def both_dict_and_func(dash_br, dash_thread_server):
app = Dash(__name__)
- app.layout = html.Div([
- dcc.Input(id="input", value="initial value"),
- html.Div(id="output")
- ])
+ app.layout = html.Div(
+ [dcc.Input(id="input", value="initial value"), html.Div(id="output")]
+ )
basic_auth.BasicAuth(app, TEST_USERS["valid"], auth_func=auth_function)
return True
@@ -75,10 +70,9 @@ def both_dict_and_func(dash_br, dash_thread_server):
def both_no_auth_func_or_dict(dash_br, dash_thread_server):
app = Dash(__name__)
- app.layout = html.Div([
- dcc.Input(id="input", value="initial value"),
- html.Div(id="output")
- ])
+ app.layout = html.Div(
+ [dcc.Input(id="input", value="initial value"), html.Div(id="output")]
+ )
basic_auth.BasicAuth(app)
return True
diff --git a/tests/test_group_protection.py b/tests/test_group_protection.py
index f8be30d..4da9de3 100644
--- a/tests/test_group_protection.py
+++ b/tests/test_group_protection.py
@@ -6,22 +6,34 @@ def test_gp001_list_groups():
app = Flask(__name__)
app.secret_key = "Test!"
with app.test_request_context("/", method="GET"):
- session["user"] = {"email": "a.b@mail.com", "groups": ["default"], "tenant": "ABC"}
+ session["user"] = {
+ "email": "a.b@mail.com",
+ "groups": ["default"],
+ "tenant": "ABC",
+ }
assert list_groups() == ["default"]
- assert list_groups(groups_key="tenant", groups_str_split=",") == ["ABC"]
+ assert list_groups(groups_key="tenant", groups_str_split=",") == [
+ "ABC"
+ ]
def test_gp002_check_groups():
app = Flask(__name__)
app.secret_key = "Test!"
with app.test_request_context("/", method="GET"):
- session["user"] = {"email": "a.b@mail.com", "groups": ["default"], "tenant": "ABC"}
+ session["user"] = {
+ "email": "a.b@mail.com",
+ "groups": ["default"],
+ "tenant": "ABC",
+ }
assert check_groups(["default"]) is True
assert check_groups(["other"]) is False
assert check_groups(["default", "other"]) is True
assert check_groups(["other", "default"], check_type="all_of") is False
assert check_groups(["default"], check_type="all_of") is True
- assert check_groups(["other", "default"], check_type="none_of") is False
+ assert (
+ check_groups(["other", "default"], check_type="none_of") is False
+ )
assert check_groups(["other"], check_type="none_of") is True
@@ -33,7 +45,11 @@ def func():
return "success"
with app.test_request_context("/", method="GET"):
- session["user"] = {"email": "a.b@mail.com", "groups": ["default"], "tenant": "ABC"}
+ session["user"] = {
+ "email": "a.b@mail.com",
+ "groups": ["default"],
+ "tenant": "ABC",
+ }
f0 = protected(
unauthenticated_output="unauthenticated",
missing_permissions_output="forbidden",
diff --git a/tests/test_oidc_auth.py b/tests/test_oidc_auth.py
index 4c43a3b..afda02f 100644
--- a/tests/test_oidc_auth.py
+++ b/tests/test_oidc_auth.py
@@ -10,6 +10,9 @@
)
import pytest
+_FLASK_OAUTH_MODULE = "authlib.integrations.flask_client.apps.FlaskOAuth2App"
+_METADATA_URL = "https://idp2.com/oidc/2/.well-known/openid-configuration"
+
def valid_authorize_redirect(_, redirect_uri, *args, **kwargs):
return redirect("/" + redirect_uri.split("/", maxsplit=3)[-1])
@@ -17,7 +20,9 @@ def valid_authorize_redirect(_, redirect_uri, *args, **kwargs):
def invalid_authorize_redirect(_, redirect_uri, *args, **kwargs):
base_url = "/" + redirect_uri.split("/", maxsplit=3)[-1]
- return redirect(f"{base_url}?error=Unauthorized&error_description=something went wrong")
+ return redirect(
+ f"{base_url}?error=Unauthorized&error_description=something went wrong"
+ )
def valid_authorize_access_token(*args, **kwargs):
@@ -32,22 +37,35 @@ def valid_authorize_access_token(*args, **kwargs):
[
{},
{"url_base_pathname": "/app/"},
- {"routes_pathname_prefix": "/app/"},
- {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"},
+ {"url_base_pathname": "/sub/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, kwargs):
+@patch(
+ f"{_FLASK_OAUTH_MODULE}.authorize_redirect",
+ valid_authorize_redirect,
+)
+@patch(
+ f"{_FLASK_OAUTH_MODULE}.authorize_access_token",
+ valid_authorize_access_token,
+)
+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"),
- html.Div(id="output2"),
- html.Div("static", id="output3"),
- html.Div("static", id="output4"),
- html.Div("not static", id="output5"),
- ])
+ app.layout = html.Div(
+ [
+ dcc.Input(id="input", value="initial value"),
+ html.Div(id="output1"),
+ html.Div(id="output2"),
+ html.Div("static", id="output3"),
+ html.Div("static", id="output4"),
+ html.Div("not static", id="output5"),
+ ]
+ )
@app.callback(Output("output1", "children"), Input("input", "value"))
def update_output1(new_value):
@@ -95,7 +113,7 @@ def update_output5(new_value):
token_endpoint_auth_method="client_secret_post",
client_id="",
client_secret="",
- server_metadata_url="https://idp.com/oidc/2/.well-known/openid-configuration",
+ server_metadata_url=_METADATA_URL,
)
dash_thread_server(app)
path_prefix = (
@@ -120,17 +138,22 @@ def update_output5(new_value):
[
{},
{"url_base_pathname": "/app/"},
- {"routes_pathname_prefix": "/app/"},
- {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"},
+ {"url_base_pathname": "/sub/app/"},
+ {
+ "routes_pathname_prefix": "/app/",
+ "requests_pathname_prefix": "/app/",
+ },
],
)
-@patch("authlib.integrations.flask_client.apps.FlaskOAuth2App.authorize_redirect", invalid_authorize_redirect)
+@patch(
+ f"{_FLASK_OAUTH_MODULE}.authorize_redirect",
+ invalid_authorize_redirect,
+)
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")
- ])
+ app.layout = html.Div(
+ [dcc.Input(id="input", value="initial value"), html.Div(id="output")]
+ )
@app.callback(Output("output", "children"), Input("input", "value"))
def update_output(new_value):
@@ -142,7 +165,7 @@ def update_output(new_value):
token_endpoint_auth_method="client_secret_post",
client_id="",
client_secret="",
- server_metadata_url="https://idp.com/oidc/2/.well-known/openid-configuration",
+ server_metadata_url=_METADATA_URL,
)
dash_thread_server(app)
path_prefix = (
@@ -161,7 +184,7 @@ def test_authorized(url):
assert requests.get(url).status_code == 200
test_unauthorized(base_url)
- test_authorized("/".join([base_url, "public"]))
+ test_authorized(base_url.rstrip("/") + "/public")
@pytest.mark.parametrize(
@@ -169,18 +192,31 @@ def test_authorized(url):
[
{},
{"url_base_pathname": "/app/"},
- {"routes_pathname_prefix": "/app/"},
- {"routes_pathname_prefix": "/app/", "requests_pathname_prefix": "/app/"},
+ {"url_base_pathname": "/sub/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, kwargs):
+@patch(
+ f"{_FLASK_OAUTH_MODULE}.authorize_redirect",
+ valid_authorize_redirect,
+)
+@patch(
+ f"{_FLASK_OAUTH_MODULE}.authorize_access_token",
+ valid_authorize_access_token,
+)
+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"),
- ])
+ app.layout = html.Div(
+ [
+ dcc.Input(id="input", value="initial value"),
+ html.Div(id="output1"),
+ ]
+ )
@app.callback(Output("output1", "children"), Input("input", "value"))
def update_output1(new_value):
@@ -193,7 +229,7 @@ def update_output1(new_value):
token_endpoint_auth_method="client_secret_post",
client_id="",
client_secret="",
- server_metadata_url="https://idp.com/oidc/2/.well-known/openid-configuration",
+ server_metadata_url=_METADATA_URL,
)
# Add a second provider
oidc.register_provider(
@@ -201,7 +237,7 @@ def update_output1(new_value):
token_endpoint_auth_method="client_secret_post",
client_id="",
client_secret="",
- server_metadata_url="https://idp2.com/oidc/2/.well-known/openid-configuration",
+ server_metadata_url=_METADATA_URL,
)
dash_thread_server(app)
@@ -211,8 +247,10 @@ def update_output1(new_value):
or app.config.get("routes_pathname_prefix", "")
)
base_url = dash_thread_server.url
- base_url_prefix = (base_url + path_prefix).strip("/")
+ base_url_prefix = (base_url + path_prefix).rstrip("/")
assert requests.get(base_url).status_code == 400
+ # Also verify that the prefixed app URL itself requires auth selection.
+ assert requests.get(base_url_prefix).status_code == 400
# Login with IDP1
assert requests.get(base_url + "/oidc/idp1/login").status_code == 200