From 95f571efc9588bfa4da07ec09fa3f28c2a3a47b6 Mon Sep 17 00:00:00 2001 From: faisalsaificode Date: Sat, 4 Apr 2026 01:04:30 +0530 Subject: [PATCH] Add AuthStaticFiles to support authentication for static file serving Adds a new AuthStaticFiles class that extends StaticFiles with an `auth` parameter, allowing users to protect static files behind authentication. This addresses a long-standing feature request (issue #858) where mounted static files bypass FastAPI's dependency injection and serve files without any access control. Closes #858 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../static_files/tutorial002_auth_py310.py | 19 +++ fastapi/staticfiles.py | 91 +++++++++++ tests/test_auth_static_files.py | 154 ++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 docs_src/static_files/tutorial002_auth_py310.py create mode 100644 tests/test_auth_static_files.py diff --git a/docs_src/static_files/tutorial002_auth_py310.py b/docs_src/static_files/tutorial002_auth_py310.py new file mode 100644 index 0000000000..b9cd40e404 --- /dev/null +++ b/docs_src/static_files/tutorial002_auth_py310.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI, HTTPException, Request +from fastapi.staticfiles import AuthStaticFiles + + +async def verify_token(request: Request) -> None: + """Check for a valid Bearer token in the Authorization header.""" + token = request.headers.get("Authorization") + if token != "Bearer mysecrettoken": + raise HTTPException(status_code=401, detail="Not authenticated") + + +app = FastAPI() + +# Private files - requires a valid token to access +app.mount( + "/private", + AuthStaticFiles(directory="private_files", auth=verify_token), + name="private", +) diff --git a/fastapi/staticfiles.py b/fastapi/staticfiles.py index 299015d4fe..548b27b944 100644 --- a/fastapi/staticfiles.py +++ b/fastapi/staticfiles.py @@ -1 +1,92 @@ +from typing import Any, Awaitable, Callable + +from starlette.requests import Request +from starlette.responses import JSONResponse from starlette.staticfiles import StaticFiles as StaticFiles # noqa +from starlette.types import Receive, Scope, Send + + +class AuthStaticFiles(StaticFiles): + """ + A static files handler that requires authentication before serving files. + + This solves the problem where `app.mount("/static", StaticFiles(...))` serves + files without any authentication, making it impossible to protect private files. + + `AuthStaticFiles` accepts an `auth` callable that receives a `Request` and + should either return successfully (authenticated) or raise an `HTTPException` + (not authenticated). + + ## Usage + + ```python + from fastapi import FastAPI, HTTPException, Request + from fastapi.staticfiles import AuthStaticFiles + + app = FastAPI() + + + async def verify_token(request: Request) -> None: + token = request.headers.get("Authorization") + if token != "Bearer mysecrettoken": + raise HTTPException(status_code=401, detail="Unauthorized") + + + app.mount( + "/private", + AuthStaticFiles(directory="private_files", auth=verify_token), + name="private", + ) + ``` + + ## Parameters + + * `auth`: An async callable that takes a `Request` object and performs + authentication. It should raise an `HTTPException` if authentication + fails, or return `None` if authentication succeeds. + * `directory`: The directory to serve files from. + * `packages`: A list of Python packages to serve files from. + * `html`: If `True`, serves `index.html` files for directories. + * `check_dir`: If `True`, checks that the directory exists on startup. + * `follow_symlink`: If `True`, follows symbolic links. + + Ref: https://github.com/fastapi/fastapi/issues/858 + """ + + def __init__( + self, + *, + directory: str | None = None, + packages: list[str | tuple[str, str]] | None = None, + html: bool = False, + check_dir: bool = True, + follow_symlink: bool = False, + auth: Callable[[Request], Awaitable[Any]], + ) -> None: + super().__init__( + directory=directory, + packages=packages, + html=html, + check_dir=check_dir, + follow_symlink=follow_symlink, + ) + self.auth = auth + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "http": + request = Request(scope, receive) + try: + await self.auth(request) + except Exception as exc: + from fastapi.exceptions import HTTPException + + if isinstance(exc, HTTPException): + response = JSONResponse( + {"detail": exc.detail}, + status_code=exc.status_code, + headers=getattr(exc, "headers", None), + ) + await response(scope, receive, send) + return + raise + await super().__call__(scope, receive, send) diff --git a/tests/test_auth_static_files.py b/tests/test_auth_static_files.py new file mode 100644 index 0000000000..d71c15202a --- /dev/null +++ b/tests/test_auth_static_files.py @@ -0,0 +1,154 @@ +import os +from pathlib import Path + +import pytest +from fastapi import FastAPI, HTTPException, Request +from fastapi.staticfiles import AuthStaticFiles +from fastapi.testclient import TestClient + + +@pytest.fixture(scope="module") +def static_dir(tmp_path_factory): + d = tmp_path_factory.mktemp("static") + (d / "public.txt").write_text("public content") + (d / "secret.txt").write_text("secret content") + return d + + +async def verify_token(request: Request) -> None: + """Simple token-based auth for testing.""" + token = request.headers.get("Authorization") + if token != "Bearer valid-token": + raise HTTPException(status_code=401, detail="Not authenticated") + + +@pytest.fixture(scope="module") +def app(static_dir): + app = FastAPI() + + # Public static files (no auth) + app.mount( + "/public", + AuthStaticFiles( + directory=str(static_dir), + auth=_allow_all, + ), + name="public", + ) + + # Private static files (requires auth) + app.mount( + "/private", + AuthStaticFiles( + directory=str(static_dir), + auth=verify_token, + ), + name="private", + ) + + return app + + +async def _allow_all(request: Request) -> None: + """Auth function that allows all requests.""" + pass + + +@pytest.fixture(scope="module") +def client(app): + with TestClient(app) as c: + yield c + + +def test_private_file_without_auth(client: TestClient): + """Requesting a private file without auth should return 401.""" + response = client.get("/private/secret.txt") + assert response.status_code == 401 + assert response.json() == {"detail": "Not authenticated"} + + +def test_private_file_with_wrong_token(client: TestClient): + """Requesting a private file with wrong token should return 401.""" + response = client.get( + "/private/secret.txt", + headers={"Authorization": "Bearer wrong-token"}, + ) + assert response.status_code == 401 + assert response.json() == {"detail": "Not authenticated"} + + +def test_private_file_with_valid_token(client: TestClient): + """Requesting a private file with valid token should return the file.""" + response = client.get( + "/private/secret.txt", + headers={"Authorization": "Bearer valid-token"}, + ) + assert response.status_code == 200 + assert response.text == "secret content" + + +def test_private_file_not_found_with_valid_token(client: TestClient): + """Requesting a non-existent private file with valid auth should return 404.""" + response = client.get( + "/private/nonexistent.txt", + headers={"Authorization": "Bearer valid-token"}, + ) + assert response.status_code == 404 + + +def test_public_files_accessible(client: TestClient): + """Public mount with allow-all auth should serve files without auth.""" + response = client.get("/public/public.txt") + assert response.status_code == 200 + assert response.text == "public content" + + +def test_auth_headers_forwarded(static_dir): + """Auth errors with custom headers should forward them in the response.""" + + async def auth_with_headers(request: Request) -> None: + raise HTTPException( + status_code=401, + detail="Login required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + app = FastAPI() + app.mount( + "/protected", + AuthStaticFiles(directory=str(static_dir), auth=auth_with_headers), + name="protected", + ) + + with TestClient(app) as client: + response = client.get("/protected/public.txt") + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + assert response.json() == {"detail": "Login required"} + + +def test_cookie_based_auth(static_dir): + """AuthStaticFiles should work with cookie-based authentication.""" + + async def verify_cookie(request: Request) -> None: + session = request.cookies.get("session_id") + if session != "valid-session": + raise HTTPException(status_code=403, detail="Forbidden") + + app = FastAPI() + app.mount( + "/dashboard", + AuthStaticFiles(directory=str(static_dir), auth=verify_cookie), + name="dashboard", + ) + + with TestClient(app) as client: + # Without cookie + response = client.get("/dashboard/public.txt") + assert response.status_code == 403 + + # With valid cookie + client.cookies.set("session_id", "valid-session") + response = client.get("/dashboard/public.txt") + assert response.status_code == 200 + assert response.text == "public content"