Skip to content

Feature: Add async fastapi support#9

Open
joschrag wants to merge 37 commits into
mainfrom
feature/add-async-fastapi-support
Open

Feature: Add async fastapi support#9
joschrag wants to merge 37 commits into
mainfrom
feature/add-async-fastapi-support

Conversation

@joschrag

Copy link
Copy Markdown
Owner

Add FastAPI backend support for authenticated Dash apps

Adds a FastAPIBackend so dash-auth-async can protect Dash 4.x apps served on
FastAPI/Starlette — covering BasicAuth, OIDC, group protection, and authenticated
WebSocket callbacks — alongside the existing Flask and Quart backends.

Added

  • FastAPI backend (FastAPIBackend) — request/session proxies via ContextVar,
    url_for/redirect, response coercion, and detect_backend wiring
  • Pure-ASGI auth middleware — register_auth_hook with request-body replay (re-emits
    http.disconnect once the body is consumed)
  • FastAPI session handling — setup_session/session_configured, secure_session wired
    through, hardened session lookup under -O
  • OIDC on FastAPI — backend-polymorphic OIDC dispatch, FastAPI OIDC views,
    make_oauth/get_oauth state branch, annotated view params so Starlette injects the request
  • FastAPI WebSocket callback authentication — auth via Backend.ws_identity, lazy
    callback_map migration in the message hook, fails closed
  • Backend-routed public routes/callbacks — storage via server.state instead of the
    global fallback
  • Quart WebSocket auth example (examples/websocket_auth_quart/) — public live
    counter/clock page + private progress-task page with design/plan docs and README
  • FastAPI-specific tests — BasicAuth integration matrix (groups + auth_func), OIDC
    integration (mocked IDP) + wiring, OIDC state/CSRF, WebSocket security +
    protected-callback streaming, and unit tests for the backend, group protection, and public routes
  • FastAPI dev/test dependency stack (pyproject.toml, uv.lock)

Changed

  • Backend ABC — added divergent-operation methods with Flask/Quart defaults so
    backends stay polymorphic instead of isinstance-dispatched
  • OIDC auth — replaced isinstance dispatch with backend polymorphism; merged the
    duplicated view triplets
  • _current_user — guarded against an unavailable session (FastAPI no-session path)
  • BasicAuth — routes secret_key through backend.setup_session
  • README — added a "FastAPI (async) backend" section and marked it supported
  • Test suite — renamed Quart/OIDC test modules for backend clarity (*_quart,
    *_fastapi), re-ided auth_func login tests off ba002/ba003, graceful threaded-server
    shutdown in conftest.py
  • Lint config — softened the inline-noqa rule to discourage rather than disallow;
    scoped docstring rules off examples; ignore local scratch dirs

Fixed

  • Public-route helpers resolve their backend from app.server rather than the
    process-global fallback
  • FastAPI OIDC views annotate request params so Starlette performs injection correctly

@joschrag joschrag self-assigned this Jun 18, 2026
@joschrag joschrag added documentation Improvements or additions to documentation Feature Implements a bigger feature into the project labels Jun 18, 2026
@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.95349% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
dash_auth_async/backends.py 93.06% 12 Missing ⚠️
dash_auth_async/oidc_auth.py 95.83% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@joschrag joschrag force-pushed the feature/add-async-fastapi-support branch from 75e3c34 to 7303a35 Compare June 18, 2026 13:53
joschrag added 25 commits June 18, 2026 15:54
The loop's last iteration (pct=100) already sets task-bar to 100, and the
is_shutdown early-return path skips the post-loop line too, so it was dead.
…ession lookup under -O.

secure_session was silently dropped on FastAPI; route it through
Backend.setup_session so it maps to SessionMiddleware https_only, matching
the Flask/Quart SESSION_COOKIE_SECURE behaviour. Replace the FastAPI
session lookup's reliance on Starlette's bare `assert "session" in scope`
(stripped under python -O, which would surface a KeyError) with an explicit
scope check that raises RuntimeError consistently.
…/ba005.

ba002/ba003 collided with the groups test ids; renumber the auth_func login
and misconfiguration tests across the Flask, Quart and FastAPI variants so the
short-id prefixes stay unique and parallel across backends.
…ples.

