Configuration¶
JAFAAL never reads environment variables or secret files itself. The host application builds configuration and injects it once at startup. Every setting is data you own.
One configuration per process
jafaal.configure() and the configure_* adapter functions install
process-wide module registries. Every JAFAAL router in the process shares
those settings, ports, stores, scope catalog, session factory, and model
mapping. v0.2 cannot isolate two differently configured JAFAAL deployments
in one process. Configure each worker at startup and use the documented
distributed stores when state must span workers or replicas.
AuthSettings¶
Build a frozen, validated AuthSettings and install it
with jafaal.configure. Misconfiguration fails fast at
construction (e.g. a short signing key or an invalid Fernet key is rejected).
import jafaal
from cryptography.fernet import Fernet
jafaal.configure(
jafaal.AuthSettings(
secrets=jafaal.Secrets(
secret_key="<32+ byte JWT signing secret>", # HS256 signing key
fernet_key=Fernet.generate_key().decode(), # at-rest token encryption
),
base_url="https://app.example.com",
app_name="Example", # MFA issuer shown in authenticators
environment="production", # drives the cookie Secure flag
)
)
Configuration is grouped by concern rather than being one flat list of
options, so you read (and document) only the parts you use. Every group has
working defaults. secrets is always required; deployed environments also
require an absolute HTTPS base_url.
| Group | Covers |
|---|---|
secrets |
Signing/encryption keys and their rotation fallbacks |
tokens |
Algorithm, lifetimes, iss/aud/client_id, skew, revocation toggles |
sessions |
Idle/absolute timeout, refresh-cookie name/path/prefix, trusted origins |
passwords |
Argon2 cost, maximum accepted length |
mfa |
TOTP replay policy |
webauthn |
Passkey RP identity and ceremony policy |
sso |
Redirects, IdP transport policy, step-up |
network |
Trusted proxies, SSRF allow-list, outbound user agent |
rate_limits |
Request budgets consumed by your RateLimiter |
api_keys |
Key prefix, query-string transport toggle |
audit |
PII policy for the jafaal.audit stream |
jafaal.configure(
jafaal.AuthSettings(
secrets=jafaal.Secrets(secret_key=..., fernet_key=...),
base_url="https://app.example.com",
tokens=jafaal.TokenSettings(access_token_expire_minutes=10, leeway_seconds=30),
sessions=jafaal.SessionSettings(idle_timeout_enabled=True, refresh_cookie_prefix="__Secure-"),
network=jafaal.NetworkSettings(trusted_proxies=("10.0.0.0/8",)),
)
)
Registered clients¶
v0.2 supports only trusted, statically configured, first-party public clients owned by the same host as the users and authorization server. There is no dynamic registration, confidential-client authentication, or consent/grant store. Every request that issues a token names the client it is for. The registration, not the request, decides how the tokens are delivered and how wide they may be, because a value a caller can choose per request is a value an attacker can choose.
jafaal.configure(
jafaal.AuthSettings(
secrets=jafaal.Secrets(secret_key=..., fernet_key=...),
base_url="https://app.example.com",
oauth_clients=(
# The first-party web frontend: refresh token as an HttpOnly cookie.
jafaal.OAuthClient(
client_id="web",
token_delivery="cookie",
name="Example web app",
),
# A native app: the literal RFC 6749 §5.1 response.
jafaal.OAuthClient(
client_id="com.example.app",
redirect_uris=("com.example.app:/oauth/callback",),
name="Example for iOS",
),
# A first-party desktop app, capped at what it actually needs.
jafaal.OAuthClient(
client_id="com.example.desktop",
redirect_uris=("com.example.desktop:/oauth/callback",),
scopes=("profile",),
name="Example desktop app",
),
),
)
)
| Field | Effect |
|---|---|
client_id |
The identifier the client sends. Any stable opaque string. |
redirect_uris |
Where authorization codes may be delivered. Exact byte matching except that an IP-literal loopback request may vary only its port. Not needed for direct login/refresh only. |
token_delivery |
"body" (RFC 6749 §5.1, the default) or "cookie" (RFC 9700 §7.2 — HttpOnly refresh cookie plus a CSRF token). |
scopes |
Ceiling on the token's scopes, intersected with what your ScopeResolver grants the user. Empty means "whatever the user holds". |
Set a ceiling for each first-party client
JAFAAL has no consent screen, so without scopes a registered client can
receive every scope the user holds. Give each host-owned client only the
scopes it needs. Do not register a client you do not control; that trust
model is outside the v0.2 product boundary.
Allowed redirect registrations are deliberately narrow:
- HTTPS URIs include a host and match byte for byte, including path, query, and case.
- Native desktop loopback uses plain HTTP with the IP literal
127.0.0.1or[::1]. The request may vary only the port from the registered URI. The path and query still match exactly.localhostis rejected because RFC 8252 §8.3 recommends IP literals to avoid name-resolution and unintended-interface ambiguity. - Private-use schemes use reverse-domain syntax with no authority and one slash,
for example
com.example.app:/oauth/callback. Generic schemes such asmyapp:and authority forms such ascom.example.app://callbackare rejected.
Relative, opaque, credential-bearing, fragmented, and non-loopback HTTP URIs are rejected when settings are constructed.
Native apps: the authorization-code flow¶
A native app authenticates through the standard RFC 6749 §4.1 authorization-code
flow with PKCE, which every OAuth client library already speaks — AppAuth-iOS,
AppAuth-Android, openid-client, MSAL.
Registration is not bureaucracy — it is what makes exact redirect_uri
matching possible, which RFC 9700 §4.1 requires and which is the control that
stops an authorization code being steered to an attacker's target. Matching is
byte-for-byte with no prefixes, wildcards, or sub-paths; only an IP-literal
loopback redirect may vary its port. Clients are public (RFC 8252): a native
app cannot keep a secret, so PKCE — not client authentication — binds the code
to the requester.
The flow:
GET /auth/authorize?response_type=code
&client_id=com.example.app
&redirect_uri=com.example.app:/oauth/callback
&code_challenge=<S256>&code_challenge_method=S256
&state=<opaque>&idp=<provider-slug>
→ 302 to the identity provider
→ (IdP returns to /public/idp/callback/<slug>)
→ 302 to com.example.app:/oauth/callback?code=…&state=…
POST /auth/token
grant_type=authorization_code&code=…&code_verifier=…
&redirect_uri=com.example.app:/oauth/callback&client_id=com.example.app
→ {"access_token": …, "refresh_token": …, "token_type": "Bearer", "expires_in": …, "scope": …}
Four bindings must all hold for a code to be redeemed, and each closes a
published attack: PKCE, client_id, exact redirect_uri, and single use. Only
the digest of a code is stored, so database read access alone does not yield a
redeemable one. Every redemption failure answers the same invalid_grant, so the
endpoint cannot be used to probe which codes or clients exist.
Errors follow RFC 6749: §5.2 {"error", "error_description"} on /auth/token,
and §4.1.2.1 on /auth/authorize — an unregistered client_id or redirect_uri
is rendered (there is no verified target to redirect to), and every later
failure is delivered to the redirect URI with error and state, so an app
waiting on its callback listener learns what happened instead of timing out.
Missing, empty, malformed, or repeated OAuth parameters are invalid_request;
these endpoints never return FastAPI's 422 validation body. A repeated
client_id or redirect_uri is never selected as a redirect target, and a
repeated state is not echoed.
/auth/token serves both grants — authorization_code and refresh_token — so
a standard client needs exactly one token URL. /auth/refresh remains as an
alias that also accepts the refresh token from the HttpOnly cookie or an
Authorization header.
Local login at the authorization endpoint
/auth/authorize authenticates either through an identity provider
(idp=<slug>) or with JAFAAL's own credentials (omit idp).
JAFAAL ships no login UI, so the local path hands the browser to yours. Set
login_ui_url and the flow is:
- the client opens
/auth/authorize?...with noidp; - JAFAAL parks the validated request and redirects to
<login_ui_url>?auth_request=<handle>; - your page collects the credentials and posts them to
/auth/loginwith thatauth_request; - JAFAAL answers
{"redirect_to": "<client redirect_uri>?code=…&state=…&iss=…"}and your page navigates there; - the client redeems the code at
/auth/tokenwith itscode_verifier.
MFA and passkey second factors work unchanged — the handle rides on the pending-login ticket, so whichever factor finishes the login produces the authorization response.
Nothing about the grant is re-read from step 3: the client, redirect URI, PKCE challenge and scope all come from the request parked at step 2, so a compromised login page cannot widen or redirect a grant. No token ever passes through the page or the address bar.
A native app should prefer this over /auth/login. It keeps the
password in a browser the app does not control, which is what RFC 8252 §8.1
asks for. /auth/login without an auth_request remains available for a
first-party app that owns its own login form; it is not an OAuth grant and
is never advertised in discovery.
Everything is advertised in the RFC 8414 discovery document at the issuer-derived path described below, so a client can be configured from a single issuer URL.
Every component reads the installed settings through
jafaal.get_settings. Re-calling configure() replaces
the settings and invalidates settings-derived caches (e.g. the token manager).
Selected fields¶
| Field | Default | Purpose |
|---|---|---|
secret_key |
— (required) | HMAC key that signs/verifies JWTs (≥ 32 chars). |
fernet_key |
— (required) | Fernet key encrypting at-rest tokens (IdP secrets, MFA secret, rotated refresh tokens). |
secret_key_fallbacks |
() |
Extra HMAC keys accepted only when verifying JWTs (rotation overlap). |
fernet_key_fallbacks |
() |
Extra Fernet keys accepted only when decrypting at-rest tokens (rotation overlap). |
access_token_expire_minutes |
15 |
Access-token lifetime. |
refresh_token_expire_days |
7 |
Refresh-token lifetime. The default for runtime policy. |
max_access_token_minutes / max_refresh_token_days |
None |
Optional ceilings on lifetimes a SettingsProvider supplies at runtime (None = no cap). Never below the matching default. |
leeway_seconds |
0 |
Clock-skew tolerance (seconds) for JWT exp/nbf; 0 is strict, keep any value small. |
algorithm |
"HS256" |
JWT signing algorithm: HS256 (symmetric) or an asymmetric RSA/EC algorithm (see below). |
client_id |
"" |
Value of the RFC 9068 client_id claim; defaults to the resolved audience. |
private_key |
"" |
PEM private key for asymmetric signing (required when algorithm is asymmetric). |
private_key_fallbacks |
() |
Verify-only PEM keys kept in the published JWKS during a signing-key rotation. |
idle_timeout_hours |
1 |
Idle-session timeout (opt-in via idle_timeout_enabled). |
absolute_timeout_hours |
720 |
Hard ceiling on session lifetime, measured from creation. Always enforced, and expires_at is capped at it on every rotation, so refreshing cannot extend a login indefinitely. |
max_absolute_timeout_hours |
None |
Optional ceiling on the absolute timeout a SettingsProvider supplies at runtime (None = no cap). Never below absolute_timeout_hours. |
base_url |
"" |
Public base URL; default JWT issuer/audience and SSO redirect base. Required as absolute HTTPS in deployed environments. Local HTTP is limited to 127.0.0.1 or [::1]. |
issuer |
"" |
Public authorization-server URL and JWT iss; defaults to base_url. Its path determines the RFC 8414 metadata path. |
login_ui_url |
"" |
Absolute URL of your login page. Required to offer local login at /auth/authorize; JAFAAL redirects there with an auth_request handle. Empty means idp is mandatory. |
csrf_trusted_origins |
() |
Origins allowed to drive the web refresh flow; defaults to the base_url origin. Set this when the frontend is served from a different origin than the API. |
environment |
"production" |
One of production, demo, staging, development, local, test, testing. The first three are treated as deployed; anything else is rejected at construction. |
refresh_cookie_prefix |
"" |
Optional __Secure-/__Host- refresh-cookie name prefix, applied only when deployed (__Host- requires refresh_cookie_path="/"). |
allow_query_param |
False |
Whether API keys may be sent via ?api_key= (header only by default). |
allow_in_memory_state_store_when_deployed |
False |
Permit the in-memory state store in a deployed environment (single-worker only; otherwise create_auth_router() raises at startup). |
allow_no_rate_limit_when_deployed |
False |
Permit a deployed environment with no enforcing rate limiter (otherwise create_auth_router()/verify_configuration() raise at startup). |
allow_no_password_breach_check_when_deployed |
False |
Permit deployed length_only policy without a breach checker; explicitly accepts deviation from NIST's blocklist requirement. |
denylist_enabled |
False |
Record & check revoked access-token jtis so /revoke kills an access token immediately (one state-store lookup per request). |
reauthorize_scopes_per_request |
False |
Intersect an access token's scopes with the tier its account currently holds, so a demotion applies immediately instead of at token expiry. Strictly narrowing; adds no query. |
argon2_time_cost |
3 |
Argon2 time cost (iterations) for password hashing. |
argon2_memory_cost |
65536 |
Argon2 memory cost, in KiB. |
argon2_parallelism |
4 |
Argon2 parallelism (lanes). |
verify_only_hashers |
() |
pwdlib hasher instances that may verify a stored hash but never create one; see below. |
verify_unnormalized_input |
False |
Retry a failed verification once with the raw, un-normalized input. |
max_length |
128 |
Maximum accepted password length (minimum 64), enforced before hashing. Cannot exceed jafaal.settings.PASSWORD_FIELD_MAX_LENGTH (1024), the shared bound every password request field carries. |
totp_replay_fail_open |
False |
On a state-store outage, accept a TOTP code without replay protection instead of failing closed (503). |
rp_id |
"" |
WebAuthn Relying Party ID (registrable domain, no scheme/port); defaults to the base_url host. |
rp_name |
"" |
Human-readable RP name shown by the authenticator; defaults to app_name. |
origins |
() |
Exact origins (scheme+host+port) a passkey ceremony may complete from; defaults to the base_url origin. |
user_verification |
"preferred" |
User-verification requirement (required/preferred/discouraged). |
attestation |
"none" |
Attestation conveyance requested at registration (none/direct). |
second_factor_enabled |
False |
Require a registered passkey as a second factor after password login. |
passkey_login_satisfies_mfa |
True |
Whether a passwordless passkey login completes on its own for an account that also has TOTP enrolled. The ceremony always forces user verification, so it is possession and a PIN/biometric. Set False if your policy names TOTP specifically. |
challenge_ttl_seconds |
300 |
Lifetime of a WebAuthn challenge held in the state store. |
sensitive |
"10/minute" |
Budget hint for sensitive endpoints. |
write |
"30/minute" |
Budget hint for write endpoints. |
polling |
"30/minute" |
Budget hint for read-only status polling, including sign-up confirmation. |
trusted_proxies |
() |
Peers and forwarding hops whose X-Forwarded-For/X-Real-IP are honoured (empty = trust only the direct peer). |
ssrf_allowed_hosts |
() |
Hosts/CIDRs exempted from the SSRF private-address guard. |
idp_link_browser_binding |
True |
Bind an identity-provider link to the browser that started it (an HttpOnly, SameSite=Lax cookie the callback requires), so an attacker cannot get a victim to finish a link that attaches the victim's provider identity to the attacker's account. A native app that opens a separate system browser has no shared cookie jar and needs this False, accepting that attack for every client. |
idp_require_https |
True |
Require https for identity-provider endpoints (authorization, token, userinfo, JWKS, discovery, revocation); set False to allow http:// for local or self-hosted development. |
include_pii |
True |
Include direct identifiers (username/IP/email) in jafaal.audit records; set False for PII-minimal retention. |
Behind a proxy
trusted_proxies defaults to () — only the direct TCP peer is trusted, so
X-Forwarded-For/X-Real-IP from arbitrary clients are ignored (a client
cannot spoof the IP that keys the progressive-lockout counters). When running
behind a reverse proxy, set it to your proxy addresses/CIDRs so the real
client IP is used; ("*",) trusts every peer (only safe when a trusted proxy
always overwrites the header).
List every hop, not just the direct peer. The forwarded chain is resolved
right to left, returning the first address that is not listed in
trusted_proxies. That is deliberate: a proxy configured the usual way
(nginx's proxy_add_x_forwarded_for) appends the address it observed to
whatever the client sent, so a request carrying
X-Forwarded-For: 1.2.3.4 arrives as 1.2.3.4, <real client> — the leftmost
element is entirely attacker-controlled. With a CDN in front of a reverse
proxy, list both the CDN egress ranges and the proxy, or resolution stops one
hop short at the CDN edge address.
environment is validated
environment must be one of the values in the table above. It is not a free
-form label: is_deployed gates the refresh cookie's Secure flag, the
__Secure-/__Host- cookie prefix, and the two fail-closed startup guards,
so a typo such as "prod" would silently switch all four off. An
unrecognised value raises at AuthSettings(...) construction instead. The
default is the safe one ("production"), so forgetting to set it cannot
weaken a deployment.
Split-origin frontends
/auth/refresh rejects any request a browser marks as off-site, using the
unforgeable Origin / Sec-Fetch-Site headers. If your frontend and API are
served from different origins (for example https://app.example.com calling
https://api.example.com), list the frontend origin in
csrf_trusted_origins or every refresh will be rejected with a 403.
Migrating from another hash scheme¶
JAFAAL hashes with Argon2id only. A host adopting it with hashes from another
scheme (say bcrypt, from pwdlib[bcrypt]) lists the hashers that may verify:
from pwdlib.hashers.bcrypt import BcryptHasher # pip install 'jafaal[bcrypt]'
jafaal.PasswordSettings(verify_only_hashers=(BcryptHasher(),))
You supply the instances, so JAFAAL imports no bcrypt code. Argon2id stays first,
so it is the only scheme that produces a hash. On a successful verification
against another scheme the hash is re-created as Argon2id and stored in the same
transaction as the login. That is not a password change, so no PasswordChanged
event is emitted.
- Remove them when you are done. Startup logs a
verify_only_hashers_configuredwarning while any are configured.jafaal.maintenance.count_non_argon2_password_hashes(db)returns how many credentials still hold a hash that is not Argon2id; it reaches0once every active user has signed in once. Accounts that never sign in keep the old hash until you drop the hasher and have them reset. - bcrypt truncates at 72 bytes. That affects only the verification of old hashes: a password sharing its first 72 bytes with the real one verifies against such a hash. (bcrypt 5 refuses a longer input outright, where the systems that made these hashes truncated silently; JAFAAL verifies the first 72 bytes instead, rather than fail the login.) It never affects new hashes: a match is re-hashed from the whole password as Argon2id.
- Timing. With verify-only schemes configured, a failed check runs the same number of verifications against every scheme (the one holding the hash for real, the others against dummy hashes), and so does the unknown-user dummy verify. Account existence and which scheme an account still uses are not readable from latency, at the price of roughly the sum of the schemes' costs per failed login while migrating.
- Normalization. JAFAAL NFC-normalizes a password before verifying it. A hash
made by a system that did not will fail for a non-ASCII password typed in
another form.
verify_unnormalized_input=Trueretries a failed check once with the raw input and, on success, re-hashes from the NFC form. Its failure path always costs two verifications (the second runs even when the raw input equals its NFC form), and the unknown-user dummy verify costs two as well, so timing does not distinguish the cases. Enable it only if you need it.
Runtime policy¶
AuthSettings is deployment configuration, set once: keys, algorithm,
issuer, OAuth clients, cookie names and paths, trusted proxies, Argon2 cost. The
token-lifetime, session-timeout and login-method fields in it are the
defaults and hard ceilings for policy an operator may change while the
process runs.
That runtime policy is read from the SettingsProvider,
per call, through three optional methods:
| Provider method | Returns | Read by |
|---|---|---|
get_token_lifetimes() |
TokenLifetimes(access_token_minutes, refresh_token_days) |
token issue, refresh-cookie expiry, session expiry on rotation |
get_session_policy() |
SessionPolicy(idle_timeout_enabled, idle_timeout_hours, absolute_timeout_hours) |
session validation, the idle-session sweep |
get_login_methods() |
LoginMethods(password, identity_providers, passkeys) |
the login routes below |
A provider that implements none of them keeps working: JAFAAL derives each value
from AuthSettings, as it does when no provider is installed, or when you use
StaticSettingsProvider without
overrides. JAFAAL reads them only through resolve_token_lifetimes(),
resolve_session_policy() and resolve_login_methods().
Hot path
The methods run on the request path, and JAFAAL does not cache. If your
source is slow, cache it in the provider. They take no Session.
Log volume. The resolvers run several times per request, so the clamp
warning and a failing provider's error are each logged at most once a minute;
every call still clamps or refuses. Each login reads get_token_lifetimes() about
twice (tokens, session expiry).
Ceilings. A provider value above max_access_token_minutes,
max_refresh_token_days or max_absolute_timeout_hours is clamped to it and
logged on jafaal.ports with warning_code token_lifetime_clamped or
session_timeout_clamped. The ceilings default to None (no cap), because a
default would silently shorten a 0.3 deployment already configured above it;
set them to bound what an operator can configure. Ceilings apply to provider
values only, never to the AuthSettings defaults. A non-positive value is an
error (the dataclasses validate on construction), not a value to replace.
Fail closed. If a provider method raises, or returns the wrong type, JAFAAL
raises SettingsUnavailableError (HTTP 503, code settings_unavailable)
and does not fall back to defaults: a broken settings store must not
re-enable a disabled login method or lengthen a lifetime. The idle-session sweep
skips that run instead.
What a change affects.
- New tokens, and the refresh cookie, use the new lifetimes at once. Tokens
already issued keep the
expthey were minted with. - Existing sessions are judged against the policy in force at the next check, so a reduced absolute or idle timeout applies on the session's next refresh.
- The session-wide access-token denylist entry written on revoking a refresh
token lives for the longest of the current lifetime, the
AuthSettingsdefault andmax_access_token_minutes, so shortening the lifetime does not let it lapse before a token issued earlier. To guarantee that for tokens issued under a longer runtime value, set the ceiling. A revoked access token is denied for its own remainingexp.
Login methods¶
LoginMethods is enforced by JAFAAL, not only a UI hint. Each refusal is a
LoginMethodDisabledError (HTTP 403, code login_method_disabled) raised
before any credential is checked, so it is identical for a real and an unknown
account and records no failed attempt.
| Flag | Refused when False |
Never blocked |
|---|---|---|
password |
POST /auth/login (also the password step of the /auth/authorize handoff), and the second step of a password login: /auth/mfa/verify and /auth/webauthn/mfa/complete, so a ticket minted before the flag was switched off cannot finish a login |
|
identity_providers |
/auth/authorize with idp (a redirect with error=access_denied), the login callback under /public/idp/callback/*; GET /public/idp returns [] |
linking a provider to an existing account, IdP step-up re-authentication |
passkeys |
/public/webauthn/authenticate/* |
passkeys as a second factor, passkey credential management |
Startup diagnostics¶
create_auth_router() logs soft configuration problems through
jafaal.factory. Each record carries a stable warning_code for selective
filtering:
warning_code |
Condition |
|---|---|
rate_limiter_unconfigured |
The no-op rate limiter is active outside a deployed environment. |
trusted_proxies_wildcard |
Every proxy is trusted in a deployed environment. |
password_breach_checker_unconfigured |
The no-op breached-password checker is active. |
symmetric_oauth_signing |
OAuth clients are registered while tokens use symmetric signing. |
login_token_url_prefix_mismatch |
login_token_url does not match the auth router prefix. |
refresh_cookie_path_prefix_mismatch |
The refresh-cookie path does not match the auth router prefix. |
For example, a deployment where JAFAAL is intentionally the only token verifier can suppress the symmetric-signing warning without hiding other logs:
import logging
class SuppressSymmetricOAuthSigning(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return getattr(record, "warning_code", None) != "symmetric_oauth_signing"
logging.getLogger("jafaal.factory").addFilter(SuppressSymmetricOAuthSigning())
Deployed configurations that violate a fail-closed guard raise RuntimeError;
logging filters do not bypass those guards.
JWT wire format¶
JAFAAL publishes a JWKS so third-party resource servers can verify its access tokens statelessly. The tokens follow RFC 9068, JWT Profile for OAuth 2.0 Access Tokens, so a stock JWT library reads them without any JAFAAL-specific parsing:
| Claim | Form |
|---|---|
scope |
"profile users:read" — space-delimited string (RFC 6749 §3.3) |
sub |
"42" — string (RFC 7519 §4.1.2 defines it as StringOrURI) |
client_id |
present (RFC 9068 §2.2); see the client_id setting |
sid |
session identifier (JAFAAL extension) |
token_use |
access / refresh (JAFAAL extension) |
The token's media type lives in the JOSE typ header — at+jwt for access
tokens, rt+jwt for refresh tokens — so a resource server can reject a token
minted for another purpose before parsing a single claim.
Key rotation¶
Both the JWT signing key and the Fernet encryption key rotate without downtime by keeping the previous key as a fallback for an overlap window. New material is always produced with the primary key; the fallbacks are verify-/decrypt-only.
jafaal.configure(
jafaal.AuthSettings(
secrets=jafaal.Secrets(
secret_key=NEW_SIGNING_KEY, # signs all new JWTs
secret_key_fallbacks=(OLD_SIGNING_KEY,), # still verifies tokens signed before rotation
fernet_key=NEW_FERNET_KEY, # encrypts all new at-rest secrets
fernet_key_fallbacks=(OLD_FERNET_KEY,), # still decrypts data written with the old key
),
base_url="https://app.example.com",
app_name="Example",
)
)
Once every token signed with — and every secret encrypted with — the old key has expired or been re-written, drop the fallback.
Asymmetric signing & JWKS¶
By default JAFAAL signs its access/refresh tokens with HS256 (a shared
secret), so any service that verifies them needs that secret. To let other
services verify tokens statelessly with a public key — no secret sprawl —
set an asymmetric algorithm and a PEM private_key:
import jafaal
from cryptography.fernet import Fernet
jafaal.configure(
jafaal.AuthSettings(
secrets=jafaal.Secrets(
# STILL required: keys the HMAC hashing of refresh/CSRF tokens.
secret_key="<32+ byte secret>",
fernet_key=Fernet.generate_key().decode(),
private_key=open("jwt-signing-key.pem").read(),
),
base_url="https://app.example.com",
# or ES256, PS256, RS384/512, ES384/512, PS384/512
tokens=jafaal.TokenSettings(algorithm="RS256"),
)
)
JAFAAL signs with the private key (tagging each token with the key's RFC 7638
thumbprint as kid) and publishes the public key(s) as a JSON Web Key Set:
GET <your-api-root>/.well-known/jwks.json
A resource server verifies a JAFAAL access token the same way it would any OIDC provider's — fetch the JWKS and check the signature and claims (no call back to JAFAAL):
import jwt # PyJWT, in the *resource server*
from jwt import PyJWKClient
jwks = PyJWKClient("https://app.example.com/api/v1/.well-known/jwks.json")
signing_key = jwks.get_signing_key_from_jwt(access_token)
claims = jwt.decode(
access_token,
signing_key.key,
algorithms=["RS256"],
issuer="https://app.example.com", # == base_url
audience="https://app.example.com", # == base_url
)
assert claims["typ"] == "access"
get_jwks() is also exported, so you can serve the set at the
conventional root path /.well-known/jwks.json from your own app instead of the
API-root path above.
Discovery (RFC 8414)¶
So a resource server does not have to hard-code any of the above, JAFAAL also publishes an RFC 8414 authorization server metadata document at the issuer-derived location. For an issuer without a path:
GET https://app.example.com/.well-known/oauth-authorization-server
For issuer="https://app.example.com/api/v1", RFC 8414 inserts the well-known
component before the issuer path:
GET https://app.example.com/.well-known/oauth-authorization-server/api/v1
The aggregate compatibility URL remains available at
<your-api-root>/.well-known/oauth-authorization-server.
{
"issuer": "https://app.example.com",
"jwks_uri": "https://app.example.com/api/v1/.well-known/jwks.json",
"authorization_endpoint": "https://app.example.com/api/v1/auth/authorize",
"token_endpoint": "https://app.example.com/api/v1/auth/token",
"introspection_endpoint": "https://app.example.com/api/v1/auth/introspect",
"revocation_endpoint": "https://app.example.com/api/v1/auth/revoke",
"grant_types_supported": ["authorization_code", "refresh_token"],
"response_types_supported": ["code"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["identity_providers:read", "profile", "users:read"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true
}
The endpoint URLs follow the actual mounted authorization route and your
RouterPrefixes automatically, and
scopes_supported reflects the installed scope catalog. Their origin is taken
from the configured issuer, so a forged Host header cannot make JAFAAL
advertise an attacker-controlled token_endpoint.
Reverse proxies and non-root mounts
Set tokens.issuer to the external authorization-server URL and configure
FastAPI/ASGI root_path when a trusted proxy exposes the application under
a path prefix. JAFAAL combines the configured issuer origin with the mounted
route paths; it does not trust forwarded host headers to choose an issuer.
RFC 8414 places /.well-known/oauth-authorization-server at the origin
root, before any issuer path. A proxy that exposes the whole ASGI app only
below a prefix must add an explicit route for that origin-root well-known
URL to JAFAAL's app-root metadata handler. It must sanitize inbound forwarded
headers as required by RFC 9700 §4.13.
Pass app=app to create_auth_router to install that handler. If you omit
app=, mount create_metadata_router(path=issuer_derived_metadata_path())
directly on the application. Rebuild the application after changing the
issuer path; settings reconfiguration cannot remove the old route.
authorization_response_iss_parameter_supported is
RFC 9207: every authorization response
carries iss, so a client configured against more than one authorization server
can prove which one answered and refuse a code steered in from another (the
mix-up attack). Advertising it is what lets a client require the check.
jwks_uri appears only when you sign asymmetrically
Under the default HS256 there is no public key, and the JWKS endpoint
answers 404. Advertising a URL whose document is {"keys": []} would tell
a verifier the issuer had rotated every key away — sending it into refresh
and retry logic — rather than that stateless verification was never on
offer. Set an asymmetric algorithm (with secrets.private_key) to publish
keys.
There are no extension members. Everything a client needs to drive JAFAAL is a standard field; a bespoke one would mean an endpoint a stock OAuth library cannot call.
What this document does not claim
JAFAAL is an authorization server for your own clients. It has no client
secrets, no consent screen, and no dynamic registration: clients are
registered in configuration (OAuthClient) and are
public (RFC 8252), with PKCE — not a client credential — binding a code to
its requester.
/auth/loginis deliberately not advertised. It authenticates an end user directly and is a first-party API, not an OAuth grant. Advertising it would invite a client to attempt the resource-owner password-credentials grant, which OAuth 2.1 removes and RFC 9700 §2.4 discourages.token_endpoint_auth_methods_supportedis["none"]— stating it matters, because RFC 8414 otherwise impliesclient_secret_basic.
Calling the token endpoint¶
/auth/token serves both grants with the standard RFC 6749 request shapes:
=== "Authorization code (RFC 6749 §4.1.3)"
```http
POST /api/v1/auth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=<code>&code_verifier=<verifier>
&redirect_uri=<exact registered uri>&client_id=<your client>
```
=== "Refresh (RFC 6749 §6)"
```http
POST /api/v1/auth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=<token>
```
/auth/refresh is an alias for the refresh grant that additionally accepts the
token from the HttpOnly cookie or an Authorization header, for a browser
client that never sees the token.
Delivery mode is not a request parameter. Whether the refresh token comes
back in the body (RFC 6749 §5.1) or as an HttpOnly cookie (RFC 9700 §7.2) is
fixed by the client's registered token_delivery, and on refresh it is read from
the token's own client_id claim. A caller cannot switch it, so there is no
header to get wrong and no mismatch to defend against.
Errors follow RFC 6749 §5.2 — {"error": "invalid_grant", "error_description":
"…"} with Cache-Control: no-store — which is what an OAuth client library
parses.
secret_key is always required
Even with asymmetric JWTs, secret_key still keys the HMAC hashing of
refresh tokens (reuse detection) and CSRF tokens, so it stays mandatory.
EdDSA is not offered yet
EdDSA is intentionally excluded: the underlying joserfc marks the
EdDSA JOSE identifier as deprecated (RFC 9864) and warns on use. Use
ES256 (compact) or RS256 (most widely interoperable) instead.
See Key rotation for rotating the signing key without downtime.
Token introspection & revocation¶
JAFAAL exposes RFC 7662 introspection and RFC 7009 revocation for its own access/refresh tokens, mounted on the auth router:
-
POST <api-root>/auth/introspect(RFC 7662) returns{"active": …}plus the token's metadata (sub,scope,exp,token_use,sid, …). It is protected: the caller must present a credential (JWT or API key) carrying theauth:introspectscope. Grant it to a resource-server API key:import jafaal jafaal.configure_api_key_scopes([..., jafaal.AUTH_INTROSPECT]) # "auth:introspect"A token reads as inactive once it expires, its signature/claims don't match, its
jtiis revoked, or its session has been ended (logout //revoke). -
POST <api-root>/auth/revoke(RFC 7009) — present a token and theclient_idit was issued to. Unknown and mismatched tokens return a silent200so the endpoint is not an oracle.client_idis required (RFC 7009 §2.1) and the token must have been issued to it (§5). Without that binding, possession of a leaked token is a force-logout primitive: anyone who observes a refresh token can end its owner's session. A token belonging to a different client is treated exactly like an unknown one — a silent200— so the endpoint does not answer "whose token is this?".- A refresh token deletes its session — always effective. With
denylist_enabled=Truethe session id is denylisted too, so the access tokens minted from the same grant stop working immediately rather than lapsing minutes later (§2.1).- An access token is denylisted by
jtionly whendenylist_enabled=True. Otherwise it remains live until expiry and the endpoint returns HTTP400RFC 7009unsupported_token_type, rather than falsely reporting success. Enable the denylist for immediate revocation; it adds one state-store lookup per authenticated request.
- An access token is denylisted by
Distributed deployments
The access-token denylist lives in the state store, so a multi-worker
deployment needs a shared backend (e.g.
RedisStateStore) for revocation to
apply across workers — the same requirement as progressive lockout.
WebAuthn / passkeys¶
JAFAAL ships passkey (WebAuthn) support as the optional jafaal[webauthn] extra
(installs py_webauthn). It covers both
passwordless login and passkey-as-second-factor, including
usernameless/discoverable credentials. Install and configure the Relying Party:
pip install 'jafaal[webauthn]'
import jafaal
jafaal.configure(
jafaal.AuthSettings(
secrets=jafaal.Secrets(secret_key=..., fernet_key=...),
base_url="https://app.example", # rp_id/origins default from this
webauthn=jafaal.WebAuthnSettings(
# Set explicitly if base_url is not the passkey origin:
# rp_id="app.example", # registrable domain, no scheme/port
# origins=("https://app.example",),
user_verification="preferred", # "required" makes UV (PIN/biometric) a true 2nd factor
attestation="none", # "direct" only if you process attestation
second_factor_enabled=False, # True → password login also requires a passkey
),
)
)
rp_id and origins default to the host and origin of
base_url; the endpoints return 503 if neither an explicit value nor a
usable base_url is configured. The companion table jafaal_webauthn_credentials
is created by jafaal.map_models + the packaged migrations (revision
0002_webauthn_credentials).
Endpoints¶
Mounted by create_auth_router (paths relative to your API root):
| Method & path | Auth | Purpose |
|---|---|---|
POST /auth/webauthn/register/begin |
access token + step-up | Start registration; returns navigator.credentials.create() options. |
POST /auth/webauthn/register/complete |
access token | Verify the attestation and store the passkey. |
GET /auth/webauthn/credentials |
access token | List the user's passkeys. |
POST /auth/webauthn/credentials/{id}/delete |
access token + step-up | Delete a passkey. |
POST /public/webauthn/authenticate/begin |
anonymous | Start passwordless login; returns {challenge_id, options}. |
POST /public/webauthn/authenticate/complete |
anonymous | Verify the assertion and issue JAFAAL tokens. |
POST /auth/webauthn/mfa/begin |
anonymous | Start the second-factor ceremony for a pending login. |
POST /auth/webauthn/mfa/complete |
anonymous | Verify the second-factor assertion and complete login. |
The /authenticate/* and /mfa/* completion endpoints take a client_id
naming a registered OAuthClient and return the same token
response as /auth/login. Challenges are single-use and expire after
challenge_ttl_seconds.
Second factor¶
With webauthn_second_factor_enabled=True, a successful password login for a
user who has registered passkeys returns the standard MFA-required response
(202 for web) instead of tokens. The client then completes the pending login
with a passkey via /auth/webauthn/mfa/begin → /auth/webauthn/mfa/complete
(or, if the account also has TOTP MFA, /auth/mfa/verify). Passwordless login is
always available regardless of this flag.
Distributed deployments
WebAuthn challenges live in the state store, so a multi-worker deployment
needs a shared backend (e.g.
RedisStateStore) — the same
requirement as progressive lockout.
Database: your Base and the session factory¶
You own the declarative registry; JAFAAL maps its companion tables into it with
map_models, so both share one metadata. Build your user
model on your own DeclarativeBase — call it whatever fits your domain — hand it
to map_models, and register a session factory bound to your engine.
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
import jafaal
from jafaal import IntPKUserMixin
class Base(DeclarativeBase): ... # your own base — naming conventions, schema, your other models
class Account(IntPKUserMixin, Base):
__tablename__ = "users"
jafaal.map_models(Base, user_model=Account)
engine = create_engine("postgresql+psycopg://...")
jafaal.configure_sessionmaker(sessionmaker(bind=engine, autoflush=False))
Base.metadata.create_all(engine) # fine for dev/tests; use jafaal.migrations in production (below)
The class name is yours; JAFAAL resolves its users relationship through the
class you register, not by looking one up by name. The users table name is
fixed, because JAFAAL's foreign keys reference it.
map_models(...) must run once at startup, after you define the model and
before create_auth_router() or any DB use (importing a JAFAAL model before
it is a configuration error). Omit the base — jafaal.map_models(user_model=...)
— to use JAFAAL's own convenience jafaal.orm.Base instead of owning one.
The reverse relationships (users_sessions, local_credential, auth_mfa, …)
and the mfa_enabled property are supplied by the mixin — you declare none of
them. Choose an integer or UUID primary key with
IntPKUserMixin or
UUIDPKUserMixin.
Supported databases¶
JAFAAL is database-agnostic — it owns no engine and uses only portable
SQLAlchemy types. The test suite runs on SQLite, PostgreSQL, and
MySQL in CI on every change, so cross-dialect portability is a guarantee
rather than an accident. Use whichever backend your host app already uses (e.g.
postgresql+psycopg://…, mysql+pymysql://…, or sqlite:///…).
SQLite caveats
SQLite is ideal for development, tests, and small single-node deployments,
but it has no real write concurrency (writes serialize on a
database-level lock). Combined with the default in-memory
StateStore, a SQLite deployment is effectively
single-process: run a single worker, or move to PostgreSQL/MySQL and
a distributed StateStore (e.g.
RedisStateStore) for multi-worker
setups. Note that Uuid primary keys and DateTime(timezone=True) columns
are stored differently across dialects (SQLite keeps UUIDs as 32-char hex and
can return naive datetimes); JAFAAL normalizes timestamps back to
timezone-aware UTC on read, so this stays transparent to callers.
Migrations¶
JAFAAL ships its schema as Alembic migrations (the jafaal[migrations]
extra) so its companion tables can evolve across releases. They run on a
dedicated version table (jafaal_alembic_version) and touch only JAFAAL's own
tables — your users table and your Alembic history are left alone. Every
JAFAAL table, and every index and named constraint on one, starts with
jafaal_ (jafaal_users_sessions, jafaal_oauth_states, …), so none can
collide with a table of yours.
from jafaal import migrations
migrations.adopt_existing_schema(engine) # validate/record unversioned tables, if any
migrations.upgrade(engine) # create/upgrade JAFAAL's tables
migrations.verify_schema_current(engine) # fail fast at startup if not migrated
Your users table must exist first (JAFAAL's tables reference users.id), so
run your own migrations before JAFAAL's; upgrade() refuses to start without it.
Each revision is explicit DDL, so a revision means the same schema in every
release, and each one fails the migration rather than skipping a change it
cannot make. The two things a revision takes from your application are the type
of users.id (integer or UUID) and your declarative base's naming convention,
which names JAFAAL's indexes exactly as it does for the models.
upgrade() creates a fresh database outright. A database that has JAFAAL tables
but no recorded revision — built with create_all, or with the version table
lost — is refused, because running the revisions over unchecked tables could
record a revision they were never validated against. Call
adopt_existing_schema() first: it compares the tables against the head schema
and then each earlier revision's (columns, types, nullability, server defaults,
keys, unique constraints, foreign keys and indexes), records the first exact
match, and leaves upgrade() to apply the rest. It never repairs a schema; when
nothing matches it raises SchemaCompatibilityError naming the closest revision
and its differences. Indexes you added yourself are accepted as long as they are
not unique.
Upgrading from 0.3: the jafaal_ prefix¶
Up to 0.3 the tables had unprefixed names (users_sessions, oauth_states, …).
Revision 0008_prefix_table_names renames each table with its indexes and named
constraints in place — no data is copied, and foreign keys between JAFAAL's
tables follow. Stop every process running 0.3 or earlier before running it:
the old code looks the old names up, so a rolling deployment across it is not
possible.
- Migrated with
jafaal.migrations: runupgrade()as usual. - Built with
create_all(nojafaal_alembic_versiontable): runadopt_existing_schema(), which recognises the unprefixed schema, thenupgrade().
Migrate before calling create_all() with 0.4: on a database that still holds
the old tables it raises SchemaCompatibilityError rather than creating empty
jafaal_* tables beside them.
Tables of yours that happen to carry generic old names (say your own
identity_providers, password_reset_tokens or sign_up_tokens) are not
mistaken for JAFAAL's: an unversioned 0.3 schema is only recognised by two or more
of the names specific to JAFAAL, such as users_local_credentials,
idp_link_tokens or mfa_backup_codes (see jafaal.orm.is_legacy_schema).
Prefer a single, unified Alembic history? Point your env.py at your
Base.metadata and add the package's versions directory to your
version_locations instead. Only migrations.upgrade() creates the prefixed
names directly; from your own history 0001_initial creates the unprefixed
names and 0008_prefix_table_names renames them, so a table of yours with an
old name still collides there.
Scopes¶
JAFAAL ships only auth/identity scopes. Layer your application scopes on top of
DEFAULT_SCOPE_CATALOG:
from jafaal import DEFAULT_SCOPE_CATALOG, configure_scopes, configure_api_key_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"])
Rate limiting and the state store¶
By default JAFAAL runs in a single process with an in-memory
StateStore and no rate limiting. Both are host
infrastructure you inject:
# jafaal.configure_rate_limiter(StateStoreRateLimiter()) # batteries-included limiter
# jafaal.configure_state_store(...) # inject Redis for multi-worker lockout state
create_auth_router() logs a startup warning when the no-op rate limiter is
still active, and refuses to start (raises RuntimeError) when the in-memory
state store is used in a deployed environment — set
allow_in_memory_state_store_when_deployed=True to override for a single-worker
deployment (see Security → Deployment hardening).
The batteries-included
StateStoreRateLimiter needs no
extra dependency and satisfies the limiter warning. For multi-worker/replica
deployments, also inject a distributed StateStore (e.g.
RedisStateStore); the limiter then
enforces budgets cluster-wide automatically.
Call jafaal.verify_configuration() once at startup (e.g. in a FastAPI lifespan
handler) to fail fast if a required component — the settings object, the session
factory, the user repository, or the settings provider — has not been installed,
instead of hitting a RuntimeError on the first request that needs it.