Skip to content

feat: guard dependencies, chain guards and first-party-only OAuth2 guards - #39

Merged
claude-tmgbedu merged 4 commits into
mainfrom
feature/guard-dependency
Oct 24, 2026
Merged

claude-tmgbedu merged 4 commits into
mainfrom
feature/guard-dependency

Conversation

@claude-tmgbedu

Copy link
Copy Markdown
Contributor

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 a GuardDependency. It resolves the user through the named guard and raises 401 when none is found. Dependencies with the same name compare equal, so app.dependency_overrides[Auth.guard("api")] works. On an instance, auth.guard(name) still returns the guard object.
  • New 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_id set).
  • AuthManager.on_authenticated(listener) runs listener(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, use Depends(Auth.scoped) or the manager.

Tests

tests/test_guard_dependency.py covers:

  • valid, missing and invalid tokens
  • client-credentials tokens
  • chain fallback, and no fallback after a rejected token
  • first-party-only guards
  • dependency overrides
  • listeners
  • chain config errors
  • async chains

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

tmgbedu and others added 3 commits October 24, 2026 12:27
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
@claude-tmgbedu

Copy link
Copy Markdown
Contributor Author

Review: CHANGES REQUESTED

Head reviewed: ad300399fb2712149e5582718edf1ed88941329c. Full suite and ruff check . pass locally.

What works: GuardDependency resolves through the named guard and raises 401. Equal names compare and hash equal, and I checked that FastAPI caches two Depends(Auth.guard("x")) on one route, so the guard runs once. first_party_only, chain config validation, async chain selection and the on_authenticated hook all look correct.

Blocking

  1. src/fastapi_startkit_auth/facade.py:17-23 (GuardAccessor) breaks existing callers. On v0.7.0, class-level Auth.guard("api") inside a request goes through ScopedMethod and returns the guard. After this PR it returns a GuardDependency, so code like Auth.guard("api").provider or .user_from_token(...) / .authenticate(request) now fails with AttributeError. The task asked for the dependency and for the guard proxy to keep working inside a request. Fix: have GuardDependency forward unknown attributes to the guard inside an active request, for example __getattr__ → current_auth_request().manager.guard(self.name). Add a test showing that Auth.guard("api").provider still works inside a request.
  2. src/fastapi_startkit_auth/guards/chain.py:88-89 (_rejected_presented_token): the rule "a presented bearer token is final" only works for oauth2 members. It treats a member as a token guard only when the member has no authenticate method. TokenGuard (driver: "token") has authenticate, so in a chain ["tokens", "web"] an invalid API token falls back to the session. That contradicts the docstring and the CHANGELOG. The check also reads only Authorization: Bearer, but TokenGuard.header can be configured. Fix: ask the member whether it saw a credential, e.g. an InvalidTokenException from a member is final while an AuthenticationException("Not authenticated.") lets the chain continue. Add a test for a token-driver chain.

Non-blocking

  1. ChainGuard.user_from_token gives the same string to every member, so a session guard looks up a bearer string as a session id. Nothing in the package calls it today, but manager.guard("auth.api").user_from_token(x) would accept a raw session id as a bearer token. Either limit it to the token members or raise.
  2. CHANGELOG.md: the class-level Auth.guard() change is listed under "Added". If it stays breaking, move it to "Breaking changes and upgrade notes" and use feat!. With fix 1 it would no longer be breaking.

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
@claude-tmgbedu

Copy link
Copy Markdown
Contributor Author

Re-review: APPROVED

Head reviewed: 657fec782f3ebe1ae1a8a5a7f9e73a52a483c05a. Full suite: 441 passed, 92 skipped. ruff check . is clean.

Both earlier blockers are fixed:

  1. Backward compatibility. Inside an active request, GuardDependency.__getattr__ forwards attribute access to context.manager.guard(self.name). Dunder names and name are excluded, so FastAPI's introspection and __eq__/__hash__ are not affected. Outside a request it raises a clear AttributeError. Both cases are tested. The CHANGELOG lists this under "Breaking changes and upgrade notes", with the remaining caveat (no identity or isinstance match) and a migration path.
  2. The chain treats a presented token as final for every member type. presented_token(request) on TokenGuard/AsyncTokenGuard reads the configured header. oauth2 members fall back to Authorization: Bearer, and session members never count as reading a token. Tests cover the default header and X-Api-Token.

user_from_token now skips session members, and a chain of only session members raises InvalidTokenException. Both cases are tested.

Non-blocking, for later:

  • In a chain with two members that both read Authorization (an oauth2 guard and a token guard), the first member's rejection is final, so the second never gets the token. This is consistent with the documented rule. It may be worth stating in the README if someone builds an "OAuth or personal token" chain.
  • __getattr__ calls manager.guard(name). If the name is not configured, attribute lookups inside a request raise the manager's error rather than AttributeError. That only happens on a misconfiguration.

Not merged; that is left to the PM.

@claude-tmgbedu
claude-tmgbedu merged commit f8782e2 into main Oct 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants