Ports & Adapters¶
JAFAAL owns the security-critical core (tokens, sessions, credentials, MFA, scope checks). The concerns that are inherently the application's are provided by the host through a small set of ports (protocols). The library depends only on these interfaces — never on a specific application — so you can swap any implementation.
The ports you implement¶
Install each adapter once at startup; every component reads it through the
matching get_* accessor.
UserRepository¶
Host-owned persistence for the user table. Methods run inside the caller's
transaction and take the active SQLAlchemy Session.
from jafaal import UserProtocol, configure_user_repository
class SqlUserRepository:
def get_by_id(self, user_id, db) -> UserProtocol | None: ...
def get_by_email(self, email, db) -> UserProtocol | None: ...
def get_by_username(self, username, db) -> UserProtocol | None: ...
def create_local_user(self, username, email, db, *, is_active, is_verified) -> UserProtocol: ...
def provision_from_idp(self, identity, db) -> UserProtocol: ... # SSO auto-provisioning
def sync_from_idp(self, user_id, claims, db) -> None: ... # optional profile sync
def set_email_verified(self, user_id, db, *, activate) -> None: ...
configure_user_repository(SqlUserRepository())
JAFAAL never reads app-specific profile fields — only id, username, email,
is_active, is_superuser, is_verified, and the mfa_enabled property (see
UserProtocol).
SettingsProvider¶
Host-owned dynamic settings: the password policy and the sign-up toggles.
from jafaal import PasswordPolicy, SignupConfig, configure_settings_provider
class MySettings:
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_settings_provider(MySettings())
AuthEventSink¶
JAFAAL performs the security-critical work (mint/hash/store token, single-use + expiry, enumeration-safe response) and emits an event; the host delivers it (email, SMS, websocket, queue, or just a log). This keeps email templates and i18n entirely out of the auth core.
The emitted events are PasswordResetRequested,
EmailVerificationRequested,
SignupPendingAdminApproval and
SignupApproved. Each reset/verification event carries
the plaintext token for you to build and send the link.
from jafaal import configure_event_sink
class EmailEventSink:
async def on_password_reset_requested(self, event) -> None:
send_email(event.email, reset_link(event.token)) # your delivery
async def on_email_verification_requested(self, event) -> None: ...
async def on_signup_pending_admin_approval(self, event) -> None: ...
async def on_signup_approved(self, event) -> None: ...
configure_event_sink(EmailEventSink())
Delivery is best-effort: for the enumeration-safe reset/verify flows, failures
are swallowed and logged so they can never change the HTTP response or leak
whether an account exists. If you skip these flows, the default
NullAuthEventSink is a no-op.
PasswordBreachChecker¶
Optionally screen a proposed password against a breach corpus / blocklist during
sign-up and password change (NIST SP 800-63B, the recommended companion to a
length_only policy). The port is one method that returns whether the password
should be rejected:
from jafaal import configure_password_breach_checker
class MyChecker:
def is_breached(self, password: str) -> bool:
return password in my_local_blocklist
configure_password_breach_checker(MyChecker())
It is consulted after the length/complexity policy passes and before
hashing, runs synchronously in the request path (keep it fast), and should fail
open (return False on an upstream error). It checks the password alone — not
a username/email pair. The default
NullPasswordBreachChecker disables
screening; ready-made adapters are below.
Batteries-included adapters¶
Ready-made implementations live in jafaal.adapters. They are not imported
by import jafaal (so the core never pulls their optional dependencies); import
them explicitly. The core depends only on the ports, so any adapter is swappable.
SqlAlchemyUserRepository¶
A generic UserRepository over the host's user model (mapped via
jafaal.map_models). The user class is auto-resolved from the registry (or pass
it explicitly).
from jafaal import configure_user_repository
from jafaal.adapters import SqlAlchemyUserRepository
configure_user_repository(SqlAlchemyUserRepository())
Subclass and override create_local_user / provision_from_idp if your table
has extra NOT NULL columns without defaults, or sync_from_idp (a no-op by
default) to map refreshed IdP claims onto your profile columns.
StaticSettingsProvider¶
A SettingsProvider backed by in-code constants — the simple, non-DB
password-policy / sign-up-config mode.
import jafaal
from jafaal.adapters import StaticSettingsProvider
jafaal.configure_settings_provider(
StaticSettingsProvider(
password_policy=jafaal.PasswordPolicy(min_length_regular=16, min_length_admin=24, password_type="length_only"),
signup_config=jafaal.SignupConfig(enabled=True, require_email_verification=True, require_admin_approval=False),
)
)
LoggingAuthEventSink / CompositeAuthEventSink¶
Reference AuthEventSink implementations: log events (the plaintext token is
always redacted), or fan one event out to several sinks (e.g. log and
email), isolating failures.
import jafaal
from jafaal.adapters import CompositeAuthEventSink, LoggingAuthEventSink
jafaal.configure_event_sink(CompositeAuthEventSink([LoggingAuthEventSink(), EmailEventSink()]))
RedisStateStore¶
A distributed StateStore that shares progressive-lockout
counters and TOTP-replay markers across workers/replicas. Requires the
jafaal[redis] extra.
import jafaal
from jafaal.adapters import RedisStateStore
jafaal.configure_state_store(RedisStateStore(url="redis://localhost:6379/0"))
The client must return bytes (leave decode_responses at its default of
False). The tiered-lockout increment is atomic (a WATCH/MULTI transaction),
so its correctness does not depend on how many workers hit it concurrently.
StateStoreRateLimiter¶
A batteries-included RateLimiter that enforces a
fixed-window, per-client-IP request budget using the configured
StateStore. It needs no extra dependency and becomes
distributed automatically once you configure
RedisStateStore — lockout, TOTP-replay, and rate-limit
counters then share one backend.
import jafaal
from jafaal.adapters import StateStoreRateLimiter
jafaal.configure_rate_limiter(StateStoreRateLimiter())
# ...or: create_auth_router(rate_limiter=StateStoreRateLimiter()).
Budgets come from settings (sensitive / write, e.g.
"10/minute"), and the client IP is resolved through the proxy-aware
trusted_proxies logic, so set that correctly behind a reverse proxy. Rate
limiting is defense-in-depth, so the limiter fails open (does not block) when
the client IP is unknown, the budget is malformed, or the state store is
unavailable — an infrastructure fault must never take down authentication.
HibpBreachChecker / BlocklistBreachChecker¶
Reference PasswordBreachChecker implementations for breached-password
screening.
HibpBreachChecker queries the Have I Been Pwned "Pwned Passwords" range API.
That endpoint is free and unauthenticated (no API key) and k-anonymous: the
password is SHA-1 hashed locally and only the first five hex characters of the
digest are sent, so the password (and full hash) never leave the process.
httpx is already a JAFAAL dependency, so no extra install is needed.
import jafaal
from jafaal.adapters import HibpBreachChecker
jafaal.configure_password_breach_checker(HibpBreachChecker())
It fails open (allows the password) on any network/HTTP error so a
breach-service outage never blocks password changes. Raise min_count to only
reject widely-seen passwords, and pass client= to reuse one httpx.Client.
BlocklistBreachChecker is a dependency-free, in-memory alternative for a
bundled "top-N breached passwords" list or a custom deny-list:
from jafaal.adapters import BlocklistBreachChecker
jafaal.configure_password_breach_checker(BlocklistBreachChecker(load_top_passwords()))
Password-only, by design
Both check the password alone, not a username/email + password pair. Pair / credential-stuffing checks require a commercial service and send more sensitive data to a third party; JAFAAL's server-side progressive lockout already mitigates credential stuffing.
Per-account and per-IP progressive lockout still apply.