Skip to main content

csrd-auth

Pluggable JWT authentication for FastAPI — authenticators, key providers, and dependency factories.

Tier 2 (bridge) — depends on csrd-models, csrd-context and csrd-logging only (never csrd-repository: SQL storage is duck-typed over any adapter).

Installation

pip install "csrd-auth @ git+https://github.com/csrd-api/fastapi-common.git#subdirectory=packages/auth"

Usage

from csrd.auth import (
    JWTAuthenticator,
    StaticKeyProvider,
    create_bearer_dependency,
    create_jwt_bearer,
)

Quick start — JWT bearer dependency

# Standalone (provide your own token_finder)
from csrd.auth import create_jwt_bearer

auth_dep = create_jwt_bearer(
    key="your-secret",
    token_finder=lambda: extract_token_from_request(),
)

When used with csrd.versioning, wire in find_token explicitly:

from csrd.auth import create_jwt_bearer
from csrd.versioning import find_token

auth_dep = create_jwt_bearer(key="your-secret", token_finder=find_token)

Internal token guard (service-to-service)

require_internal_token protects endpoints that other services call (not users) with a shared token compared in constant time. It fails closed: when the configured token is unset or empty, every request gets 403.

from typing import Annotated

from fastapi import Depends

from csrd.auth import require_internal_token
from csrd.models import BaseSettings, InternalToken


class Settings(BaseSettings):
    internal_token: InternalToken = ""  # refuses to boot unless INTERNAL_TOKEN is a real secret


settings = Settings()

# Defaults: header "X-Internal-Token", setting "internal_token"
Internal = Annotated[None, Depends(require_internal_token(settings))]


@router.post("/internal/refs")
async def refs(_: Internal) -> dict: ...

Custom names (e.g. X-Internal-Service-Token / internal_service_token):

InternalService = Annotated[
    None,
    Depends(
        require_internal_token(
            settings,
            setting_name="internal_service_token",
            header="X-Internal-Service-Token",
        )
    ),
]
  • settings is the settings object or a zero-arg provider (e.g. a cached get_settings), resolved on every request. str and SecretStr values work.
  • Rejection raises HTTPException(403, "Not allowed") by default. csrd-auth cannot depend on csrd-service (tier rules), so to render through service_exception_handler pass error_factory=lambda: AuthorizationError("Not allowed").
  • Callers send the header with BaseDelegate(..., internal_token=settings.internal_token).
  • Strip the header from inbound client traffic at the gateway.

Refresh-token sessions (token issuers)

Short-lived JWT access tokens plus a long-lived, rotating refresh token in an HttpOnly cookie. A session is a family of refresh tokens: sign-in starts a family, every refresh rotates the presented token into a successor, and exactly one token per family is live. Only sha256(token) is stored; the raw token is secrets.token_urlsafe(48).

from csrd.auth import (
    SessionManager,
    SessionSettings,
    SqlRefreshTokenStore,
    RefreshTokenReuseError,
    SessionError,
    set_refresh_cookie,
    clear_refresh_cookie,
    refresh_token_from_request,
)

sessions = SessionManager(SqlRefreshTokenStore(app.state.db_adapter), SessionSettings())

issued = await sessions.issue(user_id, remember=True, user_agent=ua)  # IssuedSession
set_refresh_cookie(response, issued, sessions.settings)              # Set-Cookie
# mint the access JWT yourself (KeyRingManager), with sid=issued.session_id

issued = await sessions.rotate(raw_token)      # raises SessionError subclasses -> 401
await sessions.revoke(raw_token)               # logout: whole family
await sessions.revoke_all_for_user(user_id)    # sign out everywhere
await sessions.list_sessions(user_id)          # [SessionInfo], id = family id
await sessions.revoke_session(user_id, session_id)
await sessions.purge_expired(retention=timedelta(days=7))

Rotation rules

For a presented token T, rotate does, in order:

  1. unknown: RefreshTokenInvalidError;
  2. past expires_at or the absolute cap: RefreshTokenExpiredError (nothing revoked);
  3. revoked: the family is revoked (again) and RefreshTokenReuseError is raised;
  4. already rotated:
    • within grace_seconds of T.rotated_at, with a live, unexpired head: not reuse. The family's current head is rotated and its successor returned (IssuedSession.grace is True). The family still has one live token, and the head just replaced gets its own grace window. This absorbs two tabs or a retried request racing on one cookie.
    • otherwise: the family is revoked and RefreshTokenReuseError is raised;
  5. live: rotated into a successor (compare-and-set, so concurrent rotations of the same token cannot both win).

