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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,25 @@ uv add "fastapi-multiauth[jwt,oauth]"

```python
from fastapi import FastAPI, Security
from fastapi_multiauth import HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError
from fastapi_multiauth import (
HTTPBearerAuth,
APIKeyCookieAuth,
MultiAuth,
UnauthorizedError,
)


async def validate_token(token: str) -> dict:
user = await lookup_user_by_token(token)
if user is None:
raise UnauthorizedError()
return user


async def validate_session(value: str) -> dict:
return await lookup_user_by_session(value)


bearer = HTTPBearerAuth(validate_token, prefix="user_")
session = APIKeyCookieAuth("session", validate_session, secret_key="...")

Expand All @@ -57,6 +65,7 @@ auth = MultiAuth(bearer, session)

app = FastAPI()


@app.get("/me")
async def me(user=Security(auth)):
return user
Expand Down
11 changes: 10 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,25 @@ uv add "fastapi-multiauth[jwt,oauth]"

```python
from fastapi import FastAPI, Security
from fastapi_multiauth import HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError
from fastapi_multiauth import (
HTTPBearerAuth,
APIKeyCookieAuth,
MultiAuth,
UnauthorizedError,
)


async def validate_token(token: str) -> dict:
user = await lookup_user_by_token(token)
if user is None:
raise UnauthorizedError()
return user


async def validate_session(value: str) -> dict:
return await lookup_user_by_session(value)


bearer = HTTPBearerAuth(validate_token, prefix="user_")
session = APIKeyCookieAuth("session", validate_session, secret_key="...")

Expand All @@ -57,6 +65,7 @@ auth = MultiAuth(bearer, session)

app = FastAPI()


@app.get("/me")
async def me(user=Security(auth)):
return user
Expand Down
6 changes: 3 additions & 3 deletions docs/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ from fastapi_multiauth.oauth import (
oauth_resolve_provider_urls,
)


@app.get("/oauth/login")
async def oauth_login(request: Request, next: str = "/"):
endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL)
Expand All @@ -40,15 +41,14 @@ async def oauth_login(request: Request, next: str = "/"):
code_challenge=code_challenge,
)


@app.get("/oauth/callback")
async def oauth_callback(request: Request, code: str, state: str | None = None):
endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL)
expected = request.session.pop("oauth_state", "") # single-use
code_verifier = request.session.pop("oauth_code_verifier", None)
# Open-redirect guard: by default only relative paths are accepted.
destination = oauth_decode_state(
state, expected_state_token=expected, fallback="/"
)
destination = oauth_decode_state(state, expected_state_token=expected, fallback="/")
try:
token = await oauth_exchange_code(
token_url=endpoints.token_endpoint,
Expand Down
22 changes: 17 additions & 5 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,35 @@ The flagship pattern: browser users authenticate with a signed session cookie, a
```python
from fastapi import FastAPI, Response, Security
from fastapi_multiauth import (
HTTPBearerAuth, APIKeyCookieAuth, MultiAuth, UnauthorizedError, hash_token,
HTTPBearerAuth,
APIKeyCookieAuth,
MultiAuth,
UnauthorizedError,
hash_token,
)


async def validate_session(user_id: str) -> User:
user = await db.get_user(user_id)
if user is None:
raise UnauthorizedError()
return user


async def validate_api_token(token: str) -> User:
row = await db.get_api_token(token_hash=hash_token(token))
if row is None or row.revoked:
raise UnauthorizedError()
return row.user


session = APIKeyCookieAuth("session", validate_session, secret_key=settings.SECRET_KEY)
api = HTTPBearerAuth(validate_api_token, prefix="user_")
auth = MultiAuth(api, session) # bearer first: API clients never hit cookie parsing

app = FastAPI()


@app.get("/me")
async def me(user: User = Security(auth)):
return user
Expand All @@ -44,10 +52,11 @@ user_tokens = HTTPBearerAuth(validate_user_token, prefix="user_")
org_tokens = HTTPBearerAuth(validate_org_token, prefix="org_")
auth = MultiAuth(user_tokens, org_tokens)


# Issuing: store the hash, hand out the token once:
@app.post("/tokens")
async def create_token(user: User = Security(session)):
token = user_tokens.generate_token() # "user_Xk3..."
token = user_tokens.generate_token() # "user_Xk3..."
await db.save_api_token(user.id, hash_token(token))
return {"token": token} # the only time it is ever visible
```
Expand Down Expand Up @@ -82,6 +91,7 @@ from fastapi_multiauth.oauth import (
oauth_resolve_provider_urls,
)


@app.get("/oauth/login")
async def oauth_login(request: Request, next: str = "/"):
endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL)
Expand All @@ -99,6 +109,7 @@ async def oauth_login(request: Request, next: str = "/"):
code_challenge=code_challenge,
)


