Just Another FastAPI Authentication Library (JAFAAL)
What is JAFAAL?
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.
What JAFAAL is (and is not)
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_clientsand drive/auth/authorize→/auth/tokenwith any standard OAuth client library — PKCE mandatory,codethe 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/loginauthenticates 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.
Current limitations
- 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.
Installation
pip install jafaal
# or
uv add jafaal
Requires Python 3.12+.
Optional features
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]' # everything
If a feature is used without its extra installed, JAFAAL fails fast with a clear
install hint (a MissingDependencyError) rather than an obscure error.
Verifying a release
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.whl
A 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.
Quickstart
1. Configure the library
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),
)
)
2. Own your Base, map JAFAAL's tables in, and register a session factory
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 migrations
The reverse relationships (users_sessions, local_credential, auth_mfa, …)
and the mfa_enabled property are supplied by the mixin — you declare none of them.
3. Implement the ports
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.
4. Mount the router, and run maintenance
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.
5. Transactions: you own the unit of work
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 three
6. Optional configuration
from 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 state
By 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
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
Documentation
Full documentation lives at jafaal.endurain.com. The client integration reference is the HTTP contract your front end codes against.
Sponsors
A huge thank you to the project sponsors! Your support helps keep this project going.
Support Endurain's development on:
Contributing
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.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Metadata
Release files for jafaal 0.2.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 | Provenance | Actions |
|---|---|---|---|---|
| jafaal-0.2.0.tar.gz | 354.2 kB | Without provenance | Details for jafaal-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| jafaal-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for jafaal-0.2.0-py3-none-any.whl |
Total release size: 776.7 kB
Release files / jafaal-0.2.0.tar.gz
| Download URL | jafaal-0.2.0.tar.gz |
|---|---|
| Size | 354.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c8fc5aa0ee76def869c8d6e91bf6875319bbdf3ba4edc360919b79aad62a9403
|
|
BLAKE2b-256 checksum How to use checksums |
6c0c332291a2e702df7872b0f0fbb8b79e54d83e6b47ba4e0ea6ceacafa70516
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / jafaal-0.2.0-py3-none-any.whl
| Download URL | jafaal-0.2.0-py3-none-any.whl |
|---|---|
| Size | 422.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
304b424612582b7a6a15f4a0aa3fe2786b3d29ac7f43d0ca097f3f9f6fc8151f
|
|
BLAKE2b-256 checksum How to use checksums |
5aeb92fce55365990380b84029cbd21418c7f10f8def93794285a07eac19760f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for jafaal 0.2.0
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.