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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ Each item lists what changed and what to do.
`revoke_token_chain`, and `store_refresh_token` takes `family_id`.
- Error responses carry `Cache-Control: no-store`; `invalid_client` challenges
with `WWW-Authenticate: Basic`.
- **Class-level `Auth.guard(name)` is a FastAPI dependency.**
`Auth.guard(name)` / `AsyncAuth.guard(name)` on the class now returns a
`GuardDependency`: `user = Depends(Auth.guard("api"))` resolves the user
through that guard and raises 401 otherwise, and equal names compare equal,
so `app.dependency_overrides[Auth.guard("api")]` works. Inside a request,
attribute access (`provider`, `user_from_token`, `authenticate`, ...) still
reaches the named guard, but the value is no longer the guard object itself,
so identity and `isinstance` checks fail, and outside a request attributes
raise `AttributeError`. On an instance (`auth.guard(name)` from
`Depends(Auth.scoped)`) it still returns the guard. *Migrate:* where you need
the guard object, use `auth.guard(name)` from `Depends(Auth.scoped)` or
`manager.guard(name)`.

### Migrations

Expand All @@ -104,6 +116,18 @@ using the ORM stores:

### Added

- `ChainGuard(guards=[...])` (`{"driver": "chain"}`): authenticates through the
first member guard that accepts the request, e.g. a bearer token backed by
the browser session. A token the request presents is final for every member
type (oauth2 bearer, or an API token in `Authorization` or a custom header):
when the member that reads it rejects it, the chain fails instead of trying
the session. `user_from_token` skips session members.
- `OAuth2Guard(first_party_only=True)` refuses access tokens issued to an OAuth
client (`client_id` set), so a first-party API accepts only the
application's own login and personal tokens.
- `AuthManager.on_authenticated(listener)`: `listener(request, user, guard)`
runs whenever a guard dependency resolves a user, before the route handler,
for activity tracking or request-scoped state.
- Refresh token families with reuse detection: replaying a rotated refresh token
revokes the whole family, whoever presents it: the owning client, another
authenticated client, or no client at all for unbound password-grant tokens.
Expand Down
5 changes: 5 additions & 0 deletions src/fastapi_startkit_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
SessionConfig,
)
from .dependencies import (
GuardDependency,
auth,
current_user,
optional_user,
Expand Down Expand Up @@ -62,6 +63,7 @@
)
from .policy import GrantPolicy
from .facade import AsyncAuth, Auth
from .guards.chain import AsyncChainGuard, ChainGuard
from .guards.guard import AsyncPassportGuard, PassportGuard
from .guards.session import AsyncSessionGuard, SessionGuard
from .guards.token import AsyncTokenGuard, TokenGuard
Expand Down Expand Up @@ -108,6 +110,7 @@
"UnsupportedGrantTypeException",
"UnsupportedResponseTypeException",
"current_user",
"GuardDependency",
"optional_user",
"require_scopes",
"require_abilities",
Expand Down Expand Up @@ -154,6 +157,8 @@
"AsyncPassportGuard",
"AsyncSessionGuard",
"AsyncTokenGuard",
"ChainGuard",
"AsyncChainGuard",
"AsyncApiTokenManager",
"PasswordBroker",
"AsyncPasswordBroker",
Expand Down
2 changes: 2 additions & 0 deletions src/fastapi_startkit_auth/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
ApiTokenConfig,
ApiTokenGuard,
AuthConfig,
ChainGuard,
OAuth2Config,
OAuth2Guard,
OAuthClientConfig,
Expand All @@ -29,6 +30,7 @@
"SessionGuard",
"OAuth2Guard",
"ApiTokenGuard",
"ChainGuard",
"DEFAULT_GRANT_TYPES",
"DEPRECATED_OAUTH2_ATTRIBUTES",
"as_config_class",
Expand Down
9 changes: 9 additions & 0 deletions src/fastapi_startkit_auth/config/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class SessionGuard:
class OAuth2Guard:
provider: str = "users"
audience: str | None = None
first_party_only: bool = False
driver: Literal["oauth2"] = "oauth2"


Expand All @@ -37,6 +38,14 @@ class ApiTokenGuard:
driver: Literal["token"] = "token"


@dataclass
class ChainGuard:
"""Authenticates through the first of ``guards`` (guard names) that accepts the request."""