@app.get("/oauth/callback")
async def oauth_callback(request: Request, code: str, state: str | None = None):
endpoints = await oauth_resolve_provider_urls(settings.OIDC_DISCOVERY_URL)
Expand Down Expand Up @@ -153,13 +164,14 @@ async def validate_session(user_id: str, *, role: str | None = None) -> User:
if user is None:
raise UnauthorizedError()
if role is not None and user.role != role:
raise ForbiddenError() # authenticated, but not allowed → 403
raise ForbiddenError() # authenticated, but not allowed → 403
return user


session = APIKeyCookieAuth("session", validate_session, secret_key=settings.SECRET_KEY)
admin_session = session.require(role="admin")


@app.get("/admin/stats")
async def stats(user: User = Security(admin_session)):
...
async def stats(user: User = Security(admin_session)): ...
```
19 changes: 15 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Every source wraps a callable you provide. It can be sync or async, receives the
```python
from fastapi_multiauth import UnauthorizedError


async def validate_token(token: str) -> User:
user = await db.get_user_by_token(token)
if user is None:
Expand All @@ -25,6 +26,7 @@ The same applies per route: `.require(**kwargs)` returns a copy of the source wi
```python
bearer = HTTPBearerAuth(validate_token)


@app.get("/admin")
async def admin(user=Security(bearer.require(role=Role.ADMIN))):
return user
Expand All @@ -46,6 +48,7 @@ bearer = HTTPBearerAuth(validate_token)

app = FastAPI()


@app.get("/me")
async def me(user=Security(bearer)):
return user
Expand All @@ -67,9 +70,10 @@ org_bearer = HTTPBearerAuth(validate_org_token, prefix="org_")
```python
from fastapi_multiauth import hash_token, verify_token_hash

token = user_bearer.generate_token() # "user_Xk3...": show it to the user once
token = user_bearer.generate_token() # "user_Xk3...": show it to the user once
await db.save_api_token(user_id, token_hash=hash_token(token))


async def validate_user_token(token: str) -> User:
row = await db.get_api_token(token_hash=hash_token(token))
if row is None:
Expand All @@ -91,15 +95,17 @@ session = APIKeyCookieAuth(
validate_session,
secret_key=settings.SECRET_KEY, # ≥ 32 bytes, enforced at startup
ttl=86400,
samesite="lax", # default; also: domain=..., path=...
samesite="lax", # default; also: domain=..., path=...
)


@app.post("/login")
async def login(response: Response, credentials: LoginForm):
user = await check_password(credentials)
session.set_cookie(response, str(user.id))
return {"ok": True}


@app.post("/logout")
async def logout(response: Response):
session.delete_cookie(response)
Expand Down Expand Up @@ -151,6 +157,7 @@ api_key = APIKeyQueryAuth("api_key", validate_api_key)
import secrets
from fastapi_multiauth import HTTPBasicAuth, UnauthorizedError


async def validate_basic(username: str, password: str) -> dict:
user = await db.get_user(username)
if user is None or not secrets.compare_digest(
Expand All @@ -159,6 +166,7 @@ async def validate_basic(username: str, password: str) -> dict:
raise UnauthorizedError()
return user


basic = HTTPBasicAuth(validate_basic, realm="api")
```

Expand All @@ -173,6 +181,7 @@ from fastapi_multiauth import MultiAuth

auth = MultiAuth(bearer, session)


@app.get("/me")
async def me(user=Security(auth)):
return user
Expand All @@ -189,11 +198,12 @@ async def validate_token(token: str, scopes: list[str]) -> User:
raise UnauthorizedError()
return user


bearer = HTTPBearerAuth(validate_token)


@app.post("/challenges")
async def create(user=Security(bearer, scopes=["challenges:write"])):
...
async def create(user=Security(bearer, scopes=["challenges:write"])): ...
```

!!! note "Scopes and OpenAPI"
Expand All @@ -220,6 +230,7 @@ bearer = HTTPBearerAuth(
)
)


