From b5807b481e9e10c9e1d4cacc66a62491a5204022 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 12:27:02 -0700 Subject: [PATCH 1/4] feat: make Auth.guard(name) a FastAPI dependency and add a chain guard Why: - Apps want `user = Depends(Auth.guard("api"))` so the guard config, not app code, decides how a request is authenticated. - At import time Auth.guard raised (no active request), so apps wrote their own current-user dependencies. - A first-party API often accepts a bearer token or the browser session; no built-in driver could express that. How: - Auth.guard / AsyncAuth.guard on the class return GuardDependency(name), which resolves the user through that guard and raises 401 when it cannot or the credential has no user. - GuardDependency compares equal by name so dependency_overrides can key on it; on an instance, guard(name) still returns the guard object. - New "chain" driver (config ChainGuard(guards=[...])) tries member guards in order; async members build AsyncChainGuard; unknown, nested or empty members fail clearly. - Tests cover valid, missing, invalid and user-less tokens, the session fallback, overrides and the async chain. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KicqT223Xif1un4NwyAeeA --- CHANGELOG.md | 10 ++ src/fastapi_startkit_auth/__init__.py | 5 + src/fastapi_startkit_auth/config/__init__.py | 2 + src/fastapi_startkit_auth/config/auth.py | 8 + src/fastapi_startkit_auth/dependencies.py | 29 ++++ src/fastapi_startkit_auth/facade.py | 19 ++- src/fastapi_startkit_auth/guards/__init__.py | 3 + src/fastapi_startkit_auth/guards/chain.py | 100 ++++++++++++ src/fastapi_startkit_auth/manager.py | 15 +- tests/test_guard_dependency.py | 162 +++++++++++++++++++ 10 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 src/fastapi_startkit_auth/guards/chain.py create mode 100644 tests/test_guard_dependency.py diff --git a/CHANGELOG.md b/CHANGELOG.md index beae692..37ba718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,16 @@ using the ORM stores: ### Added +- `Auth.guard(name)` / `AsyncAuth.guard(name)` on the class is a FastAPI + dependency: `user = Depends(Auth.guard("api"))` resolves the user through + that guard's driver and raises 401 otherwise. Dependencies with the same name + compare equal, so `app.dependency_overrides[Auth.guard("api")]` works. On an + instance (`auth.guard(name)` from `Depends(Auth.scoped)`) it still returns the + guard object. *Upgrade note:* class-level `Auth.guard()` inside a request used + to return the guard; use `Auth.scoped` or the manager for that. +- `ChainGuard(guards=[...])` (`{"driver": "chain"}`): authenticates through the + first member guard that accepts the request, e.g. a bearer token backed by + the browser session. - 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. diff --git a/src/fastapi_startkit_auth/__init__.py b/src/fastapi_startkit_auth/__init__.py index c749d5f..e6d10e3 100644 --- a/src/fastapi_startkit_auth/__init__.py +++ b/src/fastapi_startkit_auth/__init__.py @@ -25,6 +25,7 @@ SessionConfig, ) from .dependencies import ( + GuardDependency, auth, current_user, optional_user, @@ -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 @@ -108,6 +110,7 @@ "UnsupportedGrantTypeException", "UnsupportedResponseTypeException", "current_user", + "GuardDependency", "optional_user", "require_scopes", "require_abilities", @@ -154,6 +157,8 @@ "AsyncPassportGuard", "AsyncSessionGuard", "AsyncTokenGuard", + "ChainGuard", + "AsyncChainGuard", "AsyncApiTokenManager", "PasswordBroker", "AsyncPasswordBroker", diff --git a/src/fastapi_startkit_auth/config/__init__.py b/src/fastapi_startkit_auth/config/__init__.py index a1514fc..33a308f 100644 --- a/src/fastapi_startkit_auth/config/__init__.py +++ b/src/fastapi_startkit_auth/config/__init__.py @@ -4,6 +4,7 @@ ApiTokenConfig, ApiTokenGuard, AuthConfig, + ChainGuard, OAuth2Config, OAuth2Guard, OAuthClientConfig, @@ -29,6 +30,7 @@ "SessionGuard", "OAuth2Guard", "ApiTokenGuard", + "ChainGuard", "DEFAULT_GRANT_TYPES", "DEPRECATED_OAUTH2_ATTRIBUTES", "as_config_class", diff --git a/src/fastapi_startkit_auth/config/auth.py b/src/fastapi_startkit_auth/config/auth.py index 49a236d..85736c0 100644 --- a/src/fastapi_startkit_auth/config/auth.py +++ b/src/fastapi_startkit_auth/config/auth.py @@ -37,6 +37,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``. diff --git a/src/fastapi_startkit_auth/dependencies.py b/src/fastapi_startkit_auth/dependencies.py index 87e5231..40c25cc 100644 --- a/src/fastapi_startkit_auth/dependencies.py +++ b/src/fastapi_startkit_auth/dependencies.py @@ -85,6 +85,35 @@ 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. + Equal names compare equal, so ``app.dependency_overrides`` can key on + ``Auth.guard("api")``. + """ + + def __init__(self, name: str | None = None) -> None: + self.name = name + + async def __call__(self, request: Request, token: str | None = Depends(_bearer)) -> Any: + context = await resolve_context(request, get_auth_manager(request), self.name) + if context.user is None: + raise InvalidTokenException("This token is not associated with a user.") + return context.user + + 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). diff --git a/src/fastapi_startkit_auth/facade.py b/src/fastapi_startkit_auth/facade.py index 6a1a254..921a8c6 100644 --- a/src/fastapi_startkit_auth/facade.py +++ b/src/fastapi_startkit_auth/facade.py @@ -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 @@ -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. @@ -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: @@ -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: diff --git a/src/fastapi_startkit_auth/guards/__init__.py b/src/fastapi_startkit_auth/guards/__init__.py index 16f523f..536e7a8 100644 --- a/src/fastapi_startkit_auth/guards/__init__.py +++ b/src/fastapi_startkit_auth/guards/__init__.py @@ -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 @@ -11,4 +12,6 @@ "AsyncPassportGuard", "AsyncSessionGuard", "AsyncTokenGuard", + "ChainGuard", + "AsyncChainGuard", ] diff --git a/src/fastapi_startkit_auth/guards/chain.py b/src/fastapi_startkit_auth/guards/chain.py new file mode 100644 index 0000000..04530cc --- /dev/null +++ b/src/fastapi_startkit_auth/guards/chain.py @@ -0,0 +1,100 @@ +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 + + +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. + """ + + 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: + failures.append(failure) + raise failures[0] + + def user_from_token(self, access_token: str) -> AuthContext: + failures: list[AuthException] = [] + for guard in 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 failures[0] + + +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: + failures.append(failure) + raise failures[0] + + async def user_from_token(self, access_token: str) -> AuthContext: + failures: list[AuthException] = [] + for guard in self.guards: + try: + return await call(guard.user_from_token, access_token) + except AuthException as failure: + failures.append(failure) + raise failures[0] + + +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) diff --git a/src/fastapi_startkit_auth/manager.py b/src/fastapi_startkit_auth/manager.py index 10c0a36..8e6de2f 100644 --- a/src/fastapi_startkit_auth/manager.py +++ b/src/fastapi_startkit_auth/manager.py @@ -23,6 +23,7 @@ from .grants.client_credentials import AsyncClientCredentialsGrant, ClientCredentialsGrant from .grants.password import AsyncPasswordGrant, PasswordGrant from .grants.refresh import AsyncRefreshTokenGrant, RefreshTokenGrant +from .guards.chain import AsyncChainGuard, ChainGuard, guard_is_async from .guards.guard import AsyncPassportGuard, Guard, PassportGuard from .guards.session import AsyncSessionGuard, SessionGuard from .guards.token import AsyncTokenGuard, TokenGuard @@ -89,7 +90,7 @@ def __init__(self, config: Any = None) -> None: self._brokers: dict[str, PasswordBroker | AsyncPasswordBroker] = { name: self._build_broker(spec) for name, spec in cfg.get("passwords", {}).items() } - self._guard_drivers: dict[str, GuardFactory] = {} + self._guard_drivers: dict[str, GuardFactory] = {"chain": self._build_chain_guard} self._guards: dict[str, Guard] | None = None self._session_config: SessionConfig | None = None self._session_store: SessionStore | None = None @@ -282,6 +283,18 @@ def _build_token_guard(self, name: str, spec: dict[str, Any]) -> Guard: session_guard=(lambda: self.guard(session_guard)) if session_guard else None, ) + def _build_chain_guard(self, name: str, spec: dict[str, Any]) -> Guard: + members = [] + for member in spec.get("guards", []): + member_spec = self._guard_specs.get(member) + if member_spec is None: + raise ValueError(f"Chain guard {name!r} lists {member!r}, which AuthConfig.guards does not define.") + if member_spec["driver"] == "chain": + raise ValueError(f"Chain guard {name!r} cannot include the chain guard {member!r}.") + members.append(self._build_guard(member, member_spec)) + guard_class = AsyncChainGuard if any(guard_is_async(member) for member in members) else ChainGuard + return guard_class(name=name, guards=members) + def guard(self, name: str | None = None) -> Guard: name = name or self.default_guard_name() guards = self._all_guards() diff --git a/tests/test_guard_dependency.py b/tests/test_guard_dependency.py new file mode 100644 index 0000000..a99b3d9 --- /dev/null +++ b/tests/test_guard_dependency.py @@ -0,0 +1,162 @@ +import pytest +from fastapi import Body, Depends, FastAPI + +from fastapi_startkit_auth import AsyncAuth, AsyncChainGuard, Auth, AuthConfig, ChainGuard, GuardDependency +from fastapi_startkit_auth.config import ChainGuard as ChainGuardConfig +from fastapi_startkit_auth.config import OAuth2Guard, SessionGuard + +from conftest import BrowserTestClient as TestClient +from conftest import auth_manager, oauth2_config, register_auth +from test_async_auth import USER_PROVIDER, users # noqa: F401 +from test_session_auth import session_config + +GUARDS = { + "api": OAuth2Guard(provider="users"), + "web": SessionGuard(provider="users"), + "auth.api": ChainGuardConfig(guards=["api", "web"]), +} + + +@pytest.fixture +def guarded_app(): + api = FastAPI() + manager = register_auth(api, session_config(guards=GUARDS, default_guard="api"), session={}, oauth2=oauth2_config()) + + @api.post("/login") + def login(payload: dict = Body(...), auth: Auth = Depends(Auth.scoped)): + return {"ok": auth.attempt(payload, guard="web")} + + @api.get("/me") + def me(user=Depends(Auth.guard("auth.api"))): + return {"id": user["id"]} + + @api.get("/bearer-only") + def bearer_only(user=Depends(Auth.guard("api"))): + return {"id": user["id"]} + + @api.get("/default") + def default(user=Depends(Auth.guard())): + return {"id": user["id"]} + + return api, manager + + +def bearer(manager, user_id=1, client_id=None): + issued = manager.token_service.issue(user_id=user_id, client_id=client_id, scopes=["*"]) + return {"Authorization": f"Bearer {issued.access_token}"} + + +def test_guard_on_the_class_is_a_dependency_keyed_by_name(): + assert isinstance(Auth.guard("auth.api"), GuardDependency) + assert Auth.guard("auth.api") == AsyncAuth.guard("auth.api") + assert hash(Auth.guard("auth.api")) == hash(Auth.guard("auth.api")) + assert Auth.guard("api") != Auth.guard("auth.api") + + +def test_guard_on_an_instance_is_the_configured_guard(): + manager = auth_manager(session_config(guards=GUARDS, default_guard="api"), session={}, oauth2=oauth2_config()) + auth = Auth(manager, request=None) + assert auth.guard("auth.api") is manager.guard("auth.api") + assert isinstance(auth.guard("auth.api"), ChainGuard) + + +def test_valid_bearer_token_resolves_the_user(guarded_app): + api, manager = guarded_app + client = TestClient(api, base_url="https://testserver") + response = client.get("/me", headers=bearer(manager, user_id=2)) + assert response.status_code == 200 + assert response.json() == {"id": 2} + + +def test_default_guard_dependency_uses_the_default_guard(guarded_app): + api, manager = guarded_app + client = TestClient(api, base_url="https://testserver") + assert client.get("/default", headers=bearer(manager)).json() == {"id": 1} + + +def test_missing_credentials_are_rejected_with_401(guarded_app): + api, _ = guarded_app + response = TestClient(api, base_url="https://testserver").get("/me") + assert response.status_code == 401 + + +def test_invalid_bearer_token_is_rejected_with_401(guarded_app): + api, _ = guarded_app + response = TestClient(api, base_url="https://testserver").get("/me", headers={"Authorization": "Bearer nope"}) + assert response.status_code == 401 + assert response.json()["error"] == "invalid_token" + + +def test_client_credentials_token_without_a_user_is_rejected(guarded_app): + api, manager = guarded_app + issued = manager.token_service.issue(user_id=None, client_id="machine", scopes=["*"]) + response = TestClient(api, base_url="https://testserver").get( + "/bearer-only", headers={"Authorization": f"Bearer {issued.access_token}"} + ) + assert response.status_code == 401 + + +def test_chain_falls_back_to_the_session(guarded_app): + api, _ = guarded_app + client = TestClient(api, base_url="https://testserver") + assert client.post("/login", json={"email": "grace@example.com", "password": "hopper"}).json() == {"ok": True} + assert client.get("/me").json() == {"id": 2} + assert client.get("/bearer-only").status_code == 401 + + +def test_dependency_overrides_key_on_the_guard_name(guarded_app): + api, _ = guarded_app + api.dependency_overrides[Auth.guard("auth.api")] = lambda: {"id": 99} + assert TestClient(api, base_url="https://testserver").get("/me").json() == {"id": 99} + + +def test_chain_guard_rejects_unknown_members(): + guards = {"api": OAuth2Guard(provider="users"), "auth.api": ChainGuardConfig(guards=["api", "missing"])} + manager = auth_manager(session_config(guards=guards, default_guard="api"), session={}, oauth2=oauth2_config()) + with pytest.raises(ValueError, match="'missing'"): + manager.guard("auth.api") + + +def test_chain_guard_rejects_nested_chains(): + guards = { + "api": OAuth2Guard(provider="users"), + "inner": ChainGuardConfig(guards=["api"]), + "outer": ChainGuardConfig(guards=["inner"]), + } + manager = auth_manager(session_config(guards=guards, default_guard="api"), session={}, oauth2=oauth2_config()) + with pytest.raises(ValueError, match="cannot include the chain guard 'inner'"): + manager.guard("outer") + + +def test_chain_guard_needs_a_member(): + guards = {"api": OAuth2Guard(provider="users"), "empty": ChainGuardConfig()} + manager = auth_manager(session_config(guards=guards, default_guard="api"), session={}, oauth2=oauth2_config()) + with pytest.raises(ValueError, match="at least one member"): + manager.guard("empty") + + +def test_async_members_build_an_async_chain_that_resolves_token_and_session(users): + class Config(AuthConfig): + bcrypt_rounds = 4 + default = {"guard": "api", "passwords": "users"} + guards = GUARDS + providers = {"users": USER_PROVIDER} + + api = FastAPI() + manager = register_auth(api, Config, session={}, oauth2=oauth2_config()) + + @api.post("/login-as/{user_id}") + async def login_as(user_id: int, auth: AsyncAuth = Depends(AsyncAuth.scoped)): + await auth.login(user_id, guard="web") + return {"ok": True} + + @api.get("/me") + async def me(user=Depends(AsyncAuth.guard("auth.api"))): + return {"id": user.id} + + assert isinstance(manager.guard("auth.api"), AsyncChainGuard) + client = TestClient(api, base_url="https://testserver") + assert client.get("/me").status_code == 401 + assert client.get("/me", headers=bearer(manager)).json() == {"id": 1} + client.post("/login-as/1") + assert client.get("/me").json() == {"id": 1} From fdba3e016c6d311318038ae12088ab370fbe5e06 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 12:34:40 -0700 Subject: [PATCH 2/4] feat: notify on_authenticated listeners when a guard dependency resolves a user Why: - Apps record per-request activity (visits, usage attribution) for the authenticated user. - Without a hook they wrap the guard in their own current-user dependency, which is what Auth.guard as a dependency replaces. How: - AuthManager.on_authenticated(listener) registers listener(request, user, guard); usable as a decorator. - GuardDependency awaits the listeners after it resolves a user and before the route handler runs; failed authentication skips them. - Tests cover the listener seeing the user before the handler and not running on 401. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H45FAaoDKkVZYFVvML9Ya4 --- CHANGELOG.md | 3 +++ src/fastapi_startkit_auth/dependencies.py | 5 +++- src/fastapi_startkit_auth/manager.py | 17 +++++++++++++ tests/test_guard_dependency.py | 29 ++++++++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ba718..cfd19fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,9 @@ using the ORM stores: - `ChainGuard(guards=[...])` (`{"driver": "chain"}`): authenticates through the first member guard that accepts the request, e.g. a bearer token backed by the browser session. +- `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. diff --git a/src/fastapi_startkit_auth/dependencies.py b/src/fastapi_startkit_auth/dependencies.py index 40c25cc..e19f1a6 100644 --- a/src/fastapi_startkit_auth/dependencies.py +++ b/src/fastapi_startkit_auth/dependencies.py @@ -91,6 +91,7 @@ class GuardDependency: ``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")``. """ @@ -99,9 +100,11 @@ def __init__(self, name: str | None = None) -> None: self.name = name async def __call__(self, request: Request, token: str | None = Depends(_bearer)) -> Any: - context = await resolve_context(request, get_auth_manager(request), self.name) + 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 __eq__(self, other: object) -> bool: diff --git a/src/fastapi_startkit_auth/manager.py b/src/fastapi_startkit_auth/manager.py index 8e6de2f..5bf8648 100644 --- a/src/fastapi_startkit_auth/manager.py +++ b/src/fastapi_startkit_auth/manager.py @@ -40,6 +40,7 @@ from .tokens.service import AsyncTokenService, TokenService GuardFactory = Callable[[str, dict[str, Any]], Guard] +AuthenticatedListener = Callable[[Any, Any, str | None], Any] FEATURE_PROVIDERS = { "session": "AuthSessionProvider", @@ -98,6 +99,7 @@ def __init__(self, config: Any = None) -> None: self._api_token_config: ApiTokenConfig | None = None self._api_tokens: ApiTokenManager | AsyncApiTokenManager | None = None self.csrf_exempt_paths: list[str] = [] + self._authenticated_listeners: list[AuthenticatedListener] = [] @property def config(self) -> type[AuthConfig]: @@ -216,6 +218,21 @@ def validate(self) -> None: f"ApiTokenConfig.session_guard {name!r} must name a session guard in AuthConfig.guards." ) + # --- events --------------------------------------------------------- + def on_authenticated(self, listener: AuthenticatedListener) -> AuthenticatedListener: + """Call ``listener(request, user, guard)`` whenever a guard dependency resolves a user. + + Runs before the route handler, so listeners can record activity or put + request-scoped state in place. Async listeners are awaited; sync ones + run in the threadpool. + """ + self._authenticated_listeners.append(listener) + return listener + + async def authenticated(self, request: Any, user: Any, guard: str | None) -> None: + for listener in self._authenticated_listeners: + await call(listener, request, user, guard) + # --- guards --------------------------------------------------------- def register_guard_driver(self, driver: str, factory: GuardFactory) -> None: """Register a factory that builds guards for a config ``driver`` key. diff --git a/tests/test_guard_dependency.py b/tests/test_guard_dependency.py index a99b3d9..e31248e 100644 --- a/tests/test_guard_dependency.py +++ b/tests/test_guard_dependency.py @@ -1,5 +1,5 @@ import pytest -from fastapi import Body, Depends, FastAPI +from fastapi import Body, Depends, FastAPI, Request from fastapi_startkit_auth import AsyncAuth, AsyncChainGuard, Auth, AuthConfig, ChainGuard, GuardDependency from fastapi_startkit_auth.config import ChainGuard as ChainGuardConfig @@ -110,6 +110,33 @@ def test_dependency_overrides_key_on_the_guard_name(guarded_app): assert TestClient(api, base_url="https://testserver").get("/me").json() == {"id": 99} +def test_authenticated_listeners_receive_the_resolved_user_before_the_handler(guarded_app): + api, manager = guarded_app + seen = [] + + @manager.on_authenticated + async def remember(request, user, guard): + request.state.seen_user = user["id"] + seen.append((user["id"], guard)) + + @api.get("/seen") + def seen_route(request: Request, user=Depends(Auth.guard("auth.api"))): + return {"seen": request.state.seen_user} + + client = TestClient(api, base_url="https://testserver") + assert client.get("/seen", headers=bearer(manager, user_id=2)).json() == {"seen": 2} + assert seen == [(2, "auth.api")] + + +def test_authenticated_listeners_are_skipped_when_authentication_fails(guarded_app): + api, manager = guarded_app + seen = [] + manager.on_authenticated(lambda request, user, guard: seen.append(user)) + + assert TestClient(api, base_url="https://testserver").get("/me").status_code == 401 + assert seen == [] + + def test_chain_guard_rejects_unknown_members(): guards = {"api": OAuth2Guard(provider="users"), "auth.api": ChainGuardConfig(guards=["api", "missing"])} manager = auth_manager(session_config(guards=guards, default_guard="api"), session={}, oauth2=oauth2_config()) From ad300399fb2712149e5582718edf1ed88941329c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 12:51:03 -0700 Subject: [PATCH 3/4] feat: keep a presented bearer token final in chains and add first-party-only OAuth2 guards Why: - A chain that fell back to the session after rejecting a presented bearer token let a revoked or foreign token ride on a browser session. - A first-party API needs to refuse tokens minted for third-party OAuth clients, which the oauth2 guard accepted. How: - ChainGuard and AsyncChainGuard re-raise when a token member rejects a bearer token the request presented, and only move on when no token was sent. - OAuth2Guard gains first_party_only; Passport guards refuse claims carrying a client_id when it is set. - Tests cover both rules and the changelog lists them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H45FAaoDKkVZYFVvML9Ya4 --- CHANGELOG.md | 6 ++++- src/fastapi_startkit_auth/config/auth.py | 1 + src/fastapi_startkit_auth/guards/chain.py | 12 +++++++++- src/fastapi_startkit_auth/guards/guard.py | 29 ++++++++++++++++++++--- src/fastapi_startkit_auth/manager.py | 8 ++++++- tests/test_guard_dependency.py | 25 +++++++++++++++++++ 6 files changed, 75 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd19fb..f09e46a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,7 +113,11 @@ using the ORM stores: to return the guard; use `Auth.scoped` or the manager for that. - `ChainGuard(guards=[...])` (`{"driver": "chain"}`): authenticates through the first member guard that accepts the request, e.g. a bearer token backed by - the browser session. + the browser session. A bearer token the request presents is final: when a + token member rejects it, the chain fails instead of trying the session. +- `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. diff --git a/src/fastapi_startkit_auth/config/auth.py b/src/fastapi_startkit_auth/config/auth.py index 85736c0..1a212d9 100644 --- a/src/fastapi_startkit_auth/config/auth.py +++ b/src/fastapi_startkit_auth/config/auth.py @@ -28,6 +28,7 @@ class SessionGuard: class OAuth2Guard: provider: str = "users" audience: str | None = None + first_party_only: bool = False driver: Literal["oauth2"] = "oauth2" diff --git a/src/fastapi_startkit_auth/guards/chain.py b/src/fastapi_startkit_auth/guards/chain.py index 04530cc..459c73b 100644 --- a/src/fastapi_startkit_auth/guards/chain.py +++ b/src/fastapi_startkit_auth/guards/chain.py @@ -24,7 +24,9 @@ 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. + bearer-token guard backed by the first-party browser session. A bearer token + the request presents is final: when a token guard rejects it, the chain + fails instead of falling back to a later guard. """ def __init__(self, name: str, guards: list[Guard]) -> None: @@ -40,6 +42,8 @@ def authenticate(self, request: Request) -> AuthContext: 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] @@ -67,6 +71,8 @@ async def authenticate(self, request: Request) -> AuthContext: try: return await _authenticate_async(guard, request) except AuthException as failure: + if _rejected_presented_token(guard, request): + raise failures.append(failure) raise failures[0] @@ -80,6 +86,10 @@ async def user_from_token(self, access_token: str) -> AuthContext: raise failures[0] +def _rejected_presented_token(guard: Any, request: Request) -> bool: + return getattr(guard, "authenticate", None) is None and _bearer_token(request) is not None + + def _authenticate(guard: Any, request: Request) -> Any: authenticate = getattr(guard, "authenticate", None) if authenticate is not None: diff --git a/src/fastapi_startkit_auth/guards/guard.py b/src/fastapi_startkit_auth/guards/guard.py index 9286fd2..98c2d7c 100644 --- a/src/fastapi_startkit_auth/guards/guard.py +++ b/src/fastapi_startkit_auth/guards/guard.py @@ -10,6 +10,7 @@ _INACTIVE_SUBJECT = "The token subject is not active." +_CLIENT_TOKEN = "The token was issued to a client, and this guard accepts first-party tokens only." @dataclass @@ -61,19 +62,27 @@ class PassportGuard: :class:`TokenService` (which validates signature, expiry, and revocation) with the guard's configured user provider. A guard with an ``audience`` only accepts tokens bound to that resource, and tokens bound to a resource are only - accepted by a guard serving it. + accepted by a guard serving it. A ``first_party_only`` guard refuses tokens + issued to an OAuth client, accepting only the application's own tokens. """ def __init__( - self, name: str, token_service: TokenService, provider: UserProvider, audience: str | None = None + self, + name: str, + token_service: TokenService, + provider: UserProvider, + audience: str | None = None, + first_party_only: bool = False, ) -> None: self.name = name self._tokens = token_service self.provider = provider self.audience = audience + self.first_party_only = first_party_only def user_from_token(self, access_token: str) -> AuthContext: claims = ensure_sync(self._tokens.authenticate(access_token, audience=self.audience), "The token service's authenticate") + _require_first_party(self.first_party_only, claims) return _context_from_claims(claims, self._resolve_user(claims.get("sub"))) def _resolve_user(self, sub: Any) -> Any | None: @@ -87,6 +96,11 @@ def _resolve_user(self, sub: Any) -> Any | None: return user +def _require_first_party(first_party_only: bool, claims: dict[str, Any]) -> None: + if first_party_only and claims.get("client_id") is not None: + raise InvalidTokenException(_CLIENT_TOKEN) + + def _context_from_claims(claims: dict[str, Any], user: Any | None) -> AuthContext: return AuthContext( user=user, @@ -97,14 +111,23 @@ def _context_from_claims(claims: dict[str, Any], user: Any | None) -> AuthContex class AsyncPassportGuard: - def __init__(self, name: str, token_service: Any, provider: Any, audience: str | None = None) -> None: + def __init__( + self, + name: str, + token_service: Any, + provider: Any, + audience: str | None = None, + first_party_only: bool = False, + ) -> None: self.name = name self._tokens = token_service self.provider = provider self.audience = audience + self.first_party_only = first_party_only async def user_from_token(self, access_token: str) -> AuthContext: claims = await call(self._tokens.authenticate, access_token, audience=self.audience) + _require_first_party(self.first_party_only, claims) return _context_from_claims(claims, await self._resolve_user(claims.get("sub"))) async def _resolve_user(self, sub: Any) -> Any | None: diff --git a/src/fastapi_startkit_auth/manager.py b/src/fastapi_startkit_auth/manager.py index 5bf8648..bc485dd 100644 --- a/src/fastapi_startkit_auth/manager.py +++ b/src/fastapi_startkit_auth/manager.py @@ -267,7 +267,13 @@ def _build_passport_guard(self, name: str, spec: dict[str, Any]) -> Guard: stacklevel=2, ) guard_class = AsyncPassportGuard if is_async_provider(provider) or self._tokens_async() else PassportGuard - return guard_class(name=name, token_service=self.token_service, provider=provider, audience=audience) + return guard_class( + name=name, + token_service=self.token_service, + provider=provider, + audience=audience, + first_party_only=bool(spec.get("first_party_only", False)), + ) def _build_session_guard(self, name: str, spec: dict[str, Any]) -> Guard: provider = self._require_provider(spec.get("provider")) diff --git a/tests/test_guard_dependency.py b/tests/test_guard_dependency.py index e31248e..27b1fb4 100644 --- a/tests/test_guard_dependency.py +++ b/tests/test_guard_dependency.py @@ -104,6 +104,31 @@ def test_chain_falls_back_to_the_session(guarded_app): assert client.get("/bearer-only").status_code == 401 +def test_a_rejected_bearer_token_does_not_fall_back_to_the_session(guarded_app): + api, _ = guarded_app + client = TestClient(api, base_url="https://testserver") + assert client.post("/login", json={"email": "grace@example.com", "password": "hopper"}).json() == {"ok": True} + response = client.get("/me", headers={"Authorization": "Bearer nope"}) + assert response.status_code == 401 + assert response.json()["error"] == "invalid_token" + + +def test_a_first_party_only_guard_refuses_tokens_issued_to_a_client(): + guards = {"api": OAuth2Guard(provider="users", first_party_only=True)} + api = FastAPI() + manager = register_auth(api, session_config(guards=guards, default_guard="api"), session={}, oauth2=oauth2_config()) + + @api.get("/me") + def me(user=Depends(Auth.guard("api"))): + return {"id": user["id"]} + + client = TestClient(api, base_url="https://testserver") + assert client.get("/me", headers=bearer(manager, user_id=2)).json() == {"id": 2} + response = client.get("/me", headers=bearer(manager, user_id=2, client_id="connector")) + assert response.status_code == 401 + assert response.json()["error"] == "invalid_token" + + def test_dependency_overrides_key_on_the_guard_name(guarded_app): api, _ = guarded_app api.dependency_overrides[Auth.guard("auth.api")] = lambda: {"id": 99} From 657fec782f3ebe1ae1a8a5a7f9e73a52a483c05a Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 24 Oct 2026 13:12:52 -0700 Subject: [PATCH 4/4] fix: keep class-level Auth.guard usable as the guard and make presented tokens final for every chain member Why: - Inside a request, code that used class-level Auth.guard(name) as the guard hit AttributeError on provider, user_from_token and authenticate. - A chain fell back to the session after a token-driver member rejected an API token, and ignored a custom token header. - ChainGuard.user_from_token handed the bearer to session members, which looked it up as a session id. How: - GuardDependency forwards unknown attributes to the request's named guard and explains itself outside a request. - Token guards expose presented_token(request) from their configured header; the chain treats any presented token as final, falling back only when none was sent. - user_from_token skips session members and fails with invalid_token when no token member remains. - The class-level Auth.guard change moves under Breaking changes in the changelog, and tests cover each case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H45FAaoDKkVZYFVvML9Ya4 --- CHANGELOG.md | 25 ++++--- src/fastapi_startkit_auth/dependencies.py | 15 +++- src/fastapi_startkit_auth/guards/chain.py | 35 ++++++--- src/fastapi_startkit_auth/guards/token.py | 6 ++ tests/test_guard_dependency.py | 86 ++++++++++++++++++++++- 5 files changed, 148 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f09e46a..bbd6214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -104,17 +116,12 @@ using the ORM stores: ### Added -- `Auth.guard(name)` / `AsyncAuth.guard(name)` on the class is a FastAPI - dependency: `user = Depends(Auth.guard("api"))` resolves the user through - that guard's driver and raises 401 otherwise. Dependencies with the same name - compare equal, so `app.dependency_overrides[Auth.guard("api")]` works. On an - instance (`auth.guard(name)` from `Depends(Auth.scoped)`) it still returns the - guard object. *Upgrade note:* class-level `Auth.guard()` inside a request used - to return the guard; use `Auth.scoped` or the manager for that. - `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 bearer token the request presents is final: when a - token member rejects it, the chain fails instead of trying the session. + 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. diff --git a/src/fastapi_startkit_auth/dependencies.py b/src/fastapi_startkit_auth/dependencies.py index e19f1a6..8a3ba31 100644 --- a/src/fastapi_startkit_auth/dependencies.py +++ b/src/fastapi_startkit_auth/dependencies.py @@ -93,7 +93,9 @@ class GuardDependency: 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")``. + ``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: @@ -107,6 +109,17 @@ async def __call__(self, request: Request, token: str | None = Depends(_bearer)) 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 diff --git a/src/fastapi_startkit_auth/guards/chain.py b/src/fastapi_startkit_auth/guards/chain.py index 459c73b..e96dbc7 100644 --- a/src/fastapi_startkit_auth/guards/chain.py +++ b/src/fastapi_startkit_auth/guards/chain.py @@ -7,6 +7,7 @@ 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: @@ -24,9 +25,10 @@ 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 bearer token - the request presents is final: when a token guard rejects it, the chain - fails instead of falling back to a later guard. + 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: @@ -49,12 +51,12 @@ def authenticate(self, request: Request) -> AuthContext: def user_from_token(self, access_token: str) -> AuthContext: failures: list[AuthException] = [] - for guard in self.guards: + 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 failures[0] + raise _first_failure(failures) class AsyncChainGuard: @@ -78,16 +80,33 @@ async def authenticate(self, request: Request) -> AuthContext: async def user_from_token(self, access_token: str) -> AuthContext: failures: list[AuthException] = [] - for guard in self.guards: + 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 failures[0] + raise _first_failure(failures) def _rejected_presented_token(guard: Any, request: Request) -> bool: - return getattr(guard, "authenticate", None) is None and _bearer_token(request) is not None + 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: diff --git a/src/fastapi_startkit_auth/guards/token.py b/src/fastapi_startkit_auth/guards/token.py index 96ad20a..bdfad3a 100644 --- a/src/fastapi_startkit_auth/guards/token.py +++ b/src/fastapi_startkit_auth/guards/token.py @@ -59,6 +59,9 @@ def user_from_token(self, access_token: str) -> AuthContext: raise InvalidTokenException("The API token is invalid.") return AuthContext(user=user, scopes=list(record.abilities), jti=record.id) + def presented_token(self, request: Request) -> str | None: + return _extract_token(request, self.header) or None + def _extract(self, request: Request) -> str | None: return _extract_token(request, self.header) @@ -101,6 +104,9 @@ async def authenticate(self, request: Request) -> AuthContext: raise AuthenticationException("Not authenticated.") return await self.user_from_token(token) + def presented_token(self, request: Request) -> str | None: + return _extract_token(request, self.header) or None + async def user_from_token(self, access_token: str) -> AuthContext: record = await call(self._tokens.verify, access_token) user = await call(self.provider.retrieve_by_id, record.user_id) diff --git a/tests/test_guard_dependency.py b/tests/test_guard_dependency.py index 27b1fb4..48560b1 100644 --- a/tests/test_guard_dependency.py +++ b/tests/test_guard_dependency.py @@ -3,7 +3,8 @@ from fastapi_startkit_auth import AsyncAuth, AsyncChainGuard, Auth, AuthConfig, ChainGuard, GuardDependency from fastapi_startkit_auth.config import ChainGuard as ChainGuardConfig -from fastapi_startkit_auth.config import OAuth2Guard, SessionGuard +from fastapi_startkit_auth.config import ApiTokenGuard, OAuth2Guard, SessionGuard +from fastapi_startkit_auth.exceptions import AuthException, InvalidTokenException from conftest import BrowserTestClient as TestClient from conftest import auth_manager, oauth2_config, register_auth @@ -129,6 +130,89 @@ def me(user=Depends(Auth.guard("api"))): assert response.json()["error"] == "invalid_token" +def test_class_level_guard_inside_a_request_reads_through_to_the_guard(guarded_app): + api, manager = guarded_app + token = manager.token_service.issue(user_id=2, client_id=None, scopes=["*"]).access_token + + @api.get("/probe") + def probe(): + guard = Auth.guard("api") + return { + "same_provider": guard.provider is manager.guard("api").provider, + "id": guard.user_from_token(token).user["id"], + "async_same_provider": AsyncAuth.guard("api").provider is manager.guard("api").provider, + } + + response = TestClient(api, base_url="https://testserver").get("/probe") + assert response.json() == {"same_provider": True, "id": 2, "async_same_provider": True} + + +def test_class_level_guard_outside_a_request_explains_itself(): + with pytest.raises(AttributeError, match="outside a request"): + Auth.guard("api").provider + + +def test_chain_user_from_token_does_not_read_the_token_as_a_session_id(guarded_app): + api, manager = guarded_app + client = TestClient(api, base_url="https://testserver") + client.post("/login", json={"email": "grace@example.com", "password": "hopper"}) + session_id = client.cookies.get(manager.session_config.cookie) + assert session_id + with pytest.raises(AuthException): + manager.guard("auth.api").user_from_token(session_id) + + +def test_a_chain_of_only_session_members_rejects_token_lookups(): + guards = {"web": SessionGuard(provider="users"), "auth.web": ChainGuardConfig(guards=["web"])} + manager = auth_manager(session_config(guards=guards, default_guard="web"), session={}, oauth2=oauth2_config()) + with pytest.raises(InvalidTokenException): + manager.guard("auth.web").user_from_token("anything") + + +def token_chain_app(header="Authorization"): + guards = { + "tokens": ApiTokenGuard(provider="users"), + "web": SessionGuard(provider="users"), + "auth.api": ChainGuardConfig(guards=["tokens", "web"]), + } + api = FastAPI() + manager = register_auth( + api, + session_config(guards=guards, default_guard="tokens"), + session={}, + oauth2=oauth2_config(), + api_tokens={"header": header}, + ) + + @api.post("/login") + def login(payload: dict = Body(...), auth: Auth = Depends(Auth.scoped)): + return {"ok": auth.attempt(payload, guard="web")} + + @api.get("/me") + def me(user=Depends(Auth.guard("auth.api"))): + return {"id": user["id"]} + + client = TestClient(api, base_url="https://testserver") + assert client.post("/login", json={"email": "grace@example.com", "password": "hopper"}).json() == {"ok": True} + return client, manager + + +def test_a_rejected_api_token_does_not_fall_back_to_the_session(): + client, manager = token_chain_app() + assert client.get("/me").json() == {"id": 2} + assert client.get("/me", headers={"Authorization": "Bearer 1|nope"}).status_code == 401 + issued = manager.api_tokens.create(user_id=1) + assert client.get("/me", headers={"Authorization": f"Bearer {issued.plain_text}"}).json() == {"id": 1} + + +def test_a_rejected_api_token_in_a_custom_header_does_not_fall_back_to_the_session(): + client, manager = token_chain_app(header="X-Api-Token") + assert client.get("/me").json() == {"id": 2} + assert client.get("/me", headers={"X-Api-Token": "1|nope"}).status_code == 401 + issued = manager.api_tokens.create(user_id=1) + assert client.get("/me", headers={"X-Api-Token": issued.plain_text}).json() == {"id": 1} + + def test_dependency_overrides_key_on_the_guard_name(guarded_app): api, _ = guarded_app api.dependency_overrides[Auth.guard("auth.api")] = lambda: {"id": 99}