The auth module exposes an extension point — a list of async resolvers on
app.state.auth.principal_resolvers — that lets downstream modules plug in
additional credential sources alongside the built-in session cookie. This is
the supported way to add Personal Access Tokens, API keys, JWT bearers, or
any other request-scoped authentication scheme without forking
auth.AuthMiddleware.
from collections.abc import Awaitable, Callable
from starlette.requests import Request
from auth import PrincipalResolver, UserContext
PrincipalResolver = Callable[[Request], Awaitable[UserContext | None]]A resolver MUST:
- Be async (it is awaited by the middleware).
- Bail fast when its credential type isn't present (e.g., return
Noneimmediately if there is noAuthorizationheader) — resolvers run on every request, including completely unauthenticated ones. - Self-check active / disabled state before returning a
UserContext. The middleware does not re-validate. - Never raise on bad credentials — return
Noneso the chain continues to the next resolver. The middleware swallows exceptions defensively but resolver authors should not rely on it. - Not write to the session. Resolver-authenticated requests are per-request only; they never silently elevate into a long-lived session cookie. (To mint a session, use the standard login flow.)
auth.AuthMiddleware consults credential sources in this order:
- The active
AuthProvider—provider.resolve_user(request), the existing fast/cached path (session cookie for theusersprovider). If it returns aUserContext, the user is authenticated and the resolver chain is not consulted. - Registered resolvers, in registration order. The first non-
Nonereturn wins. - Unauthenticated — for
/api/*paths (or any request the provider reports as a bearer request) the middleware returns401 {"detail": "Not authenticated"}; otherwise it 302-redirects toprovider.get_login_url(request)(theusersprovider's is/users/login) and stashes the original URL insession["next"].
The chain runs on every request, including public paths (/health,
/api/openapi.json, the login page itself). That lets a resolver attach
request.state.user for telemetry even on unauthenticated routes. The
unauthenticated response is suppressed for public paths regardless of
resolver outcome — the chain populates the principal, public-path
allow-listing controls the response.
A bearer-token resolver only ever sees the request if the provider steps
aside. The users and keycloak providers treat any Authorization: Bearer
header as theirs and return a final None for a value they do not know —
except values with an api_tokens prefix (auth.contracts.tokens.is_api_token:
smpat_, smsk_), which they skip without a lookup. A resolver for a new
bearer format needs the same carve-out.
The shipped api_tokens module is the reference
implementation: it sets UserContext.auth_kind and scopes, and the framework
caps the principal's permissions at those scopes (apply_scope_cap), skips
admin-role shortcuts for it (is_admin_principal), and refuses it on every
route without a permission gate and on every page (token_guard, see
Permissions).
A provider's own bearer credential (a users access token, a Keycloak JWT)
reports auth_kind = "bearer": unscoped, full rights, but not a cookie session.
A module that ships its own Personal-Access-Token table registers a
resolver from its on_startup hook:
# modules/example/example/module.py
from __future__ import annotations
from typing import TYPE_CHECKING
from auth import PrincipalResolver, UserContext
from simple_module_core.module import ModuleBase, ModuleMeta
from starlette.requests import Request
if TYPE_CHECKING:
from fastapi import FastAPI
class ExampleModule(ModuleBase):
meta = ModuleMeta(name="Example", depends_on=["Auth", "Users"])
async def on_startup(self, app: FastAPI) -> None:
app.state.auth.principal_resolvers.append(self._build_pat_resolver(app))
@staticmethod
def _build_pat_resolver(app: FastAPI) -> PrincipalResolver:
async def resolve_pat(request: Request) -> UserContext | None:
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return None
token = header.removeprefix("Bearer ")
# Look up the token in the module's own storage and load the user.
async with app.state.sm.db.session_factory() as session:
record = await find_active_token(session, token)
if record is None:
return None
user = await load_user_with_roles(session, record.user_id)
if user is None or not user.is_active or user.disabled_at is not None:
return None
return UserContext.from_user(user)
return resolve_patdepends_on=["Auth", "Users"] ensures AuthModule.register_settings has
run (so app.state.auth exists) and — since AuthModule.register_middleware
installs AuthMiddleware (which calls the resolvers) — that the auth stack is
wired before this module's on_startup appends its resolver. The Users
dependency is for loading the user records the resolver looks up.
The middleware does not cache resolver results (there's nothing safe to key on — credentials must be re-validated every request to honor revocation). For each authenticated request, the resolver opens a fresh DB session and queries the token store. That's one round-trip per request.
For high-traffic deployments, cache the token-to-user mapping inside the resolver — typically an LRU keyed by the token (or its hash) with a short TTL. The resolver still runs every request, but the DB lookup is skipped on cache hits. Pick a TTL short enough that revocation latency stays acceptable. The framework deliberately stays out of this — caching policy is a per-module concern.
- You want to mint a long-lived session. Use the standard login flow
(
/users/loginor OAuth). Resolvers are explicitly forbidden from writing the session. - You only need a per-endpoint API-key check. A FastAPI dependency
(
require_api_key) on the route signature is simpler and keeps the authenticated-user shape clean. - You want to override the
usersmodule's behavior (e.g., reject active users, change role semantics). Resolvers add credential sources; they don't change the rules of authentication. For that, swapUsersModule/AuthMiddlewareoutright.
Write resolver tests against a minimal app (see
modules/users/tests/_middleware_support.py::_build_app for the pattern
used by the framework's own resolver suite — it takes a
principal_resolvers= keyword and seeds app.state.auth for you).
End-to-end tests should drive the full create_app(settings) stack and
append your resolver to app.state.auth.principal_resolvers in a fixture —
see tests/test_principal_resolver_integration.py for a worked example.