guards: list[str] = field(default_factory=list)
driver: Literal["chain"] = "chain"


@dataclass
class SessionConfig:
"""Cookie sessions, enabled by ``AuthSessionProvider``.
Expand Down
45 changes: 45 additions & 0 deletions src/fastapi_startkit_auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,51 @@ async def optional_user(
return None


class GuardDependency:
"""FastAPI dependency resolving the request's user through a named guard.

``user = Depends(Auth.guard("api"))`` authenticates with whatever the
guard's driver reads (bearer token, session cookie, API token, a chain of
them) and raises 401 when it cannot, or when the credential has no user.
A resolved user is announced to ``AuthManager.on_authenticated`` listeners.
Equal names compare equal, so ``app.dependency_overrides`` can key on
``Auth.guard("api")``. Inside a request, other attributes (``provider``,
``user_from_token``, ``authenticate``...) are read from the named guard, so
code that used class-level ``Auth.guard(name)`` as the guard keeps working.
"""

def __init__(self, name: str | None = None) -> None:
self.name = name

async def __call__(self, request: Request, token: str | None = Depends(_bearer)) -> Any:
manager = get_auth_manager(request)
context = await resolve_context(request, manager, self.name)
if context.user is None:
raise InvalidTokenException("This token is not associated with a user.")
await manager.authenticated(request, context.user, self.name)
return context.user

def __getattr__(self, attribute: str) -> Any:
if attribute.startswith("__") or attribute == "name":
raise AttributeError(attribute)
context = _auth_request.get()
if context is None or not context.active:
raise AttributeError(
f"{type(self).__name__} has no attribute {attribute!r} outside a request; "
"use the AuthManager's guard() there."
)
return getattr(context.manager.guard(self.name), attribute)

def __eq__(self, other: object) -> bool:
return isinstance(other, GuardDependency) and other.name == self.name

def __hash__(self) -> int:
return hash((GuardDependency, self.name))

def __repr__(self) -> str:
return f"GuardDependency({self.name!r})"


def require_scopes(*scopes: str, mode: str = "all") -> Callable[..., AuthContext]:
"""Dependency factory enforcing token abilities (Passport-style scopes).

Expand Down
19 changes: 12 additions & 7 deletions src/fastapi_startkit_auth/facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from fastapi import Depends, Request

from .dependencies import _bearer_token, get_auth_manager, resolve_context
from .dependencies import GuardDependency, _bearer_token, get_auth_manager, resolve_context
from .concurrency import call, ensure_sync
from .exceptions import AuthException
from .guards.session import AsyncSessionGuard, SessionGuard
Expand All @@ -14,6 +14,15 @@
from .sessions.models import SessionRecord


class GuardAccessor:
"""``Auth.guard(name)`` is a FastAPI dependency; ``auth.guard(name)`` on an instance is the guard itself."""

def __get__(self, instance: Any, owner: type) -> Any:
if instance is None:
return GuardDependency
return instance._manager.guard


class Auth:
"""Request-scoped facade over the manager and the request's session.

Expand Down Expand Up @@ -43,9 +52,7 @@ def scoped(cls, request: Request, manager: AuthManager = Depends(get_auth_manage
"""FastAPI dependency: ``auth: Auth = Depends(Auth.scoped)``."""
return cls(manager, request)

@ScopedMethod
def guard(self, name: str | None = None) -> Any:
return self._manager.guard(name)
guard = GuardAccessor()

@ScopedMethod
def login(self, user_or_id: Any, guard: str | None = None) -> SessionRecord:
Expand Down Expand Up @@ -135,9 +142,7 @@ def __init__(self, manager: AuthManager, request: Request) -> None:
def scoped(cls, request: Request, manager: AuthManager = Depends(get_auth_manager)) -> "AsyncAuth":
return cls(manager, request)

@ScopedMethod
def guard(self, name: str | None = None) -> Any:
return self._manager.guard(name)
guard = GuardAccessor()

@ScopedMethod
async def login(self, user_or_id: Any, guard: str | None = None) -> SessionRecord:
Expand Down
3 changes: 3 additions & 0 deletions src/fastapi_startkit_auth/guards/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .chain import AsyncChainGuard, ChainGuard
from .guard import AsyncPassportGuard, AuthContext, Guard, PassportGuard
from .session import AsyncSessionGuard, SessionGuard
from .token import AsyncTokenGuard, TokenGuard
Expand All @@ -11,4 +12,6 @@
"AsyncPassportGuard",
"AsyncSessionGuard",
"AsyncTokenGuard",
"ChainGuard",
"AsyncChainGuard",
]
129 changes: 129 additions & 0 deletions src/fastapi_startkit_auth/guards/chain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from __future__ import annotations

from typing import Any

from fastapi import Request

from ..concurrency import call, ensure_sync, is_async
from ..exceptions import AuthException, InvalidTokenException
from .guard import AuthContext, Guard
from .session import AsyncSessionGuard, SessionGuard


def _bearer_token(request: Request) -> str | None:
scheme, _, token = request.headers.get("authorization", "").partition(" ")
if scheme.lower() != "bearer" or not token.strip():
return None
return token.strip()


def guard_is_async(guard: Any) -> bool:
return is_async(guard, "authenticate") or is_async(guard, "user_from_token")


class ChainGuard:
"""Tries each member guard in order and returns the first that authenticates.

