Introspection auth (resource server)¶
Not every service issues its own tokens. Often your service is just a
resource server in the OAuth2 pattern: it receives an opaque bearer
minted by an upstream identity provider and must validate it by asking
that provider who owns the token. You don't decode a JWT or verify a
signature — you call a userinfo / introspection endpoint and trust the
response.
IntrospectionAuth wraps exactly that pattern:
- it validates the bearer by calling
GET <userinfo_url>withAuthorization: Bearer <token>, - it caches successful lookups in-process for a short TTL, so a burst of requests carrying the same token doesn't hammer the upstream,
- it can optionally gate access on an application claim
(
access_appsby default), and - it extracts the user id from the subject claim (
subby default).
Installation
IntrospectionAuth needs the [http] extra — uv add "tempest-fastapi-sdk[http]" (pulls in httpx, used to call the upstream userinfo).
When to use this
Use it when another service (IAGRO, a Keycloak, an Auth0, your
own identity service) mints the tokens and your service only needs to
accept them. If your service is the one that logs users in and
issues tokens, you want UserAuthService + make_auth_router (see
Auth flow), not this.
The minimal path¶
Instantiate once, pointing at the provider's userinfo endpoint, and use the two methods as FastAPI dependencies:
# src/api/dependencies/auth.py
from tempest_fastapi_sdk import IntrospectionAuth
from src.core.settings import settings
auth = IntrospectionAuth(
userinfo_url=settings.IAGRO_USERINFO_URL, # e.g. https://id.iagro.gov/users/me
required_app="famacha", # per-app access gate
)
# src/api/routers/animals.py
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends
from src.api.dependencies.auth import auth
from src.services.animals import animals
router = APIRouter(prefix="/api/animals", tags=["animals"])
@router.get("/me")
async def whoami(
claims: dict[str, Any] = Depends(auth.get_claims),
) -> dict[str, Any]:
"""Return the raw claims of the authenticated user."""
return claims
@router.get("/")
async def list_animals(
user_id: UUID = Depends(auth.get_user_id),
) -> list[str]:
"""List the user's animals — id already resolved from the token."""
return await animals.list_for(user_id)
animals is your application's glue
animals.list_for(...) stands in for your own service/repository layer —
it is not part of the SDK. Swap it for your project's real call.
That's it. A request without Authorization gets 401; a token the
upstream rejects gets 401; a user without famacha in access_apps
gets 403.
Wire up the exception handlers
IntrospectionAuth raises the SDK's own UnauthorizedException
(401) and ForbiddenException (403). Call
register_exception_handlers(app) (the SDK's create_app() already
does) so they become the right HTTP statuses instead of 500.
How it works, piece by piece¶
get_claims — the heart¶
get_claims is the primary dependency. It:
- reads the bearer via
HTTPBearer(auto_error=False)— no header →UnauthorizedException; - calls
fetch_userinfo(token)(cached); - if
required_appis set, requires it to be inclaims.get(app_claim) or [], otherwiseForbiddenException; - returns the claims dict.
import asyncio
from typing import Any
from fastapi.security import HTTPAuthorizationCredentials
from tempest_fastapi_sdk import IntrospectionAuth
from src.core.settings import settings
auth = IntrospectionAuth(userinfo_url=settings.IAGRO_USERINFO_URL)
credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials="eyJhbGciOiJIUzI1NiJ9.token",
)
async def main() -> None:
"""Run this example."""
claims: dict[str, Any] = await auth.get_claims(credentials)
# {"sub": "…", "access_apps": ["famacha"], "email": "…", ...}
asyncio.run(main())
get_user_id — the common shortcut¶
Most routes only want the user id, not the whole dict. get_user_id
depends on the same bearer, calls get_claims internally, and does
UUID(str(claims["sub"])):
import asyncio
from uuid import UUID
from fastapi.security import HTTPAuthorizationCredentials
from tempest_fastapi_sdk import IntrospectionAuth
from src.core.settings import settings
auth = IntrospectionAuth(userinfo_url=settings.IAGRO_USERINFO_URL)
credentials = HTTPAuthorizationCredentials(
scheme="Bearer",
credentials="eyJhbGciOiJIUzI1NiJ9.token",
)
async def main() -> None:
"""Run this example."""
user_id: UUID = await auth.get_user_id(credentials)
asyncio.run(main())
A missing subject, or one that isn't a valid UUID, becomes
UnauthorizedException.
Why doesn't get_user_id declare Depends(self.get_claims)?
Default arguments are evaluated at method-definition time, when
the self instance doesn't exist yet — so you can't write
Depends(self.get_claims) in the signature. The fix: get_user_id
depends on the bearer directly and calls
await self.get_claims(credentials) in its body. It still wires
cleanly as Depends(auth.get_user_id).
The cache¶
Every 200 response is stored for cache_ttl_seconds (30 by default),
with a time.monotonic() clock and the raw token as the key. Within the
TTL, the second call does not hit the upstream. A 401/403
evicts the token immediately. cache_ttl_seconds=0 disables caching.
from tempest_fastapi_sdk import IntrospectionAuth
from src.core.settings import settings
auth = IntrospectionAuth(
userinfo_url=settings.IAGRO_USERINFO_URL,
cache_ttl_seconds=60, # tolerate up to 60s of a revoked token
)
TTL is a revocation window
While a claim is cached, an upstream revocation isn't seen. Pick a
short TTL (seconds to a few minutes) to balance provider load against
freshness. 0 disables the cache and always revalidates.
Lazy URL (callable)¶
userinfo_url accepts a str or a zero-argument callable, resolved
on every call. That lets you pass a settings property read only at
runtime (handy when the URL arrives late from the environment, or varies
per tenant):
from tempest_fastapi_sdk import IntrospectionAuth
from src.core.settings import settings
auth = IntrospectionAuth(
userinfo_url=lambda: settings.IAGRO_USERINFO_URL,
)
Custom claims¶
If your provider uses different names, tweak them:
from tempest_fastapi_sdk import IntrospectionAuth
from src.core.settings import settings
auth = IntrospectionAuth(
userinfo_url=settings.IDP_USERINFO_URL,
required_app="famacha",
app_claim="apps", # instead of "access_apps"
subject_claim="user_id", # instead of "sub"
)
HTTP client¶
By default the instance creates one shared httpx.AsyncClient
(lazily, with httpx.Timeout(timeout)) and reuses it. You can inject
your own — handy in tests or to share a pool / limits:
import httpx
from tempest_fastapi_sdk import IntrospectionAuth
from src.core.settings import settings
client = httpx.AsyncClient(timeout=httpx.Timeout(5.0))
auth = IntrospectionAuth(
userinfo_url=settings.IDP_USERINFO_URL,
http_client=client,
)
The cache is per instance, not global — the app can create several
IntrospectionAuth objects (one per upstream) without one leaking state
into another.
Testing¶
Inject an httpx.AsyncClient with a MockTransport so you never touch
the network:
import httpx
import pytest
from fastapi.security import HTTPAuthorizationCredentials
from tempest_fastapi_sdk import IntrospectionAuth
def _handler(request: httpx.Request) -> httpx.Response:
assert request.headers["Authorization"] == "Bearer tok"
return httpx.Response(200, json={"sub": "…", "access_apps": ["famacha"]})
@pytest.mark.asyncio
async def test_valid_token() -> None:
auth = IntrospectionAuth(
userinfo_url="https://id.example.com/users/me",
required_app="famacha",
http_client=httpx.AsyncClient(transport=httpx.MockTransport(_handler)),
)
creds = HTTPAuthorizationCredentials(scheme="Bearer", credentials="tok")
claims = await auth.get_claims(creds)
assert claims["access_apps"] == ["famacha"]
Recap¶
IntrospectionAuthis for the resource server pattern: it validates opaque bearers against an upstreamuserinfo— it never issues tokens.get_claimsandget_user_idare bound methods usable directly asDepends(auth.get_claims)/Depends(auth.get_user_id).- No credentials → 401; rejected token / upstream down → 401; app not granted → 403; invalid subject → 401.
- In-process per-token cache with a
time.monotonic()TTL;401/403evicts;cache_ttl_seconds=0disables it. userinfo_urlcan be astror a callable (resolved per call); claims (app_claim,subject_claim) and thehttpxclient are configurable. Cache and client are per instance.