Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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).

## [1.2.1] - 2026-06-17

### Fixed
- `BasicAuth` now returns a `401` login response instead of a `500` server error when the `Authorization` header is missing or malformed
- `BasicAuth` password verification uses a constant-time comparison, removing a timing side channel during credential checks

## [1.2.0] - 2026-06-16

### Added
Expand Down
22 changes: 9 additions & 13 deletions dash_auth_async/__init__.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
from .public_routes import add_public_routes, public_callback
from .basic_auth import BasicAuth
from .group_protection import list_groups, check_groups, protected, protected_callback
"""Authentication and authorization for Dash apps on sync and async backends."""

# oidc auth requires authlib, install with `pip install dash-auth[oidc]`
try:
from .oidc_auth import OIDCAuth, get_oauth
except ModuleNotFoundError:
pass
from ._optional import OIDCAuth, get_oauth
from .basic_auth import BasicAuth
from .group_protection import check_groups, list_groups, protected, protected_callback
from .public_routes import add_public_routes, public_callback
from .version import __version__


__all__ = [
"BasicAuth",
"OIDCAuth",
"__version__",
"add_public_routes",
"check_groups",
"list_groups",
"get_oauth",
"list_groups",
"protected",
"protected_callback",
"public_callback",
"BasicAuth",
"OIDCAuth",
"__version__",
]
24 changes: 24 additions & 0 deletions dash_auth_async/_optional.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Optional re-exports whose dependencies are not always installed.

``OIDCAuth`` and ``get_oauth`` require authlib
(``pip install dash-auth-async[oidc]``). When it is missing they resolve to
``None`` at runtime so importing the package never fails; attempting to use
``OIDCAuth`` without authlib then raises a clear error at the call site.

This logic lives here, rather than in ``__init__``, so the package's
``__init__`` stays a pure re-export facade. Type-checkers see the real symbols
(authlib assumed present); the runtime ``None`` fallback is invisible to them.
"""

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .oidc_auth import OIDCAuth, get_oauth
else:
try:
from .oidc_auth import OIDCAuth, get_oauth
except ModuleNotFoundError:
OIDCAuth = None
get_oauth = None

__all__ = ["OIDCAuth", "get_oauth"]
50 changes: 36 additions & 14 deletions dash_auth_async/auth.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from __future__ import absolute_import
"""Framework-agnostic authentication base class for Dash."""

from abc import ABC, abstractmethod
from typing import Optional

from dash import Dash
from .backends import Backend, detect_backend, set_active_backend

from .backends import Backend, detect_backend, set_active_backend
from .public_routes import (
add_public_routes,
get_public_callbacks,
Expand All @@ -15,20 +15,24 @@


class Auth(ABC):
"""Base class holding the framework-agnostic auth decision logic."""

def __init__(
self,
app: Dash,
public_routes: Optional[list] = None,
backend: Optional[Backend] = None,
public_routes: list | None = None,
backend: Backend | None = None,
**obsolete,
):
"""Auth base class for authentication in Dash.

:param app: Dash app
:param public_routes: list of public routes, routes should follow the
Flask route syntax
"""

Raises:
TypeError: if any deprecated/unexpected keyword argument is passed.
"""
# Deprecated arguments
if obsolete:
raise TypeError(f"Auth got unexpected keyword arguments: {list(obsolete)}")
Expand All @@ -43,14 +47,28 @@ def __init__(

@property
def request(self):
"""The active backend's request proxy.

Returns:
The framework-specific request object for the current request.
"""
return self.backend.request

@property
def session(self):
"""The active backend's session proxy.

Returns:
The framework-specific session mapping for the current request.
"""
return self.backend.session

def _callback_path(self) -> str:
"""Path of Dash's callback route, including any URL base prefix."""
"""Path of Dash's callback route, including any URL base prefix.

Returns:
The fully-qualified ``_dash-update-component`` path.
"""
url_base = get_url_base(self.app)
return f"{url_base.rstrip('/')}/_dash-update-component"

Expand All @@ -67,14 +85,16 @@ def _protect(self):
self._authorize,
)

def _authorize(self, path: str, body: Optional[dict]):
def _authorize(self, path: str, body: dict | None):
"""Decide whether a request may proceed.

Pure decision logic shared by all backends: receives the request
path and the parsed JSON body (only provided for the Dash callback
route). Returns None to allow the request, or a login response.
"""
route).

Returns:
None to allow the request, or a login response to reject it.
"""
public_routes = get_public_routes(self.app)
public_callbacks = get_public_callbacks(self.app)
if path == self._callback_path():
Expand Down Expand Up @@ -109,7 +129,7 @@ def _authorize(self, path: str, body: Optional[dict]):
# Otherwise, ask the user to log in
return self.login_request()

def authorize_ws(self, payload: Optional[dict], user: Optional[dict]) -> bool:
def authorize_ws(self, payload: dict | None, user: dict | None) -> bool:
"""Decide whether a WebSocket callback_request may run.

Mirrors ``_authorize``'s public checks but, because there is no
Expand All @@ -120,7 +140,9 @@ def authorize_ws(self, payload: Optional[dict], user: Optional[dict]) -> bool:
:param payload: the callback payload (same shape as the HTTP
``_dash-update-component`` body: ``output``, ``inputs``, ...)
:param user: ``session["user"]`` or ``None``
:return: True to allow the callback, False to reject the connection