Corresponds to a ``{"driver": "chain", "guards": [...]}`` entry, e.g. a
bearer-token guard backed by the first-party browser session. A token the
request presents is final: when the member that reads it rejects it, the
chain fails instead of falling back to a later guard. ``user_from_token``
skips session members, which would read the token as a session id.
"""

def __init__(self, name: str, guards: list[Guard]) -> None:
if not guards:
raise ValueError(f"Chain guard {name!r} needs at least one member guard.")
self.name = name
self.guards = guards
self.provider = guards[0].provider

def authenticate(self, request: Request) -> AuthContext:
failures: list[AuthException] = []
for guard in self.guards:
try:
return ensure_sync(_authenticate(guard, request), f"Guard {guard.name!r}")
except AuthException as failure:
if _rejected_presented_token(guard, request):
raise
failures.append(failure)
raise failures[0]

def user_from_token(self, access_token: str) -> AuthContext:
failures: list[AuthException] = []
for guard in _token_members(self.guards):
try:
return ensure_sync(guard.user_from_token(access_token), f"Guard {guard.name!r}")
except AuthException as failure:
failures.append(failure)
raise _first_failure(failures)


class AsyncChainGuard:
def __init__(self, name: str, guards: list[Guard]) -> None:
if not guards:
raise ValueError(f"Chain guard {name!r} needs at least one member guard.")
self.name = name
self.guards = guards
self.provider = guards[0].provider

async def authenticate(self, request: Request) -> AuthContext:
failures: list[AuthException] = []
for guard in self.guards:
try:
return await _authenticate_async(guard, request)
except AuthException as failure:
if _rejected_presented_token(guard, request):
raise
failures.append(failure)
raise failures[0]

async def user_from_token(self, access_token: str) -> AuthContext:
failures: list[AuthException] = []
for guard in _token_members(self.guards):
try:
return await call(guard.user_from_token, access_token)
except AuthException as failure:
failures.append(failure)
raise _first_failure(failures)


def _rejected_presented_token(guard: Any, request: Request) -> bool:
return _presented_token(guard, request) is not None


def _presented_token(guard: Any, request: Request) -> str | None:
presented_token = getattr(guard, "presented_token", None)
if presented_token is not None:
return presented_token(request)
if getattr(guard, "authenticate", None) is None:
return _bearer_token(request)
return None


def _token_members(guards: list[Guard]) -> list[Guard]:
return [guard for guard in guards if not isinstance(guard, (SessionGuard, AsyncSessionGuard))]


def _first_failure(failures: list[AuthException]) -> AuthException:
return failures[0] if failures else InvalidTokenException("Not authenticated.")


def _authenticate(guard: Any, request: Request) -> Any:
authenticate = getattr(guard, "authenticate", None)
if authenticate is not None:
return authenticate(request)
token = _bearer_token(request)
if token is None:
raise InvalidTokenException("Not authenticated.")
return guard.user_from_token(token)


async def _authenticate_async(guard: Any, request: Request) -> AuthContext:
authenticate = getattr(guard, "authenticate", None)
if authenticate is not None:
return await call(authenticate, request)
token = _bearer_token(request)
if token is None:
raise InvalidTokenException("Not authenticated.")
return await call(guard.user_from_token, token)
Loading
Loading