diff --git a/fast_dash/Components.py b/fast_dash/Components.py
index bb6615e..2c0f500 100644
--- a/fast_dash/Components.py
+++ b/fast_dash/Components.py
@@ -437,6 +437,7 @@ def generate_navbar_container(self):
dmc.Group(
[
*(right_items or []),
+ *self._auth_header_items(),
dmc.Switch(
id="theme-toggle",
offLabel=DashIconify(icon="radix-icons:sun", width=16),
@@ -461,6 +462,28 @@ def generate_navbar_container(self):
return header_children
+ def _auth_header_items(self):
+ """Return header items for the auth gate (Sign out button).
+
+ Empty list when auth is disabled. Rendered just before the
+ theme toggle in the right-hand header group. Uses an html.A
+ wrapper because dmc.ActionIcon does not support href directly.
+ """
+ if not getattr(self.app, "_auth_config", None):
+ return []
+ return [
+ html.A(
+ dmc.Button(
+ "Sign out",
+ id="logout-button",
+ variant="subtle",
+ color="gray",
+ size="compact-sm",
+ ),
+ href="/logout",
+ )
+ ]
+
def generate_input_component(self):
"""Build the sidebar navbar content with inputs."""
sidebar_children = []
diff --git a/fast_dash/__init__.py b/fast_dash/__init__.py
index 2a8118c..03cfc72 100644
--- a/fast_dash/__init__.py
+++ b/fast_dash/__init__.py
@@ -34,6 +34,7 @@
from dash import Output, Input, State, callback, no_update
from fast_dash.fast_dash import FastDash, fastdash, update, notify
from fast_dash.utils import Fastify, depends_on, from_step
+from fast_dash.auth import current_user
__all__ = [
"FastDash",
@@ -41,6 +42,7 @@
"Fastify",
"depends_on",
"from_step",
+ "current_user",
"Text",
"TextArea",
"Slider",
diff --git a/fast_dash/auth.py b/fast_dash/auth.py
new file mode 100644
index 0000000..a460e69
--- /dev/null
+++ b/fast_dash/auth.py
@@ -0,0 +1,210 @@
+"""Lightweight password authentication for Fast Dash apps.
+
+Fast Dash apps default to no auth — anyone with the URL can use them.
+This module adds an opt-in password gate so a developer can share an
+app behind a tunnel (cloudflared, ngrok, ...) without exposing it to
+the public internet.
+
+Usage::
+
+ from fast_dash import fastdash
+
+ @fastdash(auth={"alice": "wonderland", "bob": "builder"})
+ def greet(name: str = "world") -> str:
+ return f"Hello, {name}!"
+
+The first time a visitor hits any URL on the app, they are redirected
+to ``/login``. After a successful login, a session cookie is set and
+they can access the app normally. ``/logout`` clears the session.
+
+The current user's name is exposed inside callbacks via
+``fast_dash.current_user()``.
+
+Security notes (read these before using in anger):
+- The user dict is stored in memory; passwords are compared in constant
+ time but **not hashed**. For shared/multi-user environments, hash
+ externally (e.g. ``argon2`` / ``bcrypt``) and supply a custom
+ ``verify_fn`` instead of a plain dict.
+- Sessions are signed with Flask's ``SECRET_KEY``. Fast Dash generates
+ a random key per process; restart-stable sessions require the user
+ to set ``FASTDASH_SECRET_KEY`` in the environment.
+- This is a single-tenant gate, not a full identity provider. For
+ Google / GitHub / Microsoft sign-in, a future ``auth="oidc"``
+ variant is planned.
+"""
+
+from __future__ import annotations
+
+import hmac
+import os
+import secrets
+from typing import Callable, Mapping
+
+from flask import (
+ Flask,
+ abort,
+ g,
+ redirect,
+ render_template_string,
+ request,
+ session,
+ url_for,
+)
+
+
+# ---- public surface --------------------------------------------------
+
+
+def current_user() -> str | None:
+ """Return the username for the active request, or ``None``.
+
+ Safe to call from inside any Fast Dash callback. Returns ``None``
+ when auth is disabled or when called outside a request context.
+ """
+ try:
+ return session.get("_fastdash_user")
+ except RuntimeError:
+ # Outside an app/request context.
+ return None
+
+
+# ---- configuration ---------------------------------------------------
+
+
+def _normalize_auth_config(auth) -> _AuthConfig | None:
+ """Turn whatever the user passed into a typed config object.
+
+ Accepts:
+ - ``None`` / ``False`` → auth disabled (returns ``None``).
+ - ``{"alice": "secret"}`` → password dict.
+ - A callable ``verify_fn(username, password) -> bool`` for custom
+ verification (e.g. against a hashed store).
+ """
+ if not auth:
+ return None
+ if callable(auth):
+ return _AuthConfig(verify=auth)
+ if isinstance(auth, Mapping):
+ users = dict(auth)
+ return _AuthConfig(verify=lambda u, p: _verify_against_dict(users, u, p))
+ raise TypeError(
+ "auth= must be a dict of {username: password}, a verify function, "
+ f"or None. Got {type(auth).__name__}."
+ )
+
+
+class _AuthConfig:
+ """Bundle of the verifier callable used by the middleware."""
+
+ def __init__(self, verify: Callable[[str, str], bool]):
+ self.verify = verify
+
+
+def _verify_against_dict(users: dict, username: str, password: str) -> bool:
+ """Constant-time check of (username, password) against a user dict."""
+ expected = users.get(username)
+ if expected is None:
+ # Hash comparison still runs against a known-bad value to keep
+ # timing constant whether or not the username exists.
+ return hmac.compare_digest(b"x", b"y")
+ return hmac.compare_digest(
+ password.encode("utf-8"), str(expected).encode("utf-8")
+ )
+
+
+# ---- middleware ------------------------------------------------------
+
+
+_LOGIN_TEMPLATE = """\
+
+
+
+
+ Sign in{% if title %} — {{ title }}{% endif %}
+
+
+
+
+
+
+
+"""
+
+
+def install_auth(server: Flask, config: _AuthConfig, title: str | None = None) -> None:
+ """Wire login / logout routes and a ``before_request`` gate onto the app."""
+
+ # Random per-process key. Users who restart frequently and want
+ # session continuity should set FASTDASH_SECRET_KEY.
+ if not server.secret_key:
+ server.secret_key = os.environ.get(
+ "FASTDASH_SECRET_KEY", secrets.token_hex(32)
+ )
+
+ open_paths = {"/login", "/logout"}
+
+ @server.before_request
+ def _require_login():
+ if request.path in open_paths:
+ return None
+ if request.path.startswith("/_dash") and "_fastdash_user" not in session:
+ # Dash's internal endpoints (callback updates, asset bundles)
+ # would otherwise leak app state to unauthenticated callers.
+ abort(401)
+ if "_fastdash_user" not in session:
+ return redirect(url_for("_fastdash_login", next=request.path))
+ g.fastdash_user = session["_fastdash_user"]
+ return None
+
+ @server.route("/login", methods=["GET", "POST"])
+ def _fastdash_login():
+ next_url = request.values.get("next") or "/"
+ if request.method == "POST":
+ username = (request.form.get("username") or "").strip()
+ password = request.form.get("password") or ""
+ if config.verify(username, password):
+ session.clear()
+ session["_fastdash_user"] = username
+ session.permanent = False
+ return redirect(next_url)
+ return (
+ render_template_string(
+ _LOGIN_TEMPLATE,
+ error="Invalid username or password.",
+ next=next_url,
+ title=title,
+ ),
+ 401,
+ )
+ return render_template_string(
+ _LOGIN_TEMPLATE, error=None, next=next_url, title=title
+ )
+
+ @server.route("/logout")
+ def _fastdash_logout():
+ session.clear()
+ return redirect(url_for("_fastdash_login"))
diff --git a/fast_dash/fast_dash.py b/fast_dash/fast_dash.py
index 2cbe956..d9dffa6 100644
--- a/fast_dash/fast_dash.py
+++ b/fast_dash/fast_dash.py
@@ -127,6 +127,7 @@ def __init__(
run_kwargs=dict(),
tab_titles=None,
steps=None,
+ auth=None,
**kwargs
):
"""
@@ -205,6 +206,13 @@ def __init__(
steps (list of funcs, optional): A linear pipeline of step functions. Each step gets its own \
page in a stepper UI; outputs of earlier steps can feed downstream steps via ``from_step``. \
When provided, ``callback_fn`` is ignored. Defaults to None.
+
+ auth (dict or callable, optional): Enable a password gate in front of the app. \
+ Pass ``{"alice": "secret", ...}`` for a static user/password dict, or a \
+ callable ``(username, password) -> bool`` for custom verification (e.g. \
+ against a hashed store). When set, every URL except ``/login`` and \
+ ``/logout`` requires a valid session. Use ``fast_dash.current_user()`` \
+ inside callbacks to read the signed-in username. Defaults to None (no auth).
"""
# Detect pipeline (steps) mode
@@ -293,6 +301,15 @@ def __init__(
self.server = self.app.server
self.callback = self.app.callback
+ # Optional password gate. Installs before any callbacks so
+ # /login and /logout are reachable without auth, and every
+ # other request gets redirected if the user isn't signed in.
+ from .auth import _normalize_auth_config, install_auth
+
+ self._auth_config = _normalize_auth_config(auth)
+ if self._auth_config is not None:
+ install_auth(self.server, self._auth_config, title=title)
+
if stream == True:
socketio = SocketIO(self.app.server)
@@ -1658,6 +1675,7 @@ def fastdash(
disable_logs=False,
scale_height=1,
run_kwargs=dict(),
+ auth=None,
**kwargs
):
"""
@@ -1770,6 +1788,7 @@ def wrapper_fastdash(**kwargs):
disable_logs=disable_logs,
scale_height=scale_height,
run_kwargs=run_kwargs,
+ auth=auth,
**kwargs
)
diff --git a/tests/test_auth.py b/tests/test_auth.py
new file mode 100644
index 0000000..f8c6e5d
--- /dev/null
+++ b/tests/test_auth.py
@@ -0,0 +1,222 @@
+"""Tests for the password-auth gate added in v0.2.17.
+
+These tests use Flask's built-in test client so we can exercise the
+middleware without a browser. Each test builds a small FastDash app
+with ``auth={...}`` or ``auth=callable`` and walks through the login
+flow.
+"""
+
+import pytest
+
+from fast_dash import FastDash, current_user
+
+
+def _make_app(auth=None, callback=None):
+ """Helper: build a tiny FastDash app with optional auth."""
+ if callback is None:
+ def callback(name: str = "world") -> str:
+ return f"Hello, {name}!"
+ return FastDash(callback_fn=callback, auth=auth)
+
+
+# --- baseline: no auth ----------------------------------------------------
+
+
+class TestAuthDisabled:
+ def test_default_app_has_no_auth(self):
+ app = _make_app()
+ assert app._auth_config is None
+
+ def test_app_root_is_reachable_without_login_when_auth_off(self):
+ app = _make_app()
+ client = app.server.test_client()
+ resp = client.get("/")
+ assert resp.status_code == 200
+
+ def test_no_login_form_when_auth_disabled(self):
+ app = _make_app()
+ client = app.server.test_client()
+ # When auth is off, /login isn't claimed by the auth blueprint —
+ # it falls through to Dash's catch-all (which serves the SPA),
+ # so the login form is never rendered.
+ resp = client.get("/login")
+ assert b"Sign in" not in resp.data
+
+ def test_current_user_returns_none_outside_request_context(self):
+ # No exception, no login — just None.
+ assert current_user() is None
+
+
+# --- dict-based auth ------------------------------------------------------
+
+
+class TestDictAuth:
+ def test_unauthenticated_root_redirects_to_login(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ client = app.server.test_client()
+ resp = client.get("/")
+ assert resp.status_code == 302
+ assert "/login" in resp.headers["Location"]
+
+ def test_login_page_renders(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ client = app.server.test_client()
+ resp = client.get("/login")
+ assert resp.status_code == 200
+ assert b"Sign in" in resp.data
+
+ def test_correct_credentials_grant_access(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ client = app.server.test_client()
+ resp = client.post(
+ "/login",
+ data={"username": "alice", "password": "wonderland", "next": "/"},
+ follow_redirects=False,
+ )
+ assert resp.status_code == 302
+ # Now the root should be reachable on the authenticated session.
+ with client.session_transaction() as s:
+ assert s["_fastdash_user"] == "alice"
+ resp = client.get("/")
+ assert resp.status_code == 200
+
+ def test_wrong_password_returns_401_with_error(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ client = app.server.test_client()
+ resp = client.post(
+ "/login",
+ data={"username": "alice", "password": "WRONG", "next": "/"},
+ )
+ assert resp.status_code == 401
+ assert b"Invalid username or password" in resp.data
+
+ def test_unknown_user_returns_401(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ client = app.server.test_client()
+ resp = client.post(
+ "/login",
+ data={"username": "mallory", "password": "anything", "next": "/"},
+ )
+ assert resp.status_code == 401
+
+ def test_logout_clears_session(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ client = app.server.test_client()
+ client.post(
+ "/login",
+ data={"username": "alice", "password": "wonderland", "next": "/"},
+ )
+ resp = client.get("/logout", follow_redirects=False)
+ assert resp.status_code == 302
+ assert "/login" in resp.headers["Location"]
+ with client.session_transaction() as s:
+ assert "_fastdash_user" not in s
+
+ def test_dash_internal_endpoints_blocked_when_unauthenticated(self):
+ """Dash's _dash-* endpoints would otherwise leak callback state."""
+ app = _make_app(auth={"alice": "wonderland"})
+ client = app.server.test_client()
+ # Pick a Dash endpoint that exists by default.
+ resp = client.get("/_dash-layout")
+ assert resp.status_code == 401
+
+ def test_login_preserves_next_url(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ client = app.server.test_client()
+ resp = client.get("/some/path")
+ assert resp.status_code == 302
+ # The redirect should embed /some/path as the `next` query arg
+ # so a successful login bounces the user back where they came from.
+ location = resp.headers["Location"]
+ assert "/some/path" in location and "next=" in location
+
+
+# --- callable verifier ---------------------------------------------------
+
+
+class TestCallableAuth:
+ def test_custom_verify_fn_is_called(self):
+ calls = []
+
+ def verify(u, p):
+ calls.append((u, p))
+ return u == "carol" and p == "topsecret"
+
+ app = _make_app(auth=verify)
+ client = app.server.test_client()
+ resp = client.post(
+ "/login",
+ data={"username": "carol", "password": "topsecret", "next": "/"},
+ )
+ assert resp.status_code == 302
+ assert ("carol", "topsecret") in calls
+
+ def test_custom_verify_fn_can_reject(self):
+ app = _make_app(auth=lambda u, p: False)
+ client = app.server.test_client()
+ resp = client.post(
+ "/login",
+ data={"username": "anyone", "password": "anything", "next": "/"},
+ )
+ assert resp.status_code == 401
+
+
+# --- input validation ----------------------------------------------------
+
+
+class TestAuthConfigValidation:
+ def test_invalid_auth_type_raises(self):
+ with pytest.raises(TypeError, match="auth="):
+ _make_app(auth=12345)
+
+ def test_falsy_values_disable_auth(self):
+ for falsy in (None, False, {}):
+ app = _make_app(auth=falsy)
+ assert app._auth_config is None
+
+
+# --- header logout button -----------------------------------------------
+
+
+class TestLogoutButton:
+ def test_logout_button_in_header_when_auth_enabled(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ layout_str = str(app.app.layout)
+ assert "logout-button" in layout_str
+ assert "Sign out" in layout_str
+
+ def test_no_logout_button_when_auth_disabled(self):
+ app = _make_app()
+ layout_str = str(app.app.layout)
+ assert "logout-button" not in layout_str
+ assert "Sign out" not in layout_str
+
+ def test_logout_button_links_to_logout_route(self):
+ app = _make_app(auth={"alice": "wonderland"})
+ layout_str = str(app.app.layout)
+ # The button is wrapped in an .
+ assert "/logout" in layout_str
+
+
+# --- current_user inside a request --------------------------------------
+
+
+class TestCurrentUserBinding:
+ def test_current_user_is_accessible_inside_a_request(self):
+ app = _make_app(auth={"alice": "wonderland"})
+
+ # Register the probe route *before* the first request lands —
+ # Flask freezes the route table after that.
+ @app.server.route("/_whoami")
+ def _whoami():
+ return current_user() or "anonymous"
+
+ client = app.server.test_client()
+ client.post(
+ "/login",
+ data={"username": "alice", "password": "wonderland", "next": "/"},
+ )
+
+ resp = client.get("/_whoami")
+ assert resp.status_code == 200
+ assert resp.data == b"alice"