Returns:
True to allow the callback, False to reject the connection.
"""
payload = payload or {}
public_callbacks = get_public_callbacks(self.app)
Expand All @@ -143,8 +165,8 @@ def authorize_ws(self, payload: Optional[dict], user: Optional[dict]) -> bool:

@abstractmethod
def is_authorized(self):
pass
"""Return whether the current request is authenticated."""

@abstractmethod
def login_request(self):
pass
"""Return the response that challenges an unauthenticated client."""
106 changes: 92 additions & 14 deletions dash_auth_async/backends.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Framework adapters isolating Flask/Quart-specific request and session I/O."""

from __future__ import annotations

import inspect
from abc import ABC, abstractmethod
from typing import Any, Callable, MutableMapping, Optional
from collections.abc import Callable, MutableMapping
from typing import Any

import flask

Expand Down Expand Up @@ -35,7 +38,7 @@ def register_auth_hook(
self,
server,
needs_body: Callable[[str], bool],
decide: Callable[[str, Optional[dict]], Any],
decide: Callable[[str, dict | None], Any],
) -> None:
"""Register a before-request auth hook on the server.

Expand All @@ -48,28 +51,52 @@ def register_auth_hook(
"""

@abstractmethod
def url_for(self, endpoint: str, **values) -> str: ...
def url_for(self, endpoint: str, **values) -> str:
"""Build a URL for the given endpoint on this backend."""

@abstractmethod
def redirect(self, location: str) -> Any: ...
def redirect(self, location: str) -> Any:
"""Return a redirect response to the given location."""


class FlaskBackend(Backend):
"""Backend adapter for a Flask server."""

# Properties, not class attributes: ABCMeta probes every namespace
# value for __isabstractmethod__ at class creation, which unwraps
# the context-local proxies outside any request and raises.
@property
def request(self) -> Any:
"""Flask's request context-local proxy.

Returns:
The Flask request proxy.
"""
return flask.request

@property
def session(self) -> MutableMapping:
"""Flask's session mapping.

Returns:
The Flask session proxy.
"""
return flask.session

def has_request_context(self) -> bool:
# The methods below are thin adapters over module-level framework
# functions; they must stay instance methods to satisfy the Backend
# interface, so PLR6301 (no-self-use) is suppressed on each.
def has_request_context(self) -> bool: # noqa: PLR6301
"""Whether a Flask request context is currently active.

Returns:
True if a request context is active.
"""
return flask.has_request_context()

def register_auth_hook(self, server, needs_body, decide) -> None:
def register_auth_hook(self, server, needs_body, decide) -> None: # noqa: PLR6301
"""Register the before-request auth hook on a Flask server."""

@server.before_request
def before_request_auth():
body = (
Expand All @@ -79,15 +106,32 @@ def before_request_auth():
)
return decide(flask.request.path, body)

def url_for(self, endpoint: str, **values) -> str:
def url_for(self, endpoint: str, **values) -> str: # noqa: PLR6301
"""Build a URL for a Flask endpoint.

Returns:
The URL string for ``endpoint``.
"""
return flask.url_for(endpoint, **values)

def redirect(self, location: str) -> Any:
def redirect(self, location: str) -> Any: # noqa: PLR6301
"""Build a Flask redirect response to ``location``.

Returns:
A Flask redirect response.
"""
return flask.redirect(location)


class QuartBackend(Backend):
"""Backend adapter for a Quart (async) server."""

def __init__(self) -> None:
"""Create the Quart backend, requiring the optional ``quart`` extra.

Raises:
ImportError: if Quart is not installed.
"""
if quart is None:
raise ImportError(
"Quart is not installed. Please install it with `pip install quart` "
Expand All @@ -96,16 +140,40 @@ def __init__(self) -> None:

@property
def request(self) -> Any:
"""Quart's request context-local proxy.

Returns:
The Quart request proxy.
"""
return quart.request

@property
def session(self) -> MutableMapping:
"""Quart's session mapping.

Returns:
The Quart session proxy.
"""
return quart.session

def has_request_context(self) -> bool:
# The methods below are thin adapters over module-level framework
# functions; they must stay instance methods to satisfy the Backend
# interface, so PLR6301 (no-self-use) is suppressed on each.
def has_request_context(self) -> bool: # noqa: PLR6301
"""Whether a Quart request context is currently active.

Returns:
True if a request context is active.
"""
return quart.has_request_context()

def register_auth_hook(self, server, needs_body, decide) -> None:
def register_auth_hook(self, server, needs_body, decide) -> None: # noqa: PLR6301
"""Register the before-request auth hook on a Quart server.

Awaits both the request body and the (possibly coroutine) decision so
async auth logic is preserved.
"""

@server.before_request
async def before_request_auth():
body = (
Expand All @@ -118,10 +186,20 @@ async def before_request_auth():
return await result
return result

def url_for(self, endpoint: str, **values) -> str:
def url_for(self, endpoint: str, **values) -> str: # noqa: PLR6301
"""Build a URL for a Quart endpoint.

Returns:
The URL string for ``endpoint``.
"""
return quart.url_for(endpoint, **values)

def redirect(self, location: str) -> Any:
def redirect(self, location: str) -> Any: # noqa: PLR6301
"""Build a Quart redirect response to ``location``.

Returns:
A Quart redirect response.
"""
return quart.redirect(location)


Expand All @@ -141,7 +219,7 @@ def detect_backend(server: Any) -> Backend:


# One backend per process, matching how Dash apps are deployed.
_active_backend: Optional[Backend] = None
_active_backend: Backend | None = None
_DEFAULT_BACKEND = FlaskBackend()


Expand All @@ -151,7 +229,7 @@ def set_active_backend(backend: Backend) -> None:
Called by Auth.__init__; group_protection functions run inside Dash
callbacks with no auth instance in scope and look the backend up here.
"""
global _active_backend
global _active_backend # noqa: PLW0603 — one backend per process, by design
_active_backend = backend


Expand Down
Loading
Loading