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",
)
),
]
settingsis the settings object or a zero-arg provider (e.g. a cachedget_settings), resolved on every request.strandSecretStrvalues work.- Rejection raises
HTTPException(403, "Not allowed")by default. csrd-auth cannot depend on csrd-service (tier rules), so to render throughservice_exception_handlerpasserror_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:
- unknown:
RefreshTokenInvalidError; - past
expires_ator the absolute cap:RefreshTokenExpiredError(nothing revoked); - revoked: the family is revoked (again) and
RefreshTokenReuseErroris raised; - already rotated:
- within
grace_secondsofT.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
RefreshTokenReuseErroris raised;
- within
- 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;
Cookie helpers
set_refresh_cookie(response, issued, settings):HttpOnly,Path,SameSite,Secure,Max-Ageto 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:
- keeps the access token in memory (not
localStorage); - sends every request with
credentials: 'include'; - refreshes about 60 s before
expiresInelapses (POST /api/token/refresh); - on a 401, refreshes once and retries the request once; a second 401 (or a 401 from refresh) means signed out;
- coordinates tabs: a refresh runs under
navigator.locks.request(...), so one tab rotates and the others reuse its result (shared over aBroadcastChannel). Without Web Locks, theBroadcastChannelstill shares new tokens and logouts, and the server's grace window absorbs the remaining races; - 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; - 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) -> UserClaimsKeyProvider—(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)
| File | Size | Uploaded | |
|---|---|---|---|
| csrd_auth-0.8.0.tar.gz | 42.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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