Keep scripts/, .coverage and .vscode/ out of the tracked tree so ruff (which
respects .gitignore) stays green, and add an examples/** per-file-ignore for
the D/DOC rule families. Example apps are runnable demos, not shipped library
code, so forced docstring coverage on every demo page was the only thing
keeping `ruff check .` red on this branch.
…ic_routes.

get_public_routes/get_public_callbacks run inside _authorize on every request
yet rebuilt a Backend and re-ran the isinstance chain each time. Route all four
public_routes sites through get_active_backend() — the process-global Auth.__init__
already caches for exactly this "no instance in scope" case — dropping per-request
allocation churn. Construct the Flask fallback lazily inside get_active_backend()
instead of as an eager module-level _DEFAULT_BACKEND, so Flask is no longer cemented
as the default at import. Behaviour is identical under the one-backend-per-process
model: store/read_config take the server explicitly, so the resolved backend only
supplies the storage strategy, which already matches the running server.
joschrag added 11 commits June 18, 2026 15:54
…m and merge the view triplets.

OIDCAuth branched on isinstance(backend, FastAPIBackend/QuartBackend) to pick a
sync, _async, or _fastapi view set, and the _fastapi triplet differed from _async
only in passing the Starlette request to authlib and coercing responses by hand.

Push those differences into the backend: add Backend.is_async, Backend.get_oauth
(symmetric with make_oauth so retrieval knows where storage put the registry), and
Backend.oauth_authorize_redirect/oauth_authorize_access_token (the FastAPI overrides
inject the ContextVar-resolved request the Starlette client needs). OIDCAuth now
selects views on backend.is_async, the Quart and FastAPI paths share one _async
view set, and every async view funnels its return through backend.coerce_response —
the single coercion boundary, with a bare str now rendered as HTML to match
Flask/Quart and absorb the old logout special case. get_oauth delegates to the
backend, dropping the StarletteRequest re-export leak from oidc_auth. Verified by
the full OIDC integration suite (Flask/Quart/FastAPI) and the wiring tests.
The FastAPI auth middleware caches and replays the callback body so Dash's inner
middleware can re-parse it, but the replacement receive returned the same
http.request event on every call. An ASGI app that polls receive() after the body
to detect a client disconnect would loop on the same body event. Track whether the
body was delivered and return http.disconnect on subsequent reads, per the ASGI
contract. Dash's inner middleware doesn't poll, so this was latent, not breaking.
…state validation.

Pin the negative paths the new code most needs locked down: the auth middleware's
unparseable-body branch (malformed JSON reaches decide as None) and its
short-circuit-after-body-parse branch (decide blocks when needs_body is True), plus
the replayed receive emitting http.disconnect once the body is consumed. Add a Flask
integration test that drives the real authlib state path end to end — /login stores
the state, and /callback with a tampered or missing state is rejected 401 — so the
anti-CSRF invariant the browser tests mock past stays covered against authlib changes.
…t Auth construction.

Calling app._setup_server() inside enable_ws_auth ran Dash's server setup at
Auth(...) construction time -- before later module-level @callback registrations
and outside Dash's normal lifecycle. Move the GLOBAL_CALLBACK_MAP migration into
_authorize_ws_message, which already runs (and resolves the owning app) just
before Dash validates the request against callback_map. It is now lazy and
idempotent: fires on the first real WS callback_request, after every @callback
is registered, and is a single flag check thereafter. No construction-time
double initialization.
…tup.

The multi-app hook resolution test broke when the hook moved to
get_active_backend().ws_identity(ws) (it left the active backend unset, so the
FlaskBackend fallback's ws_identity raised and the hook failed closed to 4401).
Set a QuartBackend as active to exercise the Quart identity path the test
monkeypatches, give the Auth double an app stub for the lazy _setup_server()
migration, and assert only the owning app's map is set up.
… the global fallback.

add_public_routes, get_public_routes, and get_public_callbacks called
get_active_backend() -- the process-global that falls back to FlaskBackend when
no Auth has set it -- then store_config/read_config on app.server. On a FastAPI
app the fallback writes to the nonexistent server.config (AttributeError);
detect_backend(app.server) routes through server.state instead. Brings the three
siblings in line with public_callback, which was already fixed this way. Adds a
regression test that exercises the helpers on a FastAPI app with the active
backend left at its Flask fallback.
@joschrag joschrag force-pushed the feature/add-async-fastapi-support branch from 7303a35 to fa38ff8 Compare June 18, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation Feature Implements a bigger feature into the project

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant