JAFAAL is a batteries-included, embedded FastAPI authentication library and a standards-shaped authorization server for applications controlled by one host. It integrates with synchronous SQLAlchemy and owns the security-critical parts of auth so your app doesn't have to:
- Password login (Argon2id) with progressive per-account lockout
- JWT access/refresh tokens (HS256) with refresh-token rotation + reuse detection
- Sessions with idle/absolute timeout, CSRF binding, and web/mobile (PKCE) flows
- MFA (TOTP + single-use backup codes) with replay protection
- API keys with a host-controlled scope allow-list
- SSO / OIDC identity providers with SSRF-guarded outbound calls
- Password reset & sign-up flows that emit events (you deliver the email)
- A
JafaalError→ HTTP edge handler, so the core never imports HTTP concerns
JAFAAL depends only on a small set of ports you implement (your user table, your dynamic settings, and how you deliver notifications). Everything else — tables, routers, token logic — ships with the library.
JAFAAL authenticates your users for your API. Concretely, it plays four roles, and implements the standards that govern each:
| Role | Standards |
|---|---|
| JWT issuer for your own resource servers | RFC 9068 (at+jwt access tokens), RFC 7519, RFC 7517 / 7638 (JWKS + kid thumbprints), RFC 8414 (discovery) |
| Bearer-token resource server | RFC 6750 (header extraction, WWW-Authenticate challenges incl. insufficient_scope) |
| Authorization server for your own native apps | RFC 6749 §4.1 (authorization code), RFC 7636 (PKCE S256), RFC 8252 (native apps / public clients), RFC 9700 (exact redirect-URI matching) |
| OAuth client / OIDC Relying Party for SSO | RFC 6749 client role, RFC 7636, OIDC Core 1.0 (nonce, azp, at_hash, userinfo sub check), RFC 9700 |
| Credential authority | NIST SP 800-63B-4 (NFC + length policy + configured blocklist), RFC 6238 (TOTP), W3C WebAuthn L2 (passkeys), RFC 7662 (introspection), RFC 7009 (revocation) |
NIST password alignment is conditional on a configured checker being available. The HIBP adapter fails open during an outage; use a local fail-closed blocklist when uninterrupted enforcement is required.
Important
JAFAAL is an authorization server for clients you own.
That is the boundary, and it is deliberate rather than unfinished. Register
your applications via AuthSettings.oauth_clients and drive /auth/authorize
→ /auth/token with any standard OAuth client library — PKCE mandatory,
code the only response type, redirect URIs matched byte-for-byte except for
native IP-loopback ports, clients public per RFC 8252.
Not planned: third-party clients (consent screen, client secrets, dynamic
registration), being an OpenID Provider (id_token, userinfo, the logout
specs, certification), client_credentials, and the implicit/hybrid/ROPC
grants that OAuth 2.1 removes. If you need to be an identity provider, put a
real one in front of JAFAAL — it already speaks to Keycloak, Authentik,
Authelia, Casdoor and Pocket ID as a relying party.
POST /auth/login authenticates a first-party user directly; it is not the
resource-owner password-credentials grant, and the discovery document
deliberately does not advertise it. A native app should prefer
/auth/authorize, which keeps the password out of the app entirely
(RFC 8252 §8.1).
Both web and mobile clients are first-class. They differ only in refresh-token
delivery: browsers get an HttpOnly, SameSite=Strict cookie (so page script
never touches it, per RFC 9700 §7.2), native clients get it in the response body.
- Synchronous SQLAlchemy only. JAFAAL's endpoints and CRUD layer take a
Session, not anAsyncSession, and the registered factory must be a syncsessionmaker. FastAPI runs synchronous handlers in its worker thread pool; JAFAAL's writes cannot share a transaction with hostAsyncSessionwork. - One process-wide configuration.
jafaal.configure()and theconfigure_*adapter functions install module-level settings, ports, stores, and registries shared by every JAFAAL router in the process. Two differently configured JAFAAL instances cannot be isolated in one process. Each worker must configure itself, and replicas need distributed state where documented. - First-party public clients only. OAuth clients are trusted applications
owned by the same host, registered statically in
AuthSettings.oauth_clients, and authenticated with PKCE rather than a client secret. JAFAAL v0.2 has no third-party client lifecycle, consent records, confidential-client authentication, or dynamic registration.
pip install jafaal
# or
uv add jafaalRequires Python 3.12+.
A minimal "login + JWT + sessions" deployment needs no extras. Multi-factor authentication and single sign-on pull in additional packages, so they ship as optional extras. Install only what you use:
pip install 'jafaal[mfa]' # TOTP MFA (pyotp) + QR provisioning (qrcode)
pip install 'jafaal[webauthn]' # passkeys / WebAuthn (py_webauthn)
pip install 'jafaal[sso]' # OpenID Connect identity providers (authlib)
pip install 'jafaal[redis]' # distributed StateStore adapter (redis)
pip install 'jafaal[migrations]' # packaged Alembic revisions
pip install 'jafaal[all]' # everythingIf a feature is used without its extra installed, JAFAAL fails fast with a clear
install hint (a MissingDependencyError) rather than an obscure error.
Releases are built and published by this repository's release workflow through PyPI Trusted Publishing, with PEP 740 attestations. You can confirm a downloaded artifact came from that workflow and was not substituted:
uvx pypi-attestations verify pypi \
--repository https://github.com/endurain-project/jafaal \
pypi:jafaal-<version>-py3-none-any.whlA successful run prints OK: <filename>. Provenance for file ... was not found means the artifact predates attested publishing rather than that verification failed.
Each release run also produces a CycloneDX SBOM and SHA256SUMS, generated from a clean install of the built wheel. These are retained as workflow artifacts on the release run rather than published to PyPI.
JAFAAL never reads environment variables itself — you build the settings and inject them once at startup. Configuration is grouped by concern, so you only read the groups you actually use.
import jafaal
from cryptography.fernet import Fernet
jafaal.configure(
jafaal.AuthSettings(
secrets=jafaal.Secrets(
secret_key="<32+ byte JWT signing secret>",
fernet_key=Fernet.generate_key().decode(), # at-rest token encryption
),
base_url="https://app.example.com",
app_name="Example", # shown in authenticator apps
environment="production", # drives the cookie Secure flag
# Every other group has working defaults; override only what you need:
# tokens=jafaal.TokenSettings(access_token_expire_minutes=10),
# sessions=jafaal.SessionSettings(idle_timeout_enabled=True),
# webauthn=jafaal.WebAuthnSettings(second_factor_enabled=True),
)
)You own the declarative registry; JAFAAL maps its companion tables into it with
map_models, so both share one metadata. Your model must be named Users,
mapped to the users table.
from sqlalchemy import String, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
from jafaal import IntPKUserMixin
class Base(DeclarativeBase): # you own the base
pass
class Users(IntPKUserMixin, Base):
__tablename__ = "users"
# Add any app-specific profile columns — JAFAAL never touches them:
display_name: Mapped[str | None] = mapped_column(String(250))
jafaal.map_models(Base) # map JAFAAL's companion tables into your registry
engine = create_engine("postgresql+psycopg://...")
jafaal.configure_sessionmaker(sessionmaker(bind=engine, autoflush=False))
Base.metadata.create_all(engine) # or use Alembic migrationsThe reverse relationships (users_sessions, local_credential, auth_mfa, …)
and the mfa_enabled property are supplied by the mixin — you declare none of them.
from jafaal import (
PasswordPolicy,
SignupConfig,
UserProtocol,
configure_settings_provider,
configure_user_repository,
)
class SqlUserRepository:
def get_by_id(self, user_id, db) -> UserProtocol | None:
return db.get(Users, user_id)
def get_by_email(self, email, db):
return db.query(Users).filter(Users.email == email).one_or_none()
def get_by_username(self, username, db):
return db.query(Users).filter(Users.username == username).one_or_none()
def create_local_user(self, username, email, db, *, is_active, is_verified):
user = Users(username=username, email=email, is_active=is_active, is_verified=is_verified)
db.add(user)
db.flush()
db.refresh(user)
return user
def provision_from_idp(self, identity, db): ... # SSO auto-provisioning
def sync_from_idp(self, user_id, claims, db): ... # optional profile sync
def set_email_verified(self, user_id, db, *, activate):
user = db.get(Users, user_id)
user.is_verified = True
if activate:
user.is_active = True
db.flush()
class StaticSettingsProvider:
def get_password_policy(self) -> PasswordPolicy:
return PasswordPolicy(min_length_regular=15, min_length_admin=20, password_type="length_only")
def get_signup_config(self) -> SignupConfig:
return SignupConfig(enabled=True, require_email_verification=False, require_admin_approval=False)
configure_user_repository(SqlUserRepository())
configure_settings_provider(StaticSettingsProvider())Notifications (password-reset / sign-up emails, admin pings) are optional — JAFAAL
emits events to an AuthEventSink; install one with jafaal.configure_event_sink(...)
to deliver them, or skip it and those flows simply mint tokens without sending mail.
import contextlib
from fastapi import FastAPI
import jafaal
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
# Sweeps consumed OAuth states, rotated refresh tokens and expired
# reset/sign-up tokens. Without it those tables grow without bound.
# Already have a scheduler? Call jafaal.maintenance.run_due_tasks() from it.
jafaal.maintenance.start_background_scheduler()
yield
# Closes the pooled OIDC HTTP client, stops the sweeper, drains events.
await jafaal.shutdown()
app = FastAPI(lifespan=lifespan)
# Registers the JafaalError→HTTP handler and aggregates every sub-router.
app.include_router(jafaal.create_auth_router(app=app), prefix="/api/v1")That's it — you now have /api/v1/auth/login, /refresh, /logout, session
management, MFA, API keys, SSO, sign-up and password-reset endpoints.
Every JAFAAL function that takes a Session participates in your
transaction and never commits — the CRUD layer only flushes. JAFAAL's own
endpoints commit exactly once per request; when you drive JAFAAL's services,
you decide the boundary:
with jafaal.unit_of_work(db):
user = repo.create_local_user("ada", "ada@example.com", db, is_active=True, is_verified=False)
identity_service.set_local_password_hash(user.id, hashed)
db.add(MyProfile(user_id=user.id))
# one commit — any failure rolls back all threefrom jafaal import DEFAULT_SCOPE_CATALOG, configure_scopes, configure_api_key_scopes
# Layer your application scopes on top of JAFAAL's auth/identity scopes:
configure_scopes(
DEFAULT_SCOPE_CATALOG.extend(
regular=("reports:read",),
admin=("reports:read", "reports:write"),
descriptions={"reports:read": "Read reports", "reports:write": "Manage reports"},
)
)
# Opt each scope an API key may carry in explicitly (empty by default):
configure_api_key_scopes(["reports:read"])
# Native apps use the standard RFC 6749 authorization-code flow with PKCE.
# Register each one so redirect URIs can be matched exactly (RFC 9700 §4.1):
# jafaal.AuthSettings(..., oauth_clients=(
# jafaal.OAuthClient(client_id="com.example.app",
# redirect_uris=("com.example.app:/oauth/callback",)),
# ))
# Richer authorisation than the built-in is_superuser two tiers? Implement the
# ScopeResolver port and JAFAAL stamps whatever you return into its tokens:
# jafaal.configure_scope_resolver(MyRoleBasedResolver())
# jafaal.configure_rate_limiter(...) # inject a real limiter (e.g. slowapi)
# jafaal.configure_state_store(...) # inject Redis for multi-worker lockout stateBy default JAFAAL runs in a single process with an in-memory state store and no
rate limiting. For multi-worker/replica deployments, inject a distributed
StateStore and a RateLimiter.
examples/ has a complete, runnable app — user model, ports and
router in one file — plus the two client-side walkthroughs it drives:
cd examples/minimal_app
uv run --with 'jafaal[all]' --with uvicorn uvicorn app:app --reload- Web client walkthrough — cookie refresh, CSRF, page reload, MFA
- Mobile client walkthrough — the authorization-code flow with PKCE
Full documentation lives at jafaal.endurain.com. The client integration reference is the HTTP contract your front end codes against.
A huge thank you to the project sponsors! Your support helps keep this project going.
Support Endurain's development on:
Contributions are welcomed! Please open an issue to discuss any changes or improvements before submitting a PR. Check out the Contributing Guidelines for more details.
This project is licensed under the MIT License - see the LICENSE file for details.