Repository navigation
feat: guard dependencies, chain guards and first-party-only OAuth2 guards - #39
Conversation
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KicqT223Xif1un4NwyAeeA
…ves 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H45FAaoDKkVZYFVvML9Ya4
…ty-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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H45FAaoDKkVZYFVvML9Ya4
Review: CHANGES REQUESTEDHead reviewed: What works: Blocking
Non-blocking
Do not merge. The app PR Salut-Keera/salut-keera-ai#725 pins this head SHA, so it must be re-pinned after these fixes. |
…ed 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H45FAaoDKkVZYFVvML9Ya4
Re-review: APPROVEDHead reviewed: Both earlier blockers are fixed:
Non-blocking, for later:
Not merged; that is left to the PM. |
Summary
Lets an app guard routes with
user = Depends(Auth.guard("auth.api")), where the guard config decides how the user is resolved.Auth.guard(name)/AsyncAuth.guard(name)on the class returns aGuardDependency. It resolves the user through the named guard and raises 401 when none is found. Dependencies with the same name compare equal, soapp.dependency_overrides[Auth.guard("api")]works. On an instance,auth.guard(name)still returns the guard object.ChainGuard(guards=[...])driver tries its members in order, e.g. a bearer token, then the browser session. A bearer token the request presents is final: if the token member rejects it, the chain fails rather than falling back to the session.OAuth2Guard(first_party_only=True)refuses tokens issued to an OAuth client (client_idset).AuthManager.on_authenticated(listener)runslistener(request, user, guard)after a guard dependency resolves a user and before the handler. Apps use it for activity tracking and request state.Upgrade note
Inside a request, class-level
Auth.guard()used to return the guard. To get the guard there now, useDepends(Auth.scoped)or the manager.Tests
tests/test_guard_dependency.pycovers:The full suite and ruff pass.
Salut Keera needs this to remove its local auth. A release is needed before the app can pin a version from PyPI.
🤖 Generated with Claude Code
https://claude.ai/code/session_01H45FAaoDKkVZYFVvML9Ya4