@app.get("/me")
async def me(claims=Security(bearer)):
return claims
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ build-backend = "uv_build"
extend-select = ["S"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S"]
"tests/**" = ["S", "B008"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
2 changes: 1 addition & 1 deletion src/fastapi_multiauth/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
def _require_https(url: str, description: str) -> str:
"""Reject non-string URLs and URLs that would send credentials over plaintext HTTP."""
if not isinstance(url, str):
raise ValueError(f"{description} must be a string URL (got {url!r})")
raise ValueError(f"{description} must be a string URL (got {url!r})") # noqa: TRY004
parsed = urlsplit(url)
if parsed.scheme == "https":
return url
Expand Down
10 changes: 6 additions & 4 deletions src/fastapi_multiauth/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import copy
import inspect
from abc import ABC, abstractmethod
from typing import Annotated, Any, Callable, TypeVar
from collections.abc import Callable
from typing import TYPE_CHECKING, Annotated, Any

from fastapi import Depends, HTTPException, Request
from fastapi.security import SecurityScopes
Expand All @@ -12,7 +13,8 @@
from fastapi_multiauth.exceptions import UnauthorizedError
from fastapi_multiauth.utils import add_challenge, challenge_headers, ensure_async

_V = TypeVar("_V", bound="ValidatedAuthSource")
if TYPE_CHECKING:
from typing_extensions import Self


def _reject_scopes_kwarg(kwargs: dict[str, Any]) -> None:
Expand Down Expand Up @@ -71,7 +73,7 @@ def __init__(self, scheme: Any) -> None:
self.model = scheme.model
self.scheme_name = scheme.scheme_name

async def __call__(self, request: Request) -> None: # noqa: ARG002
async def __call__(self, request: Request) -> None:
return None


Expand Down Expand Up @@ -212,7 +214,7 @@ async def authenticate_scoped(self, credential: str, scopes: list[str]) -> Any:
"""Validate a credential, forwarding route-declared scopes to the validator."""
return await self._call_validator(credential, scopes=scopes)

def require(self: _V, **kwargs: Any) -> _V:
def require(self, **kwargs: Any) -> "Self":
"""Return a copy of this source with additional (or overriding) validator kwargs."""
_reject_scopes_kwarg(kwargs)
clone = copy.copy(self)
Expand Down
4 changes: 2 additions & 2 deletions src/fastapi_multiauth/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import logging
import time
from collections.abc import Sequence
from typing import Any, Callable
from collections.abc import Callable, Sequence
from typing import Any

import anyio
from fastapi import HTTPException, status
Expand Down
3 changes: 2 additions & 1 deletion src/fastapi_multiauth/sources/basic.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""HTTP Basic authentication source."""

import base64
from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from fastapi import Request
from fastapi.security import HTTPBasic
Expand Down
3 changes: 2 additions & 1 deletion src/fastapi_multiauth/sources/bearer.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Bearer token authentication source."""

import secrets
from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from fastapi import Request
from fastapi.security import HTTPBearer
Expand Down
4 changes: 2 additions & 2 deletions src/fastapi_multiauth/sources/cookie.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Cookie-based authentication source."""

import hashlib
from collections.abc import Sequence
from typing import Any, Callable, Literal
from collections.abc import Callable, Sequence
from typing import Any, Literal

from fastapi import Request, Response
from fastapi.security import APIKeyCookie
Expand Down
3 changes: 2 additions & 1 deletion src/fastapi_multiauth/sources/header.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""API key header authentication source."""

from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from fastapi import Request
from fastapi.security import APIKeyHeader
Expand Down
3 changes: 2 additions & 1 deletion src/fastapi_multiauth/sources/query.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""API key query-string authentication source."""

from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from fastapi import Request
from fastapi.security import APIKeyQuery
Expand Down
Loading
Loading