Skip to content
Open
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions dash_auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@
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,
get_url_base,
)


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.

Expand Down Expand Up @@ -47,14 +47,15 @@ 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 = 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
# 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
Expand All @@ -66,7 +67,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"
),
Expand Down
33 changes: 19 additions & 14 deletions dash_auth/oidc_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
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

if TYPE_CHECKING:
from authlib.integrations.flask_client.apps import (
FlaskOAuth1App, FlaskOAuth2App
FlaskOAuth1App,
FlaskOAuth2App,
)


Expand Down Expand Up @@ -62,6 +64,15 @@ def __init__(
callback_route : str, optional
The route for the OIDC redirect URI, it requires a <idp>
placeholder, by default "/oidc/<idp>/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/<idp>/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
Expand Down Expand Up @@ -102,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:
Expand All @@ -116,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
Expand Down Expand Up @@ -175,9 +184,7 @@ 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."""
Expand All @@ -194,9 +201,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):
Expand Down Expand Up @@ -242,7 +247,7 @@ 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 = get_url_base(self.app) or "/"
page = self.logout_page or f"""
<div style="display: flex; flex-direction: column;
gap: 0.75rem; padding: 3rem 5rem;">
Expand All @@ -269,7 +274,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:
Expand All @@ -288,7 +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 "/")
return redirect(get_url_base(self.app) or "/")

def is_authorized(self): # pylint: disable=C0116
"""Check whether ther user is authenticated."""
Expand Down
33 changes: 28 additions & 5 deletions dash_auth/public_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path:path>.{ext}"
Expand All @@ -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.

Expand All @@ -48,11 +67,14 @@ def add_public_routes(app: Dash, routes: list):
"""

public_routes = get_public_routes(app)
url_base = get_url_base(app)

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
Expand All @@ -72,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 "
Expand Down
83 changes: 57 additions & 26 deletions tests/test_basic_auth_integration.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
from dash import Dash, Input, Output, dcc, html
import requests

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"]],
}


def test_ba001_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")
])
@pytest.mark.parametrize(
"kwargs",
[
{},
{"url_base_pathname": "/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.callback(Output("output", "children"), Input("input", "value"))
def update_output(new_value):
Expand All @@ -30,15 +35,25 @@ def update_output(new_value):
add_public_routes(app, ["/user/<user_id>/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

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)
Expand All @@ -60,12 +75,23 @@ 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__)
app.layout = html.Div([
dcc.Input(id="input", value="initial value"),
html.Div(id="output")
])
@pytest.mark.parametrize(
"kwargs",
[
{},
{"url_base_pathname": "/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.callback(
Output("output", "children"),
Expand All @@ -89,7 +115,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
Expand Down
Loading