The reuse revoke persists although the request fails. It is written by RefreshTokenStore.revoke_family before the error is raised, outside any caller transaction. SqlRefreshTokenStore must be given the unbound adapter (app.state.db_adapter), never one yielded by adapter.transaction(): it refuses a transaction-bound adapter with RuntimeError, autocommits revokes, and runs each rotation in its own top-level transaction(). (csrd-repository's transaction(join=...) only joins when asked; the store never joins.) On SQLiteAdapter (single connection) do not call the store while holding adapter.transaction() in the same task.

Settings

SessionSettings (frozen pydantic model; durations take timedelta or seconds):

Field Default Meaning
access_ttl 15 min Access-token lifetime (carried for the issuer/client; the library does not mint JWTs)
refresh_ttl 12 h Refresh lifetime without remember-me
remember_ttl 30 d Lifetime with remember=True; None disables remember-me
absolute_ttl 90 d Hard cap from sign-in; None = no cap
sliding True Rotation restarts the lifetime (capped). False: successors keep the family's original expiry
grace_seconds 30 Grace window for a just-rotated token; 0 disables
cookie_name refresh_token
cookie_path /api/token Must cover every route that reads the cookie (refresh and logout)
cookie_domain None
cookie_samesite strict none requires cookie_secure
cookie_secure True Browsers accept Secure cookies on http://localhost
transient_cookie_without_remember False True: a non-remembered session gets a browser-session cookie (no Max-Age)

The cookie is always HttpOnly; Max-Age tracks the token's expiry.

Configuring the two known deployments (scaffold env vars in brackets):

card-gen vtt-cluster
access 900 s (JWT_TTL_SECONDS=900) 3600 s (JWT_TTL_SECONDS=3600)
refresh 12 h (SESSION_REFRESH_TTL_SECONDS=43200) 30 d (SESSION_REFRESH_TTL_SECONDS=2592000)
remember-me 30 d (SESSION_REMEMBER_TTL_SECONDS=2592000) off (SESSION_REMEMBER_TTL_SECONDS=0)
absolute cap 90 d (SESSION_ABSOLUTE_TTL_SECONDS=7776000) none (SESSION_ABSOLUTE_TTL_SECONDS=0)
sliding yes (SESSION_SLIDING=true) no (SESSION_SLIDING=false)
grace 30 s (SESSION_GRACE_SECONDS=30) 0 s today (SESSION_GRACE_SECONDS=0); 30 recommended with multi-tab clients
cookie defaults defaults (SESSION_COOKIE_SECURE replaces COOKIE_SECURE)

card-gen's values are the library defaults.

Storage and migration

RefreshTokenStore is a protocol. InMemoryRefreshTokenStore serves tests and single-process apps. SqlRefreshTokenStore(adapter, table="refresh_tokens") works over any csrd-repository adapter; csrd-auth does not import csrd-repository (tier rule), it duck-types the adapter like KeyRingManager.

REFRESH_TOKENS_MIGRATION_UP / _DOWN (and refresh_tokens_migration_sql(table=, users_table=, users_id_column=, user_id_type=)) create, portably across SQLite / PostgreSQL / MariaDB:

refresh_tokens(
  id VARCHAR(64) PK, token_hash VARCHAR(64) UNIQUE NOT NULL,
  user_id VARCHAR(255) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  family_id VARCHAR(64) NOT NULL, created_at, last_used_at, expires_at NOT NULL,
  absolute_expires_at, remember INTEGER NOT NULL DEFAULT 0, rotated_at,
  replaced_by VARCHAR(64), revoked_at, user_agent VARCHAR(512))
+ indexes on family_id and user_id

Timestamps are written as fixed-width ISO-8601 UTC text (2026-01-31T12:00:00.000000+00:00); native TIMESTAMP values and naive text (read as UTC) are also accepted when reading a migrated table. The scaffold registers it as migration 005 "Refresh-token sessions table". (SQLite enforces the cascade only with PRAGMA foreign_keys=ON.)

Mapping an existing refresh_tokens table

Option A, drop and re-login: DROP TABLE refresh_tokens; then apply the library migration. Every user signs in once more.

Option B, migrate rows in place. Rename the old table, drop its indexes (the new ones reuse the names idx_refresh_tokens_family / _user), apply REFRESH_TOKENS_MIGRATION_UP (with user_id_type matching your users id), copy, drop the old table. Timestamp conversion to the canonical text:

  • SQLite: strftime('%Y-%m-%dT%H:%M:%f', col) || '000+00:00'
  • PostgreSQL (timestamptz): to_char(col AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"+00:00"')
  • MariaDB (UTC DATETIME(6)): DATE_FORMAT(col, '%Y-%m-%dT%H:%i:%s.%f+00:00')

Below, TS(col) stands for that expression (and stays NULL for NULL).

card-gen (same columns; only types/format change):

ALTER TABLE refresh_tokens RENAME TO refresh_tokens_legacy;
-- drop legacy indexes, then apply REFRESH_TOKENS_MIGRATION_UP
INSERT INTO refresh_tokens (id, token_hash, user_id, family_id, created_at, last_used_at,
    expires_at, absolute_expires_at, remember, rotated_at, replaced_by, revoked_at, user_agent)
SELECT CAST(id AS VARCHAR(64)), token_hash, user_id, CAST(family_id AS VARCHAR(64)),
       TS(created_at), TS(last_used_at), TS(expires_at), TS(absolute_expires_at),
       CASE WHEN remember THEN 1 ELSE 0 END, TS(rotated_at),
       CAST(replaced_by AS VARCHAR(64)), TS(revoked_at), user_agent
FROM refresh_tokens_legacy;
DROP TABLE refresh_tokens_legacy;

replaced_by must hold the successor row's id (it does in card-gen). If the hash column has another name, select it AS token_hash.

vtt-cluster (id, token_hash, user_id, family_id, expires_at, revoked_at, created_at). vtt revokes the old token on rotation, so its revoked rows keep revoked_at and presenting one is still reuse:

ALTER TABLE refresh_tokens RENAME TO refresh_tokens_legacy;
-- drop legacy indexes, then apply refresh_tokens_migration_sql(user_id_type=<users.id type>)
INSERT INTO refresh_tokens (id, token_hash, user_id, family_id, created_at, last_used_at,
    expires_at, absolute_expires_at, remember, rotated_at, replaced_by, revoked_at, user_agent)
SELECT CAST(id AS VARCHAR(64)), token_hash, user_id, CAST(family_id AS VARCHAR(64)),
       TS(created_at), TS(created_at), TS(expires_at), NULL,
       0, NULL, NULL, TS(revoked_at), NULL
FROM refresh_tokens_legacy;
DROP TABLE refresh_tokens_legacy;
  • set_refresh_cookie(response, issued, settings): HttpOnly, Path, SameSite, Secure, Max-Age to expiry.
  • clear_refresh_cookie(response, settings): same attributes, Max-Age=0.
  • refresh_token_from_request(request, settings, body_token=None): the cookie, else the body token (non-browser clients).

Scaffolded endpoints (jwt-auth-provider)

Endpoint Auth Behaviour
POST /api/token public Body {username, password, remember?, returnRefreshToken?}. Returns {accessToken, tokenType, expiresIn, sessionId}; sets the cookie
POST /api/token/refresh cookie Rotates the cookie, returns a new access token. Optional body {refreshToken, returnRefreshToken} when there is no cookie. 401 clears the cookie
POST /api/token/logout cookie Revokes the family, clears the cookie, 204. Works with an expired access token
GET /api/sessions JWT The caller's active sessions (current marks the caller's sid)
DELETE /api/sessions/{id} JWT Revoke one of the caller's sessions (404 if not theirs)
POST /api/sessions/revoke-all JWT Sign out everywhere: {revoked: n}

Access tokens carry sid (session id). Revoking a session stops its refresh token; access tokens already issued live until exp, so keep access_ttl short.

Client contract

Browsers never see the refresh token. A client:

  1. keeps the access token in memory (not localStorage);
  2. sends every request with credentials: 'include';
  3. refreshes about 60 s before expiresIn elapses (POST /api/token/refresh);
  4. on a 401, refreshes once and retries the request once; a second 401 (or a 401 from refresh) means signed out;
  5. coordinates tabs: a refresh runs under navigator.locks.request(...), so one tab rotates and the others reuse its result (shared over a BroadcastChannel). Without Web Locks, the BroadcastChannel still shares new tokens and logouts, and the server's grace window absorbs the remaining races;
  6. logs out with POST /api/token/logout (inside the cookie path, so the cookie is sent and the session revoked), then clears local state in every tab;
  7. restores a session on page load with one refresh.

The frontend-vue scaffold ships this as src/api/session.ts (createSessionClient(options): login, refresh, restore, logout, fetch, getAccessToken, subscribe, dispose), framework-agnostic and dependency-free.

Behind the gateway, /api/token* must be public routes that forward Cookie and Set-Cookie per request (never a shared cookie jar), and /api/sessions* JWT-guarded routes to the auth service.

Key ring readiness

KeyRingManager.initialized is True once initialize() has an active key; health() reports it as UP / DOWN. The auth scaffold initialises the key ring in the lifespan (app.state.key_ring) and passes health_gates={"keyRing": state_health_gate("key_ring", "initialized")} (csrd-versioning), so /_info/health is 503 until the first key exists.

Authenticators

Class Strategy
JWTAuthenticator Decode & verify JWT locally via PyJWT
StaticAuthenticator Accept a fixed token (dev/testing)
RemoteAuthenticator Validate via upstream HTTP endpoint
CallbackAuthenticator Delegate to any sync/async callable
ChainedAuthenticator Try multiple authenticators in order

Key Providers

Class Strategy
StaticKeyProvider Fixed string, bytes, or SecretStr
EnvKeyProvider Read from app.state or settings loader
JWKSKeyProvider Fetch from JWKS endpoint (cached with TTL)
MultiKeyProvider Route by kid header or try all
CallbackKeyProvider Delegate to any callable

Protocols

  • Authenticator — (Request, str) -> UserClaims
  • KeyProvider — (token_headers, app) -> key

Both are @runtime_checkable protocols.

Dependency Tier

Tier 1      csrd.models  csrd.lifespan  csrd.context
Tier 2      csrd.auth  csrd.logging  csrd.delegate  csrd.repository  csrd.service
Tier 3      csrd.versioning

csrd.auth depends only on csrd.models (for UserClaims) and csrd.context (for user_info_context). It has no dependency on csrd.versioning.

Metadata

Release files for csrd-auth 0.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for csrd-auth 0.8.0
File Size Uploaded
csrd_auth-0.8.0.tar.gz 42.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for csrd-auth 0.8.0
File Interpreter ABI Platform
csrd_auth-0.8.0-py3-none-any.whl Python 3 none any Details

Total release size: 76.3 kB

Release files / csrd_auth-0.8.0.tar.gz

Download URL csrd_auth-0.8.0.tar.gz
Size 42.4 kB
Tags Source
SHA-256 checksum
How to use checksums
cf394b3a34c8adbd6ae9618bb487dab3f706295e22640433c2180d9554d96805
BLAKE2b-256 checksum
How to use checksums
48282cbf1bf256c1f1a34212601ec0d4112e9e3ae27afb789425a881894b31d0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release files / csrd_auth-0.8.0-py3-none-any.whl

Download URL csrd_auth-0.8.0-py3-none-any.whl
Size 33.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
140d8e0195e251a3ac1b9ab322c476ef30c5e5682fb24824369d938fa78a15ad
BLAKE2b-256 checksum
How to use checksums
901f6862b8cbefdec0e544d8ec25a1702425110efaecb5cdc8b4bdf1d47067d2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.8.0 This release

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.30

2 release files

0.5.29

2 release files

0.5.28

2 release files

0.5.27

2 release files

0.5.26

2 release files

0.5.25

2 release files

0.5.24

2 release files

0.5.23

2 release files

0.5.22

2 release files

0.5.21

2 release files

0.5.9

2 release files

0.5.8

2 release files

0.5.7

2 release files

0.5.6

2 release files

0.5.5

2 release files

0.5.4

2 release files

0.5.3

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.9

2 release files

0.3.8

2 release files

0.3.7

2 release files

0.3.6

2 release files

0.3.5

2 release files

0.3.4

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.45

2 release files

0.1.44

2 release files

0.1.43

2 release files

0.1.42

2 release files

0.1.41

2 release files

0.1.40

2 release files

0.1.39

2 release files

0.1.38

2 release files

0.1.37

2 release files

0.1.36

2 release files

0.1.35

2 release files

0.1.34

2 release files

0.1.33

2 release files

0.1.32

2 release files

0.1.31

2 release files

0.1.30

2 release files

0.1.29

2 release files

0.1.28

2 release files

0.1.26

2 release files

0.1.25

2 release files

0.1.24

2 release files

0.1.23

2 release files

0.1.22

2 release files

0.1.21

2 release files

0.1.20

2 release files

0.1.19

2 release files

0.1.18

2 release files

0.1.17

2 release files

0.1.15

2 release files

0.1.14

2 release files

0.1.13

2 release files

0.1.11

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page