From d395e8ff09bae701d2bd8d866079b2236ec844ee Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:42:16 +0200 Subject: [PATCH 01/10] [fix]: Malformed header now results in 401 response instead of 500 error. --- dash_auth_async/basic_auth.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/dash_auth_async/basic_auth.py b/dash_auth_async/basic_auth.py index e22e99c..50887c2 100644 --- a/dash_auth_async/basic_auth.py +++ b/dash_auth_async/basic_auth.py @@ -2,7 +2,7 @@ import logging from typing import Dict, List, Optional, Union, Callable, cast from dash import Dash - +import binascii from .auth import Auth UserGroups = Dict[str, List[str]] @@ -44,7 +44,6 @@ def __init__( you should create a key and then assign the value of that key in your code. """ - super().__init__(app, public_routes=public_routes) self._auth_func = auth_func if isinstance(user_groups, dict): self._user_groups_dict: UserGroups | None = cast(UserGroups, user_groups) @@ -74,14 +73,20 @@ def __init__( if isinstance(username_password_list, dict) else {k: v for k, v in username_password_list} ) + super().__init__(app, public_routes=public_routes) def is_authorized(self): header = self.request.headers.get("Authorization", None) if not header: return False - username_password = base64.b64decode(header.split("Basic ")[1]) - username_password_utf8 = username_password.decode("utf-8") - username, password = username_password_utf8.split(":", 1) + try: + username_password = base64.b64decode( + header.split("Basic ")[1], validate=True + ) + username_password_utf8 = username_password.decode("utf-8") + username, password = username_password_utf8.split(":", 1) + except (binascii.Error, UnicodeEncodeError, ValueError, IndexError): + return False authorized = False if self._auth_func is not None: try: From 66f67dc45f929bdbda3ae6082f7284fe7b245350 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:43:32 +0200 Subject: [PATCH 02/10] [tests]: Add unit tests with malformed BasicAuth headers. --- tests/unit/test_basic_auth.py | 129 ++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/unit/test_basic_auth.py diff --git a/tests/unit/test_basic_auth.py b/tests/unit/test_basic_auth.py new file mode 100644 index 0000000..6a62dc6 --- /dev/null +++ b/tests/unit/test_basic_auth.py @@ -0,0 +1,129 @@ +"""Unit tests for BasicAuth — Issue 1 (S2 malformed headers, C1 init order). + +These run without a browser or live server. S2 drives the real header-parsing +path inside a Flask ``test_request_context`` (FlaskBackend.request *is* +flask.request, so no mocking is needed). C1 injects a recording backend via +``detect_backend`` to observe construction order. +""" + +from types import SimpleNamespace + +import pytest +from dash import Dash + +from dash_auth_async.backends import Backend +from dash_auth_async.basic_auth import BasicAuth + +VALID_USERS = {"alice": "secret"} + + +def _basic_auth_app(**kwargs): + """A Dash app with BasicAuth on the real Flask backend.""" + app = Dash(__name__) + auth = BasicAuth(app, VALID_USERS, secret_key="test-secret", **kwargs) + return app, auth + + +# --- S2: malformed Authorization headers must fail auth, never raise (500) --- + +MALFORMED_HEADERS = [ + pytest.param("Bearer xyz", id="non-basic-scheme"), + pytest.param("Basic !!!notbase64!!!", id="non-base64"), + pytest.param("Basic bm9jb2xvbg==", id="no-colon"), # b64('nocolon') + pytest.param("Basic //4=", id="non-utf8"), # b64(b'\xff\xfe') +] + + +@pytest.mark.parametrize("header", MALFORMED_HEADERS) +def test_malformed_authorization_header_is_unauthorized(header): + """is_authorized() returns False (not raises) for any malformed header.""" + app, auth = _basic_auth_app() + with app.server.test_request_context(headers={"Authorization": header}): + assert auth.is_authorized() is False + + +@pytest.mark.parametrize("header", MALFORMED_HEADERS) +def test_malformed_authorization_header_returns_401_not_500(header): + """The decision core returns the 401 login response, not a 500 traceback.""" + app, auth = _basic_auth_app() + with app.server.test_request_context( + path="/private", headers={"Authorization": header} + ): + result = auth._authorize("/private", None) + # login_request() is ("Login Required", 401, {...}) + assert result[1] == 401 + + +# --- Control: the well-formed paths still behave correctly --- + + +def test_valid_credentials_are_authorized(): + import base64 + + app, auth = _basic_auth_app() + token = base64.b64encode(b"alice:secret").decode() + with app.server.test_request_context(headers={"Authorization": f"Basic {token}"}): + assert auth.is_authorized() is True + + +def test_wrong_password_is_unauthorized(): + import base64 + + app, auth = _basic_auth_app() + token = base64.b64encode(b"alice:wrong").decode() + with app.server.test_request_context(headers={"Authorization": f"Basic {token}"}): + assert auth.is_authorized() is False + + +def test_missing_header_is_unauthorized(): + app, auth = _basic_auth_app() + with app.server.test_request_context(): + assert auth.is_authorized() is False + + +# --- C1: __init__ must set auth state / validate args before wiring hooks --- + + +class RecordingBackend(Backend): + """Captures instance state at the moment the auth hook is registered.""" + + def __init__(self): + self.request = SimpleNamespace(path="/", headers={}) + self.session = {} + self.hook_registered = False + self.attrs_at_registration = set() + + def has_request_context(self) -> bool: + return True + + def register_auth_hook(self, server, needs_body, decide) -> None: + self.hook_registered = True + # decide is the bound Auth._authorize; __self__ is the instance + # under construction. + self.attrs_at_registration = set(vars(decide.__self__)) + + def url_for(self, endpoint: str, **values) -> str: + return f"/{endpoint}" + + def redirect(self, location: str): + return ("redirect", location) + + +def test_auth_state_exists_when_hook_is_registered(monkeypatch): + """By the time _protect() registers the hook, _users must already be set — + otherwise a request arriving mid-construction hits is_authorized() against a + half-initialised instance and raises AttributeError.""" + backend = RecordingBackend() + monkeypatch.setattr("dash_auth_async.auth.detect_backend", lambda server: backend) + BasicAuth(Dash(__name__), VALID_USERS) + assert "_users" in backend.attrs_at_registration + + +def test_invalid_config_rejected_before_wiring_hooks(monkeypatch): + """Passing both username list and auth_func must raise before any hook or + executor wiring mutates server state.""" + backend = RecordingBackend() + monkeypatch.setattr("dash_auth_async.auth.detect_backend", lambda server: backend) + with pytest.raises(ValueError): + BasicAuth(Dash(__name__), VALID_USERS, auth_func=lambda u, p: True) + assert backend.hook_registered is False From 5d8302e5e821577fcfc70a8e6906e286347f9af9 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:49:11 +0200 Subject: [PATCH 03/10] [chore]: Add ruff checks and rules to pyproject.toml Also removed bloated dependencies and extras. --- pyproject.toml | 20 ++++++++++++++++---- uv.lock | 10 +++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c87b88f..a43ee6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Database :: Front-Ends", - "Topic :: Office/Business :: Financial :: Spreadsheet", "Topic :: Scientific/Engineering :: Visualization", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Widget Sets", @@ -39,13 +38,11 @@ classifiers = [ dependencies = [ "authlib>=1.7.2", "flask>=3.1.3", - "requests[security]>=2.34.2", "werkzeug>=3.1.8", "dash>=2", ] [project.optional-dependencies] -oidc = ["authlib"] fastapi = ["dash[fastapi]>=4.2.0"] quart = ["dash[quart]>=4.2.0","httpx>=0.23.0"] async = ["dash[async]>=4.2.0"] @@ -69,6 +66,7 @@ dev = [ "ruff>=0.15.16", "pytest-cov>=7.1.0", "websocket-client>=1.9.0", + "requests[security]>=2.34.2" ] [tool.uv] @@ -85,4 +83,18 @@ venvs = [".venv"] paths = ["dash_auth_async"] deps-file = "pyproject.toml" sections = ["project.dependencies"] -exclude-deps = ["requests"] +exclude-deps = [] + +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +preview = true +select = ["FAST","I","D","DOC","PL","UP","PERF","RUF"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.ruff.lint.pydocstyle] +convention = "google" \ No newline at end of file diff --git a/uv.lock b/uv.lock index e1ada67..526596d 100644 --- a/uv.lock +++ b/uv.lock @@ -602,7 +602,6 @@ dependencies = [ { name = "authlib" }, { name = "dash" }, { name = "flask" }, - { name = "requests" }, { name = "werkzeug" }, ] @@ -613,9 +612,6 @@ async = [ fastapi = [ { name = "dash", extra = ["fastapi"] }, ] -oidc = [ - { name = "authlib" }, -] quart = [ { name = "dash", extra = ["quart"] }, { name = "httpx" }, @@ -629,6 +625,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "requests" }, { name = "ruff" }, { name = "setuptools" }, { name = "ty" }, @@ -638,17 +635,15 @@ dev = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = ">=1.7.2" }, - { name = "authlib", marker = "extra == 'oidc'" }, { name = "dash", specifier = ">=2" }, { name = "dash", extras = ["async"], marker = "extra == 'async'", specifier = ">=4.2.0" }, { name = "dash", extras = ["fastapi"], marker = "extra == 'fastapi'", specifier = ">=4.2.0" }, { name = "dash", extras = ["quart"], marker = "extra == 'quart'", specifier = ">=4.2.0" }, { name = "flask", specifier = ">=3.1.3" }, { name = "httpx", marker = "extra == 'quart'", specifier = ">=0.23.0" }, - { name = "requests", extras = ["security"], specifier = ">=2.34.2" }, { name = "werkzeug", specifier = ">=3.1.8" }, ] -provides-extras = ["oidc", "fastapi", "quart", "async"] +provides-extras = ["fastapi", "quart", "async"] [package.metadata.requires-dev] dev = [ @@ -658,6 +653,7 @@ dev = [ { name = "pre-commit", specifier = ">=3.5.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "requests", extras = ["security"], specifier = ">=2.34.2" }, { name = "ruff", specifier = ">=0.15.16" }, { name = "setuptools", specifier = ">=79.0.1" }, { name = "ty", specifier = ">=0.0.46" }, From 4389c2980103e62188437ae63e614640ceddc3a9 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:07:42 +0200 Subject: [PATCH 04/10] [fix]: Use constant time comparison for BasicAuth password. --- dash_auth_async/basic_auth.py | 67 ++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/dash_auth_async/basic_auth.py b/dash_auth_async/basic_auth.py index 50887c2..6bbf275 100644 --- a/dash_auth_async/basic_auth.py +++ b/dash_auth_async/basic_auth.py @@ -1,21 +1,29 @@ +"""Basic (username/password) HTTP authentication for Dash apps.""" + import base64 +import binascii +import hmac import logging -from typing import Dict, List, Optional, Union, Callable, cast +from collections.abc import Callable +from typing import cast + from dash import Dash -import binascii + from .auth import Auth -UserGroups = Dict[str, List[str]] +UserGroups = dict[str, list[str]] class BasicAuth(Auth): - def __init__( + """Protect a Dash app with HTTP Basic authentication.""" + + def __init__( # noqa: PLR0913, PLR0917 — configuration constructor self, app: Dash, - username_password_list: Union[list, dict] | None = None, + username_password_list: list | dict | None = None, auth_func: Callable | None = None, - public_routes: Optional[list] = None, - user_groups: Optional[Union[UserGroups, Callable[[str], UserGroups]]] = None, + public_routes: list | None = None, + user_groups: UserGroups | Callable[[str], UserGroups] | None = None, secret_key: str | None = None, ): """Add basic authentication to Dash. @@ -43,6 +51,10 @@ 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. + + Raises: + ValueError: if both ``auth_func`` and ``username_password_list`` + are supplied, or if neither is. """ self._auth_func = auth_func if isinstance(user_groups, dict): @@ -61,21 +73,28 @@ def __init__( "(auth_func kwarg) or username_password_list, " "it cannot use both." ) + elif username_password_list is None: + raise ValueError( + "BasicAuth requires username/password map " + "or user-defined authorization function." + ) else: - if username_password_list is None: - raise ValueError( - "BasicAuth requires username/password map " - "or user-defined authorization function." - ) - else: - self._users = ( - username_password_list - if isinstance(username_password_list, dict) - else {k: v for k, v in username_password_list} - ) + self._users = ( + username_password_list + if isinstance(username_password_list, dict) + else {k: v for k, v in username_password_list} + ) super().__init__(app, public_routes=public_routes) def is_authorized(self): + """Return whether the request carries valid Basic credentials. + + Parses the ``Authorization`` header; a missing or malformed header + returns ``False`` (surfacing as a 401 rather than a 500). On success + the authenticated user is stored in the session. + + :return: True if the credentials are valid, otherwise False. + """ header = self.request.headers.get("Authorization", None) if not header: return False @@ -95,7 +114,10 @@ def is_authorized(self): logging.exception("Error in authorization function.") return False else: - authorized = self._users.get(username) == password + stored_password = self._users.get(username) + if stored_password is None: + return authorized + authorized = hmac.compare_digest(stored_password, password) if authorized: try: self.session["user"] = {"email": username, "groups": []} @@ -109,7 +131,12 @@ def is_authorized(self): logging.warning("Session is not available. Have you set a secret key?") return authorized - def login_request(self): + def login_request(self): # noqa: PLR6301 — overrides Auth.login_request + """Return a 401 response prompting the browser for credentials. + + :return: a ``(body, status, headers)`` tuple challenging the client + with HTTP Basic authentication. + """ return ( "Login Required", 401, From b3ba5327fd4c222eaceb05f2debebfcd6dae10d2 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 00:42:58 +0200 Subject: [PATCH 05/10] [docs]: Fix all ruff issues after config changes. --- dash_auth_async/__init__.py | 15 +-- dash_auth_async/auth.py | 50 +++++++--- dash_auth_async/backends.py | 106 ++++++++++++++++++--- dash_auth_async/group_protection.py | 74 +++++++++------ dash_auth_async/oidc_auth.py | 141 ++++++++++++++++++++-------- dash_auth_async/public_routes.py | 37 +++++--- dash_auth_async/quart_client.py | 53 +++++++++-- dash_auth_async/version.py | 2 + dash_auth_async/websocket_auth.py | 26 ++--- 9 files changed, 371 insertions(+), 133 deletions(-) diff --git a/dash_auth_async/__init__.py b/dash_auth_async/__init__.py index eff075c..fc25539 100644 --- a/dash_auth_async/__init__.py +++ b/dash_auth_async/__init__.py @@ -1,6 +1,8 @@ -from .public_routes import add_public_routes, public_callback +"""Authentication and authorization for Dash apps on sync and async backends.""" + from .basic_auth import BasicAuth -from .group_protection import list_groups, check_groups, protected, protected_callback +from .group_protection import check_groups, list_groups, protected, protected_callback +from .public_routes import add_public_routes, public_callback # oidc auth requires authlib, install with `pip install dash-auth[oidc]` try: @@ -9,16 +11,15 @@ pass 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__", ] diff --git a/dash_auth_async/auth.py b/dash_auth_async/auth.py index 441bac6..c4ae625 100644 --- a/dash_auth_async/auth.py +++ b/dash_auth_async/auth.py @@ -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, @@ -15,11 +15,13 @@ 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. @@ -27,8 +29,10 @@ def __init__( :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)}") @@ -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" @@ -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(): @@ -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 @@ -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) @@ -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.""" diff --git a/dash_auth_async/backends.py b/dash_auth_async/backends.py index 60d38f5..5278af7 100644 --- a/dash_auth_async/backends.py +++ b/dash_auth_async/backends.py @@ -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 @@ -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. @@ -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 = ( @@ -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` " @@ -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 = ( @@ -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) @@ -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() @@ -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 diff --git a/dash_auth_async/group_protection.py b/dash_auth_async/group_protection.py index bbc40d9..73f9b11 100644 --- a/dash_auth_async/group_protection.py +++ b/dash_auth_async/group_protection.py @@ -1,8 +1,11 @@ -import inspect +"""Group-based protection for Dash callbacks and outputs.""" + import functools +import inspect import logging import re -from typing import Any, Callable, List, Literal, Optional, Union +from collections.abc import Callable +from typing import Any, Literal import dash from dash.exceptions import PreventUpdate @@ -10,7 +13,7 @@ from .backends import get_active_backend from .websocket_auth import _WS_AUTH_USER -OutputVal = Union[Callable[[], Any], Any] +OutputVal = Callable[[], Any] | Any CheckType = Literal["one_of", "all_of", "none_of"] # Sentinel: the gate authorises the call, so run the protected target rather @@ -18,7 +21,7 @@ _PROCEED = object() -def _current_user() -> Optional[dict]: +def _current_user() -> dict | None: """Resolve the authenticated user for the current dispatch. Single source of truth for "who is calling": under an HTTP request context @@ -29,6 +32,9 @@ def _current_user() -> Optional[dict]: Every helper that needs the caller -- ``list_groups`` and the default ``protected_callback`` fallbacks -- goes through here so none of them touch ``backend.session`` directly (which raises ``RuntimeError`` off-request). + + Returns: + The authenticated user dict, or None when unauthenticated. """ backend = get_active_backend() if backend.has_request_context(): @@ -43,15 +49,15 @@ def list_groups( *, groups_key: str = "groups", groups_str_split: str | None = None, -) -> Optional[List[str]]: +) -> list[str] | None: """List all the groups the user belongs to. :param groups_key: Groups key in the user data saved in the backend session e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]} :param groups_str_split: Used to split groups if provided as a string - :return: None or list[str]: - * None if the user is not authenticated - * list[str] otherwise + + Returns: + None if the user is not authenticated, otherwise the list of groups. """ user = _current_user() if user is None: @@ -66,14 +72,13 @@ def list_groups( def check_groups( - groups: Optional[List[str]] = None, + groups: list[str] | None = None, *, groups_key: str = "groups", groups_str_split: str | None = None, check_type: CheckType = "one_of", -) -> Optional[bool]: - """Check whether the current user is authenticated - and has the specified groups. +) -> bool | None: + """Check whether the current user is authenticated and has the groups. :param groups: List of groups to check for with check_type :param groups_key: Groups key in the user data saved in the backend session @@ -81,11 +86,13 @@ def check_groups( :param groups_str_split: Used to split groups if provided as a string :param check_type: Type of check to perform. Either "one_of", "all_of" or "none_of" - :return: None or boolean: - * None if the user is not authenticated - * True if the user is authenticated and has the right permissions - * False if the user is authenticated but does not have - the right permissions + + Returns: + None if the user is not authenticated; True if authenticated with the + right permissions; False if authenticated without them. + + Raises: + ValueError: if ``check_type`` is not a recognised value. """ user_groups = list_groups( groups_key=groups_key, @@ -109,17 +116,16 @@ def check_groups( raise ValueError(f"Invalid check_type: {check_type}") -def protected( +def protected( # noqa: PLR0913 — public decorator with many optional knobs unauthenticated_output: OutputVal, *, - missing_permissions_output: Optional[OutputVal] = None, - groups: Optional[List[str]] = None, + missing_permissions_output: OutputVal | None = None, + groups: list[str] | None = None, groups_key: str = "groups", groups_str_split: str | None = None, check_type: CheckType = "one_of", ) -> Callable: - """Decorate a function or output to alter it depending on the state - of authentication and permissions. + """Alter a function or output depending on authentication and permissions. :param unauthenticated_output: Output when the user is not authenticated. Note: needs to be a function with no argument or static outputs. @@ -134,8 +140,10 @@ def protected( :param groups_str_split: Used to split groups if provided as a string :param check_type: Type of check to perform. Either "one_of", "all_of" or "none_of" - """ + Returns: + A decorator that wraps the target output with the auth gate. + """ if missing_permissions_output is None: missing_permissions_output = unauthenticated_output @@ -190,7 +198,11 @@ def wrap(*args, **kwargs): def _prevent_unauthenticated(func_name: str) -> None: - """Default ``unauthenticated_output``: log and stop the callback.""" + """Default ``unauthenticated_output``: log and stop the callback. + + Raises: + PreventUpdate: always, to stop the callback from running. + """ logging.info( "A user tried to run %s without being authenticated.", func_name, @@ -204,6 +216,9 @@ def _prevent_unauthorised(func_name: str) -> None: Resolves the caller via ``_current_user`` rather than touching ``backend.session`` directly, so it stays graceful on the WebSocket worker path (no request context) instead of raising ``RuntimeError``. + + Raises: + PreventUpdate: always, to stop the callback from running. """ user = _current_user() or {} logging.info( @@ -214,11 +229,11 @@ def _prevent_unauthorised(func_name: str) -> None: raise PreventUpdate -def protected_callback( +def protected_callback( # noqa: PLR0913 — public decorator with many optional knobs *callback_args, - unauthenticated_output: Optional[OutputVal] = None, - missing_permissions_output: Optional[OutputVal] = None, - groups: List[str] | None = None, + unauthenticated_output: OutputVal | None = None, + missing_permissions_output: OutputVal | None = None, + groups: list[str] | None = None, groups_key: str = "groups", groups_str_split: str | None = None, check_type: CheckType = "one_of", @@ -247,6 +262,9 @@ def protected_callback( :param groups_str_split: Used to split groups if provided as a string :param check_type: Type of check to perform. Either "one_of", "all_of" or "none_of" + + Returns: + A decorator that registers the protected Dash callback. """ def decorator(func): diff --git a/dash_auth_async/oidc_auth.py b/dash_auth_async/oidc_auth.py index cf057e8..ee0c420 100644 --- a/dash_auth_async/oidc_auth.py +++ b/dash_auth_async/oidc_auth.py @@ -1,16 +1,19 @@ +"""OpenID Connect authentication for Dash on Flask and Quart backends.""" + import logging import os import re -from typing import Optional, Union, TYPE_CHECKING +from typing import TYPE_CHECKING import dash from authlib.integrations.base_client import OAuthError from authlib.integrations.flask_client import OAuth -from dash_auth_async.auth import Auth -from dash_auth_async.public_routes import get_url_base from flask import Response from werkzeug.routing import Map, Rule +from dash_auth_async.auth import Auth +from dash_auth_async.public_routes import get_url_base + from .backends import QuartBackend if TYPE_CHECKING: @@ -18,6 +21,7 @@ FlaskOAuth1App, FlaskOAuth2App, ) + from dash_auth_async.quart_client import OAuth as QuartOAuth from dash_auth_async.quart_client import QuartOAuth2App @@ -25,18 +29,18 @@ class OIDCAuth(Auth): """Implements auth via OpenID.""" - def __init__( + def __init__( # noqa: PLR0913, PLR0917 — configuration constructor self, app: dash.Dash, secret_key: str | None = None, - force_https_callback: Optional[Union[bool, str]] = None, + force_https_callback: bool | str | None = None, login_route: str = "/oidc//login", logout_route: str = "/oidc/logout", callback_route: str = "/oidc//callback", idp_selection_route: str | None = None, log_signins: bool = False, - public_routes: Optional[list] = None, - logout_page: Union[str, Response] | None = None, + public_routes: list | None = None, + logout_page: str | Response | None = None, secure_session: bool = False, ): """Secure a Dash app through OpenID Connect. @@ -92,10 +96,10 @@ def __init__( SESSION_COOKIE_SECURE and SESSION_COOKIE_HTTPONLY to True, by default False - Raises - ------ - Exception - Raise an exception if the app.server.secret_key is not defined + Raises: + RuntimeError: if ``app.server.secret_key`` is not defined. + Exception: if the login or callback route lacks an ```` + placeholder. """ super().__init__(app, public_routes=public_routes) @@ -139,9 +143,9 @@ def __init__( if isinstance(self.backend, QuartBackend): # Imported lazily so flask-only installs never import # quart/httpx (quart_client raises ImportError without them). - from dash_auth_async import quart_client + from dash_auth_async import quart_client # noqa: PLC0415 - self.oauth: "OAuth | quart_client.OAuth" = quart_client.OAuth(app.server) + self.oauth: OAuth | quart_client.OAuth = quart_client.OAuth(app.server) else: self.oauth = OAuth(app.server) @@ -192,6 +196,9 @@ def register_provider(self, idp_name: str, **kwargs): * server_metadata_url * token_endpoint_auth_method * client_kwargs (defaults to {"scope": "openid email"}) + + Raises: + ValueError: if ``idp_name`` contains unsupported characters. """ if not re.match(r"^[\w\-\. ]+$", idp_name): raise ValueError( @@ -203,30 +210,47 @@ def register_provider(self, idp_name: str, **kwargs): self.oauth.register(idp_name, client_kwargs=client_kwargs, **kwargs) def get_oauth_client(self, idp: str): - """Get the OAuth client.""" + """Get the OAuth client. + + Returns: + The authlib OAuth client for the given idp. + + Raises: + ValueError: if ``idp`` is not a registered provider. + """ if idp not in self.oauth._registry: raise ValueError(f"'{idp}' is not a valid registered idp") - client: Union[FlaskOAuth1App, FlaskOAuth2App, QuartOAuth2App] = ( + client: FlaskOAuth1App | FlaskOAuth2App | QuartOAuth2App = ( self.oauth.create_client(idp) ) return client def get_oauth_kwargs(self, idp: str): - """Get the OAuth kwargs.""" + """Get the OAuth kwargs. + + Returns: + The registration kwargs stored for the given idp. + + Raises: + ValueError: if ``idp`` is not a registered provider. + """ if idp not in self.oauth._registry: raise ValueError(f"'{idp}' is not a valid registered idp") kwargs: dict = self.oauth._registry[idp][1] return kwargs - def _resolve_idp(self, idp: Optional[str]): + def _resolve_idp(self, idp: str | None): """Resolve which idp to use for login. - Returns ``(idp, None)`` when a provider could be determined, or - ``(None, response)`` when the caller should return ``response`` - instead (idp-selection redirect or a 400). Shared by the sync and - async login views so selection behavior cannot drift. + Shared by the sync and async login views so selection behavior + cannot drift. + + Returns: + ``(idp, None)`` when a provider could be determined, or + ``(None, response)`` when the caller should return ``response`` + instead (idp-selection redirect or a 400). """ if idp in self.oauth._registry: return idp, None @@ -244,7 +268,11 @@ def _resolve_idp(self, idp: Optional[str]): ) def _create_redirect_uri(self, idp: str): - """Create the redirect uri based on callback endpoint and idp.""" + """Create the redirect uri based on callback endpoint and idp. + + Returns: + The fully-qualified OIDC callback redirect URI. + """ if self.force_https_callback: redirect_uri = self.backend.url_for( "oidc_callback", idp=idp, _external=True, _scheme="https" @@ -263,6 +291,10 @@ def login_request(self, idp: str | None = None): On the Quart path this returns a coroutine (both the route and the before-request hook await it); on Flask it returns the response. + + Returns: + The authorize-redirect response, or a coroutine producing it on + the Quart path. """ # `idp` can be none here as login_request is called # without arguments in the before_request hook @@ -282,7 +314,11 @@ def login_request(self, idp: str | None = None): ) async def _login_request_async(self, idp: str | None = None): - """Async login view for the Quart path.""" + """Async login view for the Quart path. + + Returns: + The authorize-redirect response. + """ idp, response = self._resolve_idp(idp) if response is not None: return response @@ -296,7 +332,11 @@ async def _login_request_async(self, idp: str | None = None): ) def logout(self): # pylint: disable=C0116 - """Logout the user.""" + """Logout the user. + + Returns: + The logged-out page content. + """ self.session.clear() base_url = get_url_base(self.app) or "/" page = ( @@ -312,11 +352,19 @@ def logout(self): # pylint: disable=C0116 return page async def _logout_async(self): - """Async logout view for the Quart path; the body is sync.""" + """Async logout view for the Quart path; the body is sync. + + Returns: + The logged-out page content. + """ return self.logout() def callback(self, idp: str): # pylint: disable=C0116 - """Handle the OIDC dance and post-login actions.""" + """Handle the OIDC dance and post-login actions. + + Returns: + The post-login redirect, or an error tuple on failure. + """ if idp not in self.oauth._registry: return f"'{idp}' is not a valid registered idp", 400 @@ -333,7 +381,11 @@ def callback(self, idp: str): # pylint: disable=C0116 return self.after_logged_in(user, idp, token) async def _callback_async(self, idp: str): - """Async OIDC callback view for the Quart path.""" + """Async OIDC callback view for the Quart path. + + Returns: + The post-login redirect, or an error tuple on failure. + """ if idp not in self.oauth._registry: return f"'{idp}' is not a valid registered idp", 400 @@ -349,15 +401,19 @@ async def _callback_async(self, idp: str): user = token.get("userinfo") return self.after_logged_in(user, idp, token) - 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: - class MyOIDCAuth(OIDCAuth): - def after_logged_in(self, user, idp, token): - if user: - user["params"] = value1 - return super().after_logged_in(user, idp, token) + def after_logged_in(self, user: dict | None, idp: str, token: dict): + """Run post-login actions after successful OIDC authentication. + + For example, allows passing custom attributes to the user session:: + + class MyOIDCAuth(OIDCAuth): + def after_logged_in(self, user, idp, token): + if user: + user["params"] = value1 + return super().after_logged_in(user, idp, token) + + Returns: + A redirect response to the app's base URL. """ if user: self.session["user"] = user @@ -371,8 +427,11 @@ def after_logged_in(self, user, idp, token): return self.backend.redirect(get_url_base(self.app) or "/") def is_authorized(self): # pylint: disable=C0116 - """Check whether ther user is authenticated.""" + """Check whether the user is authenticated. + Returns: + True if the path is an OIDC route or a user is in the session. + """ map_adapter = Map( [ Rule(x) @@ -388,12 +447,18 @@ def is_authorized(self): # pylint: disable=C0116 return map_adapter.test(self.request.path) or "user" in self.session -def get_oauth(app: dash.Dash | None = None) -> "Union[OAuth, QuartOAuth]": +def get_oauth(app: dash.Dash | None = None) -> "OAuth | QuartOAuth": """Retrieve the OAuth object. :param app: dash.Dash Dash app or None, if None the current app is used calling `dash.get_app()` + + Returns: + The Flask or Quart OAuth integration registered on the app server. + + Raises: + RuntimeError: if no OAuth object has been registered yet. """ if app is None: app = dash.get_app() diff --git a/dash_auth_async/public_routes.py b/dash_auth_async/public_routes.py index 195c312..29363da 100644 --- a/dash_auth_async/public_routes.py +++ b/dash_auth_async/public_routes.py @@ -1,9 +1,12 @@ +"""Public route and callback registration for unauthenticated access.""" + import inspect import os -from dash import Dash, callback -from dash._callback import GLOBAL_CALLBACK_MAP -from dash import get_app +from dash import Dash, callback, get_app + +# GLOBAL_CALLBACK_MAP is a Dash internal with no public API equivalent. +from dash._callback import GLOBAL_CALLBACK_MAP # noqa: PLC2701 from werkzeug.routing import Map, MapAdapter, Rule DASH_PUBLIC_ASSETS_EXTENSIONS = "js,css" @@ -65,7 +68,6 @@ def add_public_routes(app: Dash, routes: list): :param app: Dash app :param routes: list of public routes to be added """ - public_routes = get_public_routes(app) url_base = get_url_base(app) @@ -73,9 +75,10 @@ def add_public_routes(app: Dash, routes: list): 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)) + full_route = route + if url_base and not full_route.startswith(url_base): + full_route = url_base.rstrip("/") + full_route + public_routes.map.add(Rule(full_route)) app.server.config[PUBLIC_ROUTES] = public_routes @@ -87,6 +90,9 @@ def public_callback(*callback_args, **callback_kwargs): of whitelisted callbacks in the Flask server's config. :param **: all args and kwargs passed to a dash callback + + Returns: + A decorator that registers the public Dash callback. """ def decorator(func): @@ -101,8 +107,9 @@ def decorator(func): ) 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( @@ -119,10 +126,18 @@ def wrap(*args, **kwargs): def get_public_routes(app: Dash) -> MapAdapter: - """Retrieve the public routes.""" + """Retrieve the public routes. + + Returns: + The MapAdapter holding the app's registered public routes. + """ return app.server.config.get(PUBLIC_ROUTES, Map([]).bind("")) def get_public_callbacks(app: Dash) -> list: - """Retrieve the public callbacks ids.""" + """Retrieve the public callbacks ids. + + Returns: + The list of whitelisted public callback ids. + """ return app.server.config.get(PUBLIC_CALLBACKS, []) diff --git a/dash_auth_async/quart_client.py b/dash_auth_async/quart_client.py index ce3e5b4..4ad2c52 100644 --- a/dash_auth_async/quart_client.py +++ b/dash_auth_async/quart_client.py @@ -15,7 +15,7 @@ import json import time -from typing import Any, Optional +from typing import Any from authlib.integrations.base_client import ( BaseApp, @@ -38,9 +38,9 @@ __all__ = [ "OAuth", + "OAuthError", "QuartIntegration", "QuartOAuth2App", - "OAuthError", ] @@ -63,8 +63,13 @@ async def _get_cache_data(self, key): return None async def get_state_data( - self, session: Optional[dict[str, Any]], state: str - ) -> Optional[dict[str, Any]]: + self, session: dict[str, Any] | None, state: str + ) -> dict[str, Any] | None: + """Return the stored authorization state data, or None if absent. + + Returns: + The state ``data`` payload, or None when not found or unverified. + """ key = f"_state_{self.name}_{state}" if self.cache: # require a session-bound marker to prove the callback @@ -83,8 +88,9 @@ async def get_state_data( return None async def set_state_data( - self, session: Optional[dict[str, Any]], state: str, data: Any + self, session: dict[str, Any] | None, state: str, data: Any ): + """Persist authorization state data, sweeping stale state keys.""" key_prefix = f"_state_{self.name}_" key = f"{key_prefix}{state}" now = time.time() @@ -103,7 +109,8 @@ async def set_state_data( session.pop(old_key) session[key] = {"data": data, "exp": now + self.expires_in} - async def clear_state_data(self, session: Optional[dict[str, Any]], state: str): + async def clear_state_data(self, session: dict[str, Any] | None, state: str): + """Remove the stored authorization state data for ``state``.""" key = f"_state_{self.name}_{state}" if self.cache: await self.cache.delete(key) @@ -112,10 +119,15 @@ async def clear_state_data(self, session: Optional[dict[str, Any]], state: str): self._clear_session_state(session) def update_token(self, token, refresh_token=None, access_token=None): - pass + """No-op token-update hook required by the authlib interface.""" @staticmethod def load_config(oauth, name, params): + """Read ``{NAME}_{PARAM}`` config values into a dict. + + Returns: + The mapping of requested params present in the app config. + """ rv = {} for k in params: conf_key = f"{name}_{k}".upper() @@ -140,7 +152,9 @@ async def authorize_redirect(self, redirect_uri=None, **kwargs): :param redirect_uri: Callback or redirect URI for authorization. :param kwargs: Extra parameters to include. - :return: A Quart redirect response. + + Returns: + A Quart redirect response. """ rv = await self.create_authorization_url(redirect_uri, **kwargs) # type: ignore await self.save_authorize_data(redirect_uri=redirect_uri, **rv) @@ -148,6 +162,8 @@ async def authorize_redirect(self, redirect_uri=None, **kwargs): class QuartOAuth2App(AsyncQuartAppMixin, AsyncOAuth2Mixin, AsyncOpenIDMixin, BaseApp): + """OAuth2/OIDC app for the Quart backend.""" + client_cls = AsyncOAuth2Client async def authorize_access_token(self, **kwargs): @@ -156,7 +172,11 @@ async def authorize_access_token(self, **kwargs): Only the GET callback shape is handled: OIDCAuth registers the callback route with methods=["GET"] exclusively. - :return: A token dict. + Returns: + A token dict, including ``userinfo`` when an id_token is present. + + Raises: + OAuthError: if the IdP returned an error in the callback. """ error = quart.request.args.get("error") if error: @@ -199,6 +219,7 @@ class OAuth(BaseOAuth): framework_integration_cls = QuartIntegration def __init__(self, app=None, cache=None, fetch_token=None, update_token=None): + """Create the registry, optionally binding a Quart app immediately.""" super().__init__( cache=cache, fetch_token=fetch_token, update_token=update_token ) @@ -220,11 +241,25 @@ def init_app(self, app, cache=None, fetch_token=None, update_token=None): app.extensions["authlib.integrations.quart_client"] = self def create_client(self, name): + """Create the OAuth client registered under ``name``. + + Returns: + The instantiated OAuth client. + + Raises: + RuntimeError: if no Quart app has been initialised. + """ if not self.app: raise RuntimeError("OAuth is not init with Quart app.") return super().create_client(name) def register(self, name, overwrite=False, **kwargs): + """Register an OAuth provider and return its client. + + Returns: + The client, or a LocalProxy that lazily creates it when no app + is bound yet. + """ self._registry[name] = (overwrite, kwargs) if self.app: return self.create_client(name) diff --git a/dash_auth_async/version.py b/dash_auth_async/version.py index c68196d..9a5b4a0 100644 --- a/dash_auth_async/version.py +++ b/dash_auth_async/version.py @@ -1 +1,3 @@ +"""Single source of truth for the package version.""" + __version__ = "1.2.0" diff --git a/dash_auth_async/websocket_auth.py b/dash_auth_async/websocket_auth.py index 86ab8f6..845dfcb 100644 --- a/dash_auth_async/websocket_auth.py +++ b/dash_auth_async/websocket_auth.py @@ -12,21 +12,20 @@ import threading from concurrent.futures import ThreadPoolExecutor from contextvars import ContextVar -from typing import Any, Optional +from typing import Any from weakref import WeakKeyDictionary # The authenticated user (session["user"] dict) for the callback currently being # dispatched over a WebSocket. Set by the websocket_message hook in the WS # context and propagated into Dash's callback worker by the context-copying # executor. ``list_groups`` reads it when no HTTP request context is active. -_WS_AUTH_USER: "ContextVar[Optional[dict]]" = ContextVar( +_WS_AUTH_USER: ContextVar[dict | None] = ContextVar( "dash_auth_async_ws_user", default=None ) class _ContextCopyingExecutor(ThreadPoolExecutor): - """A ThreadPoolExecutor that runs each task inside a copy of the context - active at ``submit()`` time. + """Run each submitted task inside a copy of the submit-time context. Dash's WebSocket runner submits callbacks to a plain ThreadPoolExecutor, which does not propagate ``contextvars`` into the worker thread. We pre-seed @@ -42,7 +41,7 @@ def submit(self, fn, /, *args, **kwargs): # server (Quart/Flask app) -> Auth. Weak keys so test apps are collected. -_AUTH_BY_SERVER: "WeakKeyDictionary[Any, Any]" = WeakKeyDictionary() +_AUTH_BY_SERVER: WeakKeyDictionary[Any, Any] = WeakKeyDictionary() _hook_lock = threading.Lock() _hook_registered = False @@ -51,15 +50,18 @@ def submit(self, fn, /, *args, **kwargs): def _ws_message_hook(ws: Any, message: Any): """Global Dash websocket_message hook: authorize each callback_request. - Returns a truthy value to allow, or a ``(code, reason)`` tuple to reject - (which closes the socket). Resolves the owning app via ``quart.current_app`` - so it is correct when several apps share the process; inert for apps that do - not use dash-auth-async. + Resolves the owning app via ``quart.current_app`` so it is correct when + several apps share the process; inert for apps that do not use + dash-auth-async. + + Returns: + A truthy value to allow, or a ``(code, reason)`` tuple to reject + (which closes the socket). """ if not isinstance(message, dict) or message.get("type") != "callback_request": return True try: - import quart + import quart # noqa: PLC0415 — quart is an optional dependency # ``quart.current_app`` is a proxy; ``_get_current_object`` unwraps it to # the real Quart app (the key in ``_AUTH_BY_SERVER``). The attribute is @@ -90,11 +92,11 @@ def _ws_message_hook(ws: Any, message: Any): def _ensure_hook_registered() -> None: """Register the global websocket_message hook exactly once per process.""" - global _hook_registered + global _hook_registered # noqa: PLW0603 — register the hook once per process with _hook_lock: if _hook_registered: return - from dash import hooks + from dash import hooks # noqa: PLC0415 — lazy import to avoid an import cycle hooks.websocket_message()(_ws_message_hook) _hook_registered = True From d8f4dc0de77510d241671dd6c452d54df8e099fc Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:28:51 +0200 Subject: [PATCH 06/10] [chore]: Bump project to version 1.2.1 --- CHANGELOG.md | 6 ++++++ dash_auth_async/version.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a8773..ef3f3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash_auth_async/version.py b/dash_auth_async/version.py index 9a5b4a0..e461518 100644 --- a/dash_auth_async/version.py +++ b/dash_auth_async/version.py @@ -1,3 +1,3 @@ """Single source of truth for the package version.""" -__version__ = "1.2.0" +__version__ = "1.2.1" diff --git a/pyproject.toml b/pyproject.toml index a43ee6b..99b044e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dash-auth-async" -version = "1.2.0" +version = "1.2.1" description = "Dash Authorization Package." readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 526596d..827b4c3 100644 --- a/uv.lock +++ b/uv.lock @@ -596,7 +596,7 @@ testing = [ [[package]] name = "dash-auth-async" -version = "1.2.0" +version = "1.2.1" source = { editable = "." } dependencies = [ { name = "authlib" }, From 2bb4ca4e884c5da9c53c329cc7dc7b5aa414f136 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:36:45 +0200 Subject: [PATCH 07/10] [chore]: Update linting rules for tests files. --- pyproject.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 99b044e..e3f9486 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,21 @@ target-version = "py310" preview = true select = ["FAST","I","D","DOC","PL","UP","PERF","RUF"] +[tool.ruff.lint.per-file-ignores] +# Tests are self-describing and have different idioms than library code: +"tests/**" = [ + "D", # don't require docstrings on every test/fixture/module + "DOC", # ...nor docstring sections (Returns, etc.) + "PLR2004", # status-code literals (200/401/...) read clearer than named constants in asserts + "PLC0415", # local imports are fine for optional deps and lazy fixtures + "PLC2701", # tests legitimately import private names to exercise internals + "PLR6301", # mock methods match a backend's instance interface; can't be static + "PLR0913", # mocks/fixtures mirror real signatures, including wide ones + "PLR0917", # ...same, for positional-argument count + "PERF203", # try/except in a loop is irrelevant in test setup + "RUF029", # async mocks intentionally match an awaited interface without awaiting +] + [tool.ruff.format] quote-style = "double" indent-style = "space" From 172ec3a03d61c3878a49edb79d688c69cd252537 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:37:31 +0200 Subject: [PATCH 08/10] [docs]: Fix all ruff issues for test directory and usage.py --- tests/conftest.py | 4 ++-- tests/integration/test_basic_auth_integration.py | 5 +++-- .../test_basic_auth_integration_auth_func.py | 5 ++--- .../test_basic_auth_integration_auth_func_quart.py | 5 ++--- .../integration/test_basic_auth_integration_quart.py | 5 +++-- tests/integration/test_oidc_auth.py | 4 ++-- tests/integration/test_oidc_auth_quart.py | 2 +- ...test_protected_callback_async_integration_flask.py | 2 +- ...test_protected_callback_async_integration_quart.py | 2 +- ..._protected_callback_websocket_integration_quart.py | 2 +- tests/integration/test_websocket_security_quart.py | 3 ++- tests/unit/test_basic_auth.py | 6 ++++-- tests/unit/test_group_protection.py | 3 ++- tests/unit/test_group_protection_quart.py | 6 ++++-- tests/unit/test_quart_client.py | 2 +- tests/unit/test_websocket_auth.py | 9 ++++++--- usage.py | 11 ++++++++++- 17 files changed, 47 insertions(+), 29 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 84bdad5..9440ac8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,9 @@ -import pytest from typing import Any import dash.testing.application_runners as _runners +import pytest -import dash_auth_async.backends as backends +from dash_auth_async import backends def _stop_quart_gracefully(runner) -> bool: diff --git a/tests/integration/test_basic_auth_integration.py b/tests/integration/test_basic_auth_integration.py index 65241e7..fd60db9 100644 --- a/tests/integration/test_basic_auth_integration.py +++ b/tests/integration/test_basic_auth_integration.py @@ -1,6 +1,7 @@ -from dash import Dash, Input, Output, dcc, html -import requests import pytest +import requests +from dash import Dash, Input, Output, dcc, html + from dash_auth_async import BasicAuth, add_public_routes, protected TEST_USERS = { diff --git a/tests/integration/test_basic_auth_integration_auth_func.py b/tests/integration/test_basic_auth_integration_auth_func.py index 26169d3..4631ece 100644 --- a/tests/integration/test_basic_auth_integration_auth_func.py +++ b/tests/integration/test_basic_auth_integration_auth_func.py @@ -1,6 +1,6 @@ -from dash import Dash, Input, Output, dcc, html -import requests import pytest +import requests +from dash import Dash, Input, Output, dcc, html from dash_auth_async import basic_auth @@ -109,4 +109,3 @@ def test_ba003_basic_auth_login_flow(dash_br, dash_thread_server, kwargs): both_dict_and_func(dash_br, dash_thread_server, **kwargs) with pytest.raises(ValueError): both_no_auth_func_or_dict(dash_br, dash_thread_server, **kwargs) - return diff --git a/tests/integration/test_basic_auth_integration_auth_func_quart.py b/tests/integration/test_basic_auth_integration_auth_func_quart.py index 3eef75a..ac87026 100644 --- a/tests/integration/test_basic_auth_integration_auth_func_quart.py +++ b/tests/integration/test_basic_auth_integration_auth_func_quart.py @@ -1,6 +1,6 @@ -from dash import Dash, Input, Output, dcc, html -import requests import pytest +import requests +from dash import Dash, Input, Output, dcc, html from dash_auth_async import basic_auth @@ -111,4 +111,3 @@ def test_ba003_basic_auth_login_flow(dash_br, dash_thread_server, kwargs): both_dict_and_func(dash_br, dash_thread_server, **kwargs) with pytest.raises(ValueError): both_no_auth_func_or_dict(dash_br, dash_thread_server, **kwargs) - return diff --git a/tests/integration/test_basic_auth_integration_quart.py b/tests/integration/test_basic_auth_integration_quart.py index d9a47f5..f5978a4 100644 --- a/tests/integration/test_basic_auth_integration_quart.py +++ b/tests/integration/test_basic_auth_integration_quart.py @@ -1,6 +1,7 @@ -from dash import Dash, Input, Output, dcc, html -import requests import pytest +import requests +from dash import Dash, Input, Output, dcc, html + from dash_auth_async import BasicAuth, add_public_routes, protected pytest.importorskip("quart", reason="Quart extra dependencies are not installed") diff --git a/tests/integration/test_oidc_auth.py b/tests/integration/test_oidc_auth.py index e666f49..c98983b 100644 --- a/tests/integration/test_oidc_auth.py +++ b/tests/integration/test_oidc_auth.py @@ -1,14 +1,14 @@ from unittest.mock import patch +import pytest import requests from dash import Dash, Input, Output, dcc, html from flask import redirect from dash_auth_async import ( - protected_callback, OIDCAuth, + protected_callback, ) -import pytest _FLASK_OAUTH_MODULE = "authlib.integrations.flask_client.apps.FlaskOAuth2App" _METADATA_URL = "https://idp2.com/oidc/2/.well-known/openid-configuration" diff --git a/tests/integration/test_oidc_auth_quart.py b/tests/integration/test_oidc_auth_quart.py index 6a3af06..6abb6c7 100644 --- a/tests/integration/test_oidc_auth_quart.py +++ b/tests/integration/test_oidc_auth_quart.py @@ -5,8 +5,8 @@ from dash import Dash, Input, Output, dcc, html from dash_auth_async import ( - protected_callback, OIDCAuth, + protected_callback, ) quart = pytest.importorskip( diff --git a/tests/integration/test_protected_callback_async_integration_flask.py b/tests/integration/test_protected_callback_async_integration_flask.py index 4ffd252..0139fd6 100644 --- a/tests/integration/test_protected_callback_async_integration_flask.py +++ b/tests/integration/test_protected_callback_async_integration_flask.py @@ -13,8 +13,8 @@ import asyncio -from dash import Dash, Input, Output, dcc, html import pytest +from dash import Dash, Input, Output, dcc, html from dash_auth_async import BasicAuth, protected_callback diff --git a/tests/integration/test_protected_callback_async_integration_quart.py b/tests/integration/test_protected_callback_async_integration_quart.py index 66fcb1a..e2043d5 100644 --- a/tests/integration/test_protected_callback_async_integration_quart.py +++ b/tests/integration/test_protected_callback_async_integration_quart.py @@ -17,8 +17,8 @@ import asyncio -from dash import Dash, Input, Output, dcc, html import pytest +from dash import Dash, Input, Output, dcc, html from dash_auth_async import BasicAuth, protected_callback diff --git a/tests/integration/test_protected_callback_websocket_integration_quart.py b/tests/integration/test_protected_callback_websocket_integration_quart.py index 12449cc..4569754 100644 --- a/tests/integration/test_protected_callback_websocket_integration_quart.py +++ b/tests/integration/test_protected_callback_websocket_integration_quart.py @@ -16,8 +16,8 @@ import asyncio import dash -from dash import Dash, Input, Output, html, set_props import pytest +from dash import Dash, Input, Output, html, set_props from dash_auth_async import BasicAuth, protected_callback diff --git a/tests/integration/test_websocket_security_quart.py b/tests/integration/test_websocket_security_quart.py index f81c32a..8f20365 100644 --- a/tests/integration/test_websocket_security_quart.py +++ b/tests/integration/test_websocket_security_quart.py @@ -162,7 +162,8 @@ def test_unauthenticated_ws_can_invoke_public_callback(dash_thread_server): def test_authenticated_wrong_group_ws_gets_fallback_not_secret(dash_thread_server): """Authenticated-but-under-privileged over the raw socket: the group gate - renders ``missing_permissions_output`` and never leaks the admin payload.""" + renders ``missing_permissions_output`` and never leaks the admin payload. + """ app = _build_app_with_protected_admin_callback() dash_thread_server(app) base = dash_thread_server.url diff --git a/tests/unit/test_basic_auth.py b/tests/unit/test_basic_auth.py index 6a62dc6..bc71824 100644 --- a/tests/unit/test_basic_auth.py +++ b/tests/unit/test_basic_auth.py @@ -112,7 +112,8 @@ def redirect(self, location: str): def test_auth_state_exists_when_hook_is_registered(monkeypatch): """By the time _protect() registers the hook, _users must already be set — otherwise a request arriving mid-construction hits is_authorized() against a - half-initialised instance and raises AttributeError.""" + half-initialised instance and raises AttributeError. + """ backend = RecordingBackend() monkeypatch.setattr("dash_auth_async.auth.detect_backend", lambda server: backend) BasicAuth(Dash(__name__), VALID_USERS) @@ -121,7 +122,8 @@ def test_auth_state_exists_when_hook_is_registered(monkeypatch): def test_invalid_config_rejected_before_wiring_hooks(monkeypatch): """Passing both username list and auth_func must raise before any hook or - executor wiring mutates server state.""" + executor wiring mutates server state. + """ backend = RecordingBackend() monkeypatch.setattr("dash_auth_async.auth.detect_backend", lambda server: backend) with pytest.raises(ValueError): diff --git a/tests/unit/test_group_protection.py b/tests/unit/test_group_protection.py index c5cde15..7c872b5 100644 --- a/tests/unit/test_group_protection.py +++ b/tests/unit/test_group_protection.py @@ -1,6 +1,7 @@ -from dash_auth_async import list_groups, check_groups, protected from flask import Flask, session +from dash_auth_async import check_groups, list_groups, protected + def test_gp001_list_groups(): app = Flask(__name__) diff --git a/tests/unit/test_group_protection_quart.py b/tests/unit/test_group_protection_quart.py index 8971318..f910346 100644 --- a/tests/unit/test_group_protection_quart.py +++ b/tests/unit/test_group_protection_quart.py @@ -1,6 +1,8 @@ -import pytest import asyncio -from dash_auth_async import list_groups, check_groups + +import pytest + +from dash_auth_async import check_groups, list_groups pytest.importorskip("quart", reason="Quart extra dependencies are not installed") diff --git a/tests/unit/test_quart_client.py b/tests/unit/test_quart_client.py index 0da1c70..7ef57e7 100644 --- a/tests/unit/test_quart_client.py +++ b/tests/unit/test_quart_client.py @@ -9,7 +9,7 @@ pytest.importorskip("quart", reason="Quart extra dependencies are not installed") pytest.importorskip("httpx", reason="httpx is required for the Quart OAuth client") -from dash_auth_async.quart_client import QuartIntegration # noqa: E402 +from dash_auth_async.quart_client import QuartIntegration def test_state_data_roundtrip(): diff --git a/tests/unit/test_websocket_auth.py b/tests/unit/test_websocket_auth.py index a62e513..6817c2e 100644 --- a/tests/unit/test_websocket_auth.py +++ b/tests/unit/test_websocket_auth.py @@ -11,7 +11,7 @@ _current_user, _prevent_unauthorised, ) -from dash_auth_async.websocket_auth import _ContextCopyingExecutor, _WS_AUTH_USER +from dash_auth_async.websocket_auth import _WS_AUTH_USER, _ContextCopyingExecutor _probe: "contextvars.ContextVar[str]" = contextvars.ContextVar( "probe", default="DEFAULT" @@ -73,7 +73,8 @@ def test_context_copying_executor_propagates_contextvar(): def test_plain_executor_does_not_propagate_contextvar(): """Control: a plain ThreadPoolExecutor worker sees the default, proving the - custom executor is doing real work.""" + custom executor is doing real work. + """ token = _probe.set("SET-IN-SUBMITTER") try: with ThreadPoolExecutor(max_workers=1) as ex: @@ -85,6 +86,7 @@ def test_plain_executor_does_not_propagate_contextvar(): def _build_auth_app(): pytest.importorskip("quart", reason="Quart extra dependencies are not installed") from dash import Dash, Input, Output, html + from dash_auth_async import BasicAuth, public_callback app = Dash(__name__, backend="quart") @@ -141,7 +143,8 @@ def authorize_ws(self, payload, user) -> bool: def test_ws_hook_resolves_auth_for_the_current_app(monkeypatch): """With two dash-auth-async apps in the process, the hook consults only the - Auth registered for ``quart.current_app`` -- not some other app's Auth.""" + Auth registered for ``quart.current_app`` -- not some other app's Auth. + """ quart = pytest.importorskip("quart") from dash_auth_async.websocket_auth import ( _AUTH_BY_SERVER, diff --git a/usage.py b/usage.py index 8c0ab58..ec1dfc0 100644 --- a/usage.py +++ b/usage.py @@ -1,4 +1,7 @@ +"""Runnable example: protect a Dash app with ``BasicAuth``.""" + from dash import Dash, Input, Output, dcc, html + import dash_auth_async # Keep this out of source code repository - save in a file or a database @@ -9,6 +12,7 @@ # Authorization function defined by developer # (can be used instead of VALID_USERNAME_PASSWORD_PAIRS [Example 2 below]) def authorization_function(username, password): + """Return ``True`` when the supplied credentials are valid.""" if (username == "hello") and (password == "world"): return True else: @@ -41,9 +45,14 @@ def authorization_function(username, password): @app.callback(Output("graph", "figure"), [Input("dropdown", "value")]) def update_graph(dropdown_value): + """Build the figure shown for the selected dropdown value. + + Returns: + A Plotly figure dict for the selected dropdown value. + """ return { "layout": { - "title": "Graph of {}".format(dropdown_value), + "title": f"Graph of {dropdown_value}", "margin": {"l": 20, "b": 20, "r": 10, "t": 60}, }, "data": [{"x": [1, 2, 3], "y": [4, 1, 2]}], From ad779bfa1e5c6897f279b0d043757405fcf8c426 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:43:44 +0200 Subject: [PATCH 09/10] [docs]: Fix leftover ruff issues. --- dash_auth_async/__init__.py | 7 +--- dash_auth_async/_optional.py | 24 +++++++++++ dash_auth_async/websocket_auth.py | 67 ++++++++++++++++++------------- 3 files changed, 65 insertions(+), 33 deletions(-) create mode 100644 dash_auth_async/_optional.py diff --git a/dash_auth_async/__init__.py b/dash_auth_async/__init__.py index fc25539..7ddac88 100644 --- a/dash_auth_async/__init__.py +++ b/dash_auth_async/__init__.py @@ -1,14 +1,9 @@ """Authentication and authorization for Dash apps on sync and async backends.""" +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 - -# oidc auth requires authlib, install with `pip install dash-auth[oidc]` -try: - from .oidc_auth import OIDCAuth, get_oauth -except ModuleNotFoundError: - pass from .version import __version__ __all__ = [ diff --git a/dash_auth_async/_optional.py b/dash_auth_async/_optional.py new file mode 100644 index 0000000..8deee8c --- /dev/null +++ b/dash_auth_async/_optional.py @@ -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"] diff --git a/dash_auth_async/websocket_auth.py b/dash_auth_async/websocket_auth.py index 845dfcb..de8fdfa 100644 --- a/dash_auth_async/websocket_auth.py +++ b/dash_auth_async/websocket_auth.py @@ -50,9 +50,9 @@ def submit(self, fn, /, *args, **kwargs): def _ws_message_hook(ws: Any, message: Any): """Global Dash websocket_message hook: authorize each callback_request. - Resolves the owning app via ``quart.current_app`` so it is correct when - several apps share the process; inert for apps that do not use - dash-auth-async. + The single fail-closed boundary for WebSocket auth: any unexpected error + while deciding rejects the socket. The decision itself lives in + ``_authorize_ws_message``. Returns: A truthy value to allow, or a ``(code, reason)`` tuple to reject @@ -61,35 +61,48 @@ def _ws_message_hook(ws: Any, message: Any): if not isinstance(message, dict) or message.get("type") != "callback_request": return True try: - import quart # noqa: PLC0415 — quart is an optional dependency - - # ``quart.current_app`` is a proxy; ``_get_current_object`` unwraps it to - # the real Quart app (the key in ``_AUTH_BY_SERVER``). The attribute is - # present at runtime but absent from the proxy's type stub, so go through - # ``getattr`` to keep the static type checker happy. - current_app: Any = quart.current_app - app = getattr(current_app, "_get_current_object")() - auth = _AUTH_BY_SERVER.get(app) - if auth is None: - # Not a dash-auth-async app: nothing to enforce. Safe because the - # registry entry is created by the developer's ``Auth(app, ...)`` - # call, not by the client -- an attacker cannot evict their own app. - return True - payload = message.get("payload", {}) or {} - user = quart.session.get("user") - if auth.authorize_ws(payload, user): - # Load-bearing invariant: this hook runs before every callback_request - # is submitted to the executor, so the context-copying executor always - # snapshots the user set here -- a stale value from a prior message can - # never reach a worker. ``set`` (never ``reset``) is therefore safe. - _WS_AUTH_USER.set(user) - return True - return (4401, "Unauthorized") + return _authorize_ws_message(message) except Exception: # pylint: disable=broad-exception-caught # Fail closed on any unexpected error. return (4401, "Unauthorized") +def _authorize_ws_message(message: dict) -> bool | tuple[int, str]: + """Authorize one WebSocket ``callback_request`` for the current Quart app. + + Resolves the owning app via ``quart.current_app`` so it is correct when + several apps share the process; inert for apps that do not use + dash-auth-async. + + Returns: + ``True`` to allow, or a ``(code, reason)`` tuple to reject the socket. + """ + import quart # noqa: PLC0415 — quart is an optional dependency + + # ``quart.current_app`` is a proxy; ``_get_current_object`` unwraps it to + # the real Quart app (the key in ``_AUTH_BY_SERVER``). The attribute is + # present at runtime but absent from the proxy's type stub, so go through + # ``getattr`` to keep the static type checker happy. + current_app: Any = quart.current_app + app = getattr(current_app, "_get_current_object")() + auth = _AUTH_BY_SERVER.get(app) + if auth is None: + # Not a dash-auth-async app: nothing to enforce. Safe because the + # registry entry is created by the developer's ``Auth(app, ...)`` + # call, not by the client -- an attacker cannot evict their own app. + return True + payload = message.get("payload", {}) or {} + user = quart.session.get("user") + if auth.authorize_ws(payload, user): + # Load-bearing invariant: this hook runs before every callback_request + # is submitted to the executor, so the context-copying executor always + # snapshots the user set here -- a stale value from a prior message can + # never reach a worker. ``set`` (never ``reset``) is therefore safe. + _WS_AUTH_USER.set(user) + return True + return (4401, "Unauthorized") + + def _ensure_hook_registered() -> None: """Register the global websocket_message hook exactly once per process.""" global _hook_registered # noqa: PLW0603 — register the hook once per process From 70504de29f1436221c943d47504777199b255026 Mon Sep 17 00:00:00 2001 From: "jonas.schrage" <119843859+joschrag@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:47:09 +0200 Subject: [PATCH 10/10] [chore]: Bump cryptography to fix GHSA OpenSSL advisory (https://github.com/joschrag/dash-auth-async/security/dependabot/6). --- pyproject.toml | 3 ++ uv.lock | 104 ++++++++++++++++++++++++------------------------- 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e3f9486..9ab916b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,9 @@ dev = [ [tool.uv] constraint-dependencies = [ "setuptools<80", + # GHSA: cryptography wheels < 48.0.1 bundle a vulnerable OpenSSL + # (advisory 2026-06-09). Pulled in transitively via authlib. + "cryptography>=48.0.1", ] override-dependencies = [ "urllib3>=2.7.0", diff --git a/uv.lock b/uv.lock index 827b4c3..9e56cdf 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,10 @@ resolution-markers = [ ] [manifest] -constraints = [{ name = "setuptools", specifier = "<80" }] +constraints = [ + { name = "cryptography", specifier = ">=48.0.1" }, + { name = "setuptools", specifier = "<80" }, +] overrides = [ { name = "selenium", specifier = ">=4.6.0" }, { name = "urllib3", specifier = ">=2.7.0" }, @@ -490,62 +493,59 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.0" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, - { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, - { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, - { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, - { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, - { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, - { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, - { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]]