API Reference¶
HTTP route classification¶
The paths below use the default RouterPrefixes and
are relative to the path where the host mounts
create_auth_router(). A host may override the
prefixes, so deployed absolute paths can differ.
JAFAAL's registered clients are trusted, statically configured, first-party public clients. Calling a route an OAuth endpoint describes its wire contract; it does not make JAFAAL a third-party authorization service. The OIDC RP routes communicate with an upstream identity provider. Every other route is a JAFAAL extension, even when it implements a standard such as WebAuthn internally.
OAuth authorization-server endpoints¶
| Method | Path | Contract |
|---|---|---|
GET |
/auth/authorize |
RFC 6749 authorization endpoint; authorization code only, with PKCE S256 required |
POST |
/auth/token |
RFC 6749 token endpoint; authorization-code and refresh-token grants |
POST |
/auth/introspect |
RFC 7662 token introspection |
POST |
/auth/revoke |
RFC 7009 token revocation |
GET |
/.well-known/oauth-authorization-server |
RFC 8414 authorization-server metadata |
GET |
/.well-known/jwks.json |
RFC 7517 JSON Web Key Set used to verify asymmetric tokens |
Upstream OIDC relying-party integration¶
| Method | Path | Contract |
|---|---|---|
GET |
/public/idp/callback/{idp_slug} |
OAuth/OIDC callback from a configured upstream provider |
POST |
/auth/idp/step-up/reauth/{idp_id} |
JAFAAL extension that starts fresh upstream OIDC authentication for step-up |
These routes are part of JAFAAL's role as an OAuth client and OpenID Connect Relying Party. They are not OpenID Provider endpoints.
JAFAAL extension endpoints¶
| Methods | Paths | Purpose |
|---|---|---|
POST |
/auth/login |
First-party password login; not the OAuth resource-owner password grant |
POST |
/auth/mfa/verify |
Complete a pending login with TOTP or a backup code |
POST |
/auth/refresh |
Native cookie/header alias for the refresh-token grant; OAuth clients use /auth/token |
POST |
/auth/logout |
Delete the current JAFAAL session; not an OIDC logout endpoint |
POST |
/auth/password/change |
Change the authenticated user's password after step-up |
POST |
/auth/password/renew |
Replace a password marked as requiring change |
POST |
/auth/password/user/{user_id} |
Administrative password reset |
POST |
/auth/password-reset/request, /auth/password-reset/confirm |
Request and consume a password-reset token |
POST |
/auth/sign-up/request, /auth/sign-up/confirm |
Create a local account and confirm its email token |
GET |
/auth/sessions/user/{user_id} |
List a user's sessions |
DELETE |
/auth/sessions/{session_id}/user/{user_id}, /auth/sessions/user/{user_id} |
Revoke one or all of a user's sessions |
GET, POST |
/auth/api-keys |
List or create API keys |
PATCH |
/auth/api-keys/{api_key_id}/revoke |
Revoke an API key while retaining its record |
DELETE |
/auth/api-keys/{api_key_id} |
Delete an API key |
GET |
/public/idp |
List enabled upstream identity providers for a login picker |
GET, POST |
/auth/idp |
List or create configured identity providers |
GET |
/auth/idp/templates |
List built-in identity-provider templates |
PUT, DELETE |
/auth/idp/{idp_id} |
Update or delete an identity provider |
POST |
/auth/webauthn/register/begin, /auth/webauthn/register/complete |
Register a passkey |
GET |
/auth/webauthn/credentials |
List the authenticated user's passkeys |
POST |
/auth/webauthn/credentials/{credential_pk}/delete |
Delete a passkey after step-up |
POST |
/auth/webauthn/mfa/begin, /auth/webauthn/mfa/complete |
Complete a pending password login with a passkey second factor |
POST |
/public/webauthn/authenticate/begin, /public/webauthn/authenticate/complete |
Passwordless passkey login |
Login, MFA, passkey, password, signup, session, API-key, and administrative
routes are not OAuth grants or endpoints. Their JAFAAL domain failures use the
{"detail", "code"} contract; request-schema validation may use FastAPI's
native HTTP 422 detail array.
OAuth protocol routes never use that FastAPI validation body. Missing, empty,
malformed, non-text, and repeated query or form parameters use the OAuth
{"error", "error_description"} shape. /auth/authorize renders that JSON
without redirecting until one registered client and redirect URI validate;
later errors are sent to that validated URI with error, error_description,
iss, and an unambiguous state. See Client integration: OAuth protocol
errors.
Python API¶
The curated public API is everything exported from the top-level jafaal
package. Each symbol below is generated from its source docstring.
Authentication package.
Provides the FastAPI router, JWT token issuance and validation, password hashing, scope enforcement, API-key validation, and the progressive-lockout stores used during login and MFA verification.
Persistence-bearing concerns (identity providers, IdP link tokens, MFA backup codes, OAuth state) live in dedicated sub-packages and expose their own models, schemas, and CRUD modules.
Exports
- Password hashing:
PasswordHasher,PasswordPolicyError,get_password_hasher - JWT:
TokenManager,TokenType,get_token_manager - Security dependencies:
AuthContext,oauth2_scheme,validate_access_token,check_auth_scopes,get_sub_from_access_token,get_sid_from_access_token,get_sub_from_refresh_token,get_sid_from_refresh_token,validate_access_token_or_api_key,
Scope enforcement (check_scopes) is provided by
:mod:jafaal.dependencies, which resolves the full principal.
- Schemas: MFALoginRequest,
MFARequiredResponse,
TokenResponseWeb, TokenResponseMobile,
LogoutResponse
- Stores: PendingMFALogin, FailedLoginAttempts,
StepUpAttempts, get_pending_mfa_store,
get_failed_login_attempts, get_step_up_attempts,
cleanup_expired_pending_mfa_logins,
clear_pending_mfa_for_user
- Helpers: authenticate_user, complete_login,
create_tokens
- User model mixins: UserMixin, IntPKUserMixin,
UUIDPKUserMixin (extensible base for the host app's user table)
PasswordHasher ¶
PasswordHasher provides secure password hashing, verification, and password policy enforcement.
This class encapsulates password hashing logic, verification, and secure password generation according to strong password policies. It supports pluggable hashers and ensures that generated or validated passwords meet complexity requirements (uppercase, lowercase, digit, punctuation).
Attributes:
| Name | Type | Description |
|---|---|---|
UPPER |
str
|
All uppercase ASCII letters. |
LOWER |
str
|
All lowercase ASCII letters. |
DIGITS |
str
|
All ASCII digits. |
PUNCTUATION |
str
|
All ASCII punctuation characters. |
ALL |
str
|
Combination of all allowed characters. |
Methods:
| Name | Description |
|---|---|
__init__ |
Argon2Hasher | None = None): Initializes the PasswordHasher with an optional custom hasher. |
hash_password |
str) -> str: Hashes a plain text password using the configured password hashing algorithm. |
verify_password |
str, hashed_password: str) -> bool: Verifies if a plain password matches the given hashed password. |
verify_and_update |
str, hashed_password: str) -> tuple[bool, str | None]: Verifies a password and updates the hash if the algorithm or parameters have changed. |
generate_password |
int = 8) -> str: Generates a secure random password of specified length, ensuring complexity. |
validate_password |
str, min_length: int = 8) -> None: Validates that a password meets the required security policy, raising PasswordPolicyError if not. |
is_valid_password |
str, min_length: int = 8) -> bool: Checks if a password meets the specified minimum length and password policy requirements. |
Example
try: PasswordHasher.validate_password("weak") except PasswordPolicyError as e: print("Oops:", e)
Source code in jafaal/_internal/password_hasher.py
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 | |
__init__ ¶
__init__(hasher=None)
Initialize the password hasher configuration. Args: hasher (Argon2Hasher | Iterable[object] | PasswordHash | None, optional): The hasher(s) to use for password hashing. Can be: - None: Uses the strongest recommended configuration. - PasswordHash: Uses the provided PasswordHash instance. - Argon2Hasher: Uses the single hasher instance. - Iterable: Uses a list of hasher instances. Raises: TypeError: If the provided hasher is not of a supported type.
Source code in jafaal/_internal/password_hasher.py
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | |
hash_password ¶
hash_password(password)
Hashes the provided password using the configured password hashing algorithm.
The password is NFKC-normalized first (NIST SP 800-63B §5.1.1.2), so a passphrase enrolled on one platform verifies on another.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
password
|
str
|
The plain text password to be hashed. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
The resulting hashed password. |
Source code in jafaal/_internal/password_hasher.py
151 152 153 154 155 156 157 158 159 160 161 162 163 164 | |
verify_password ¶
verify_password(plain_password, hashed_password)
Verifies whether the provided plain text password matches the given hashed password.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
plain_password
|
str
|
The plain text password to verify. |
required |
hashed_password
|
str
|
The hashed password to compare against. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True if the plain password matches the hashed password, False otherwise. |
Source code in jafaal/_internal/password_hasher.py
166 167 168 169 170 171 172 173 174 175 176 177 | |
verify_and_update ¶
verify_and_update(plain_password, hashed_password)
Verifies a plain password against a hashed password and updates the hash if necessary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
plain_password
|
str
|
The plain text password to verify. |
required |
hashed_password
|
str
|
The hashed password to verify against. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
tuple[bool, str | None]: A tuple where the first element is a boolean indicating |
str | None
|
whether the password is correct, and the second element is the updated hash if |
tuple[bool, str | None]
|
the hash algorithm has changed or None otherwise. |
Source code in jafaal/_internal/password_hasher.py
179 180 181 182 183 184 185 186 187 188 189 190 191 192 | |
dummy_verify ¶
dummy_verify()
Run a constant-time-equivalent password verify against a dummy hash.
Used by the login and MFA-verify endpoints on the "username/user not found" branch to equalise wall-clock latency with the "found, wrong password" branch. Without this, an unauthenticated attacker can enumerate valid usernames by measuring response time, because Argon2 is deliberately tuned to hundreds of milliseconds and a fast bail-out on the not-found branch is trivially distinguishable from a real verify.
The dummy hash is pre-computed once at construction (see
:meth:__init__), so this call always costs exactly one verify —
the first invocation is not slower than steady state. A fresh
random password is verified against it, so the result is always
False; the return value is ignored (the call exists purely
for its timing side effect).
Source code in jafaal/_internal/password_hasher.py
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | |
generate_password
staticmethod
¶
generate_password(length=8)
Generate a secure random password of specified length.
The generated password will contain at least one uppercase letter, one lowercase letter, one digit, and one punctuation character to ensure complexity. The remaining characters are randomly selected from all allowed character sets.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
length
|
int
|
The desired length of the password. Must be at least 8. |
8
|
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
A randomly generated password meeting the specified criteria. |
Raises:
| Type | Description |
|---|---|
PasswordPolicyError
|
If the requested length is less than 8. |
Source code in jafaal/_internal/password_hasher.py
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | |
validate_password
staticmethod
¶
validate_password(
password,
min_length=8,
policy_type="strict",
max_length=None,
)
Validates whether the given password meets the required security policy.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
password
|
str
|
The password string to validate. |
required |
min_length
|
int
|
The minimum required length for the password. Defaults to 8. |
8
|
policy_type
|
str
|
The password policy type to enforce. - "strict": Requires uppercase, lowercase, digit, and special character. - "length_only": Only enforces minimum/maximum length. Defaults to "strict". |
'strict'
|
max_length
|
int | None
|
The maximum accepted length. When
provided, the password is rejected before hashing if it exceeds
this bound. |
None
|
Raises:
| Type | Description |
|---|---|
PasswordPolicyError
|
If the password does not meet the policy requirements. |
Notes
- NIST SP 800-63B advises against imposing composition rules and in
favour of length plus breach screening.
"length_only"is the standards-aligned choice and the shipped default; pair it with a longermin_lengthand a breached-password check."strict"remains available for hosts bound by legacy composition requirements, but SP 800-63B-4 §3.1.1.2 states verifiers SHALL NOT impose them. - The password is never truncated: Argon2 accepts it whole, so
max_lengthis the only upper bound and exists purely to cap hashing work.
Source code in jafaal/_internal/password_hasher.py
249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 | |
is_valid_password
staticmethod
¶
is_valid_password(
password,
min_length=8,
policy_type="strict",
max_length=None,
)
Checks if the provided password meets the specified minimum length and password policy requirements.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
password
|
str
|
The password string to validate. |
required |
min_length
|
int
|
The minimum required length for the password. Defaults to 8. |
8
|
policy_type
|
str
|
The password policy type to enforce. Defaults to "strict". |
'strict'
|
max_length
|
int | None
|
The maximum accepted length, or |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True if the password is valid according to the policy, False otherwise. |
Source code in jafaal/_internal/password_hasher.py
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 | |
PasswordPolicyError ¶
Bases: UnprocessableError
A password failed the configured policy (422).
Source code in jafaal/exceptions.py
437 438 439 440 441 | |
FailedLoginAttempts ¶
Track failed login attempts with progressive lockout.
Two independent dimensions guard the login endpoint:
- Per-username (5/10/20 → 5m/30m/24h) bounds brute-force against a single account. Because it keys on the username, anyone who knows a username can trip it — i.e. it is also a targeted-lockout (DoS) lever: an attacker can lock a known account out by submitting bad passwords for it. This is inherent to per-account lockout.
- Per-source-IP (50/100/250 → 15m/1h/24h) bounds how many accounts a
single IP can lock out by spraying failures across many usernames, so the
targeted-lockout lever above is not cheap at scale — an attacker must
rotate IPs. It is reset on any successful login from the IP (so a busy
shared egress rarely trips it) and gated by
:attr:
~jafaal.settings.AuthSettings.login_ip_lockout_enabled. It relies on an accurate client IP, so configuretrusted_proxiesbehind a reverse proxy (otherwise every client shares the proxy's address).
Attributes:
| Name | Type | Description |
|---|---|---|
_state_override |
Explicit provider (tests); |
|
_lockout |
Per-username progressive-lockout helper. |
|
_ip_lockout |
Per-source-IP progressive-lockout helper. |
Source code in jafaal/_internal/security_stores.py
439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 | |
is_locked_out ¶
is_locked_out(username)
Check if a username is locked out from failed logins.
Source code in jafaal/_internal/security_stores.py
492 493 494 | |
get_lockout_time ¶
get_lockout_time(username)
Get the lockout expiry for a username, if locked out.
Source code in jafaal/_internal/security_stores.py
496 497 498 | |
record_failed_attempt ¶
record_failed_attempt(username)
Record a failed login and return the current attempt count.
Source code in jafaal/_internal/security_stores.py
500 501 502 | |
reset_attempts ¶
reset_attempts(username)
Clear the failed-attempt counter on successful login.
Source code in jafaal/_internal/security_stores.py
504 505 506 | |
is_ip_locked_out ¶
is_ip_locked_out(ip)
Check if a source IP is under the per-IP failed-login backoff.
Source code in jafaal/_internal/security_stores.py
509 510 511 512 513 | |
get_ip_lockout_time ¶
get_ip_lockout_time(ip)
Get the per-IP backoff expiry for a source IP, if active.
Source code in jafaal/_internal/security_stores.py
515 516 517 518 519 | |
record_ip_failure ¶
record_ip_failure(ip)
Record a failed login against the source IP; return the count (0 if disabled).
Source code in jafaal/_internal/security_stores.py
521 522 523 524 525 | |
reset_ip_attempts ¶
reset_ip_attempts(ip)
Deliberately a no-op — see below.
The per-username counter is reset on a successful login because authenticating as that user proves the failures were that user fumbling their own password. No equivalent proof exists per IP: an address is shared by many accounts, so a success from it says nothing about the failures against other usernames.
Resetting it made the per-IP tier — the only bound on the targeted
lockout DoS the username tier enables — trivially defeatable: spray 49
failures at victims, log in once to an account you own, repeat, and lock
out arbitrarily many accounts from one address. The counter instead
decays on its own attempts_ttl_seconds window, which is what keeps a
legitimate NAT gateway from accumulating failures forever.
Kept as a method (rather than removed) so the call site still reads as "success handling", with the reasoning attached to the behaviour.
Source code in jafaal/_internal/security_stores.py
527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 | |
clear_all ¶
clear_all()
Clear all failed-login records (per-username and per-IP).
Source code in jafaal/_internal/security_stores.py
548 549 550 551 | |
PendingLogin
dataclass
¶
A password-verified login awaiting its second factor.
Attributes:
| Name | Type | Description |
|---|---|---|
user_id |
UserId
|
The user who completed the password step. |
username |
str
|
The username as supplied at login, used for the MFA lockout key and for audit records. |
client_id |
str
|
The registered client the login was started for. The second factor must be completed against the same client: the client's registration decides token delivery (cookie vs body) and the scope ceiling, so letting the ticket be redeemed by a different one would let a login begun for a narrow, body-delivery client finish as a wide, cookie-delivery one. |
scope |
tuple[str, ...]
|
The |
auth_request |
str | None
|
The pending authorization request this login is completing
( |
Source code in jafaal/_internal/security_stores.py
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | |
PendingMFALogin ¶
Manage pending MFA logins plus per-username MFA failure lockout (5/10/15).
A pending login is addressed by an opaque, single-use mfa_token
minted when the password step succeeds and returned to that caller only. The
username is not an address: it is public (or guessable), so keying the
pending record on it would mean anyone holding a valid one-time code could
complete a login during the window a legitimate password step opened —
collapsing two factors back to one. Possession of the ticket is the proof
that the password factor was satisfied by this caller.
The ticket survives failed code attempts (a user may mistype) and is
consumed atomically by :meth:claim_pending_login on success; the MFA
lockout tiers bound how many attempts it can absorb.
Attributes:
| Name | Type | Description |
|---|---|---|
PENDING_MFA_TTL_SECONDS |
int
|
TTL for pending MFA entries. |
_state_override |
Explicit provider (tests); |
|
_lockout |
Shared progressive-lockout helper for MFA failures. |
Source code in jafaal/_internal/security_stores.py
554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 | |
add_pending_login ¶
add_pending_login(
username,
user_id,
client_id,
scope=(),
auth_request=None,
)
Record a pending MFA login and return its opaque ticket.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
username
|
str
|
The username that just passed the password step. |
required |
user_id
|
UserId
|
The user the pending login belongs to. |
required |
client_id
|
str
|
The registered client the login was started for; the second factor must be completed against the same one. |
required |
scope
|
Sequence[str]
|
The |
()
|
auth_request
|
str | None
|
The pending authorization request being completed, if
the login came from |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The |
str
|
complete the second factor. |
Source code in jafaal/_internal/security_stores.py
637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 | |
get_pending_login ¶
get_pending_login(mfa_token)
Resolve a pending MFA login from its ticket, evicting corrupt entries.
Source code in jafaal/_internal/security_stores.py
672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 | |
claim_pending_login ¶
claim_pending_login(mfa_token)
Atomically consume a pending MFA login, so one ticket logs in once.
Source code in jafaal/_internal/security_stores.py
689 690 691 692 693 694 695 696 697 | |
delete_pending_login ¶
delete_pending_login(mfa_token)
Remove the pending MFA login addressed by mfa_token.
Source code in jafaal/_internal/security_stores.py
699 700 701 702 703 704 | |
clear_for_user ¶
clear_for_user(user_id)
Remove every pending MFA login entry tied to a user ID.
Source code in jafaal/_internal/security_stores.py
706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 | |
has_pending_login ¶
has_pending_login(mfa_token)
Check whether mfa_token addresses a valid pending MFA login.
Source code in jafaal/_internal/security_stores.py
724 725 726 | |
cleanup_expired ¶
cleanup_expired()
Return zero because the backend expires pending entries by TTL.
Source code in jafaal/_internal/security_stores.py
728 729 730 | |
is_locked_out ¶
is_locked_out(username)
Check if a username is locked out from MFA attempts.
Source code in jafaal/_internal/security_stores.py
732 733 734 | |
get_lockout_time ¶
get_lockout_time(username)
Get the MFA lockout expiry for a username, if locked out.
Source code in jafaal/_internal/security_stores.py
736 737 738 | |
record_failed_attempt ¶
record_failed_attempt(username)
Record a failed MFA attempt and return the current count.
Source code in jafaal/_internal/security_stores.py
740 741 742 | |
reset_attempts ¶
reset_attempts(username)
Reset the MFA failure counter after a successful verification.
Source code in jafaal/_internal/security_stores.py
744 745 746 | |
clear_all ¶
clear_all()
Clear all pending logins and MFA failure records.
Source code in jafaal/_internal/security_stores.py
748 749 750 751 752 753 754 | |
StepUpAttempts ¶
Track failed step-up verification attempts (5/10/15 → 5m/30m/2h).
Keys are stable user identifiers (e.g. user:{user_id}).
Attributes:
| Name | Type | Description |
|---|---|---|
_state_override |
Explicit provider (tests); |
|
_lockout |
Shared progressive-lockout helper. |
Source code in jafaal/_internal/security_stores.py
757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 | |
is_locked_out ¶
is_locked_out(key)
Check if a user key is locked out from step-up.
Source code in jafaal/_internal/security_stores.py
781 782 783 | |
get_lockout_time ¶
get_lockout_time(key)
Get the step-up lockout expiry for a user key, if locked out.
Source code in jafaal/_internal/security_stores.py
785 786 787 | |
record_failed_attempt ¶
record_failed_attempt(key)
Record a failed step-up attempt and return the current count.
Source code in jafaal/_internal/security_stores.py
789 790 791 | |
reset_attempts ¶
reset_attempts(key)
Reset the step-up failure counter for a user key.
Source code in jafaal/_internal/security_stores.py
793 794 795 | |
clear_all ¶
clear_all()
Clear all step-up failure records.
Source code in jafaal/_internal/security_stores.py
797 798 799 | |
StepUpStore ¶
Bases: Protocol
Contract for step-up lockout stores. Keys are stable user identifiers.
Source code in jafaal/_internal/security_stores.py
282 283 284 285 286 287 288 289 290 291 292 293 294 | |
TokenManager ¶
Issue, decode, and validate JWTs (and mint CSRF tokens) for user sessions.
Signs with either HS256 (symmetric, the default — a shared secret) or an
asymmetric RSA/EC algorithm (RS256/ES256/…), where a private key
signs and the corresponding public key is published at the JWKS endpoint so
resource servers verify statelessly. The algorithm is pinned via
:data:jafaal.settings.ALLOWED_ALGORITHMS and the same allow-list is passed
to jwt.decode so it cannot drift (blocking alg=none and
algorithm-confusion). Asymmetric tokens carry the active key's RFC 7638
thumbprint as kid. Validation failures raise a
:class:~jafaal.exceptions.JafaalError (mapped to HTTP 401 at the router
edge); the constructor raises :class:ValueError for an algorithm outside
the allow-list or missing asymmetric key material.
Attributes:
| Name | Type | Description |
|---|---|---|
algorithm |
The JWT signing algorithm. |
Source code in jafaal/_internal/token_manager.py
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 | |
__init__ ¶
__init__(
secret_key,
algorithm="HS256",
*,
access_token_expire_minutes=15,
refresh_token_expire_days=7,
issuer="",
audience="",
secret_key_fallbacks=(),
private_key="",
private_key_fallbacks=(),
leeway_seconds=0,
client_id="",
)
Initializes the TokenManager with the provided secret key and settings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
secret_key
|
str
|
The secret key used for signing and verifying tokens. |
required |
algorithm
|
str
|
The algorithm to use for token
operations. Defaults to "HS256". Must be a member of
:data: |
'HS256'
|
access_token_expire_minutes
|
int
|
Access-token lifetime in minutes. |
15
|
refresh_token_expire_days
|
int
|
Refresh-token lifetime in days. |
7
|
issuer
|
str
|
JWT |
''
|
audience
|
str
|
JWT |
''
|
secret_key_fallbacks
|
tuple[str, ...]
|
Additional keys accepted
when verifying a token (never used to sign). Lets tokens
issued before a |
()
|
private_key
|
str
|
PEM private key used to sign JWTs when
|
''
|
private_key_fallbacks
|
tuple[str, ...]
|
Verify-only public/private PEM keys kept in the published JWKS during a signing-key rotation overlap. |
()
|
leeway_seconds
|
int
|
Clock-skew tolerance, in seconds, applied to
the |
0
|
client_id
|
str
|
Value of the |
''
|
Source code in jafaal/_internal/token_manager.py
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 | |
get_token_claim ¶
get_token_claim(token, claim)
Retrieves a specific claim from a decoded JWT token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
The JWT token string to decode. |
required |
claim
|
str
|
The name of the claim to retrieve from the token. |
required |
Returns:
| Type | Description |
|---|---|
str | list[str] | int
|
str | list[str] | int: The value of the requested claim, which can be a string, list of strings, or integer. |
Raises:
| Type | Description |
|---|---|
JafaalError
|
If the claim is not found in the token or if there is an error retrieving the claim. |
Source code in jafaal/_internal/token_manager.py
272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 | |
decode_token ¶
decode_token(token)
Decodes a JWT token and returns the parsed Token object.
The algorithms allow-list (pinned to this manager's algorithm) is
always passed to jwt.decode: without it joserfc would trust whatever
algorithm the token header advertises (none or an
algorithm-confusion variant), bypassing the signature check.
In symmetric (HS256) mode the token is verified against the primary key
then each rotation fallback. In asymmetric mode it is verified against
the public-key set, which joserfc selects by the header kid (the
active key plus any rotation fallbacks).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
The JWT token to decode. |
required |
Returns:
| Type | Description |
|---|---|
Token
|
joserfc.jwt.Token: The decoded token (use |
Raises:
| Type | Description |
|---|---|
JafaalError
|
If the token cannot be decoded, raises an HTTP 401 Unauthorized exception. |
Source code in jafaal/_internal/token_manager.py
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 | |
validate_token_expiration ¶
validate_token_expiration(token, expected_type)
Validates expiration, required claims, and type of a JWT.
Checks that the token contains all essential claims, is not expired or used before its valid time, and that it names the expected token use. This prevents refresh tokens from being used as access tokens and vice versa.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
The JWT token to validate. |
required |
expected_type
|
TokenType
|
The expected token type
( |
required |
Raises:
| Type | Description |
|---|---|
JafaalError
|
If the token is missing required claims, expired, not yet valid, contains invalid claims, or has the wrong type. |
Source code in jafaal/_internal/token_manager.py
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 | |
validate_access_expiration_logged ¶
validate_access_expiration_logged(access_token)
Validate an access token's expiration and log failures consistently.
Wraps :meth:validate_token_expiration for TokenType.ACCESS and
applies the shared logging policy used by the access-token validation
dependency and IdentityService: expired tokens log at debug
(an expected, routine condition) while all other validation failures
log at error. The original :class:JafaalError is re-raised
unchanged so callers keep the same 401 semantics.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
access_token
|
str
|
The raw JWT access token to validate. |
required |
Raises:
| Type | Description |
|---|---|
JafaalError
|
401 if the token is missing claims, expired, not yet valid, or otherwise invalid. |
Source code in jafaal/_internal/token_manager.py
481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 | |
create_token ¶
create_token(
session_id,
user,
token_type,
client=None,
requested_scope=None,
)
Creates a JWT token for a user session with appropriate access scope and expiration.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
The unique identifier for the session. |
required |
user
|
UserProtocol
|
The user object containing user details. |
required |
token_type
|
TokenType
|
The type of token to create (access or refresh). |
required |
client
|
OAuthClient | None
|
The registered client the token is being issued to. Its
scope ceiling narrows the user's grants and its |
None
|
requested_scope
|
Sequence[str] | None
|
The |
None
|
Returns:
| Type | Description |
|---|---|
tuple[datetime, str]
|
tuple[datetime, str]: A tuple containing the token's expiration datetime and the encoded JWT token string. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If required parameters are missing or invalid. |
Source code in jafaal/_internal/token_manager.py
511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 | |
create_csrf_token
staticmethod
¶
create_csrf_token()
Generate a secure random CSRF (Cross-Site Request Forgery) token.
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
A URL-safe, securely generated random string suitable for use as a CSRF token. |
Source code in jafaal/_internal/token_manager.py
611 612 613 614 615 616 617 618 619 620 | |
jwks ¶
jwks()
Return the JSON Web Key Set of public verification keys.
Empty ({"keys": []}) in symmetric (HS256) mode, which has no public
key to publish. In asymmetric mode it contains the active signing key's
public JWK plus any rotation fallbacks, each tagged with its kid
(RFC 7638 thumbprint), use: "sig", and alg — exactly what a
resource server needs to verify JAFAAL's access tokens statelessly.
Source code in jafaal/_internal/token_manager.py
622 623 624 625 626 627 628 629 630 631 | |
AuthenticationError ¶
Bases: JafaalError
The caller could not be authenticated (401).
Carries an RFC 6750 §3 WWW-Authenticate challenge. The base case — no
credential was supplied — is a bare Bearer, because §3 says the
error parameter MUST NOT be sent when the request contained no
authentication information. Subclasses that represent a presented but
unusable credential set :attr:bearer_error (almost always
invalid_token, per §3.1), which is what lets a client tell "log in" from
"refresh and retry" without parsing prose.
Source code in jafaal/exceptions.py
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 | |
AuthorizationError ¶
Bases: JafaalError
The caller is authenticated but not permitted (403).
Source code in jafaal/exceptions.py
87 88 89 90 91 92 | |
ConflictError ¶
Bases: JafaalError
The request conflicts with the current state (409).
Source code in jafaal/exceptions.py
119 120 121 122 123 124 | |
IdentityProviderError ¶
Bases: UpstreamError
An external identity provider returned an error (502).
Source code in jafaal/exceptions.py
455 456 457 458 459 | |
IdentityProviderTimeoutError ¶
Bases: UpstreamTimeoutError
An external identity provider timed out (504).
Source code in jafaal/exceptions.py
462 463 464 465 466 | |
InactiveAccountError ¶
Bases: AuthenticationError
The credential is well-formed but its account can no longer be used.
A deactivated (or deleted) account is a credential-validation failure,
not an authorization one: RFC 6750 §3.1 puts "the access token is
revoked… or otherwise invalid" under invalid_token with a 401. Answering
403 would tell a bearer that its token is still good and only the permission
is missing, and answering 404 would make the resource server's account state
observable to whoever holds a stale token.
Source code in jafaal/exceptions.py
227 228 229 230 231 232 233 234 235 236 237 238 239 240 | |
InternalError ¶
Bases: JafaalError
An unexpected internal error (500).
Source code in jafaal/exceptions.py
183 184 185 186 187 188 | |
InvalidApiKeyError ¶
Bases: AuthenticationError
The supplied API key is unknown, revoked, or malformed.
Source code in jafaal/exceptions.py
243 244 245 246 247 248 249 250 | |
InvalidCredentialsError ¶
Bases: AuthenticationError
Username/password (or equivalent) did not verify.
Source code in jafaal/exceptions.py
196 197 198 199 200 | |
InvalidMFACodeError ¶
Bases: InvalidRequestError
A supplied TOTP/backup MFA code did not verify (400).
Source code in jafaal/exceptions.py
348 349 350 351 352 | |
InvalidRequestError ¶
Bases: JafaalError
The request is malformed or semantically invalid (400).
Source code in jafaal/exceptions.py
95 96 97 98 99 100 | |
InvalidTokenError ¶
Bases: AuthenticationError
A JWT is malformed, has a bad signature, or fails claim validation.
Source code in jafaal/exceptions.py
211 212 213 214 215 216 | |
JafaalError ¶
Bases: Exception
Base class for all JAFAAL domain errors.
Source code in jafaal/exceptions.py
31 32 33 34 35 36 37 38 39 40 41 42 43 | |
MissingScopeError ¶
Bases: AuthorizationError
The principal lacks one or more required scopes.
Carries an RFC 6750 §3 WWW-Authenticate challenge built from the scopes
the endpoint requires:
Bearer error="insufficient_scope", scope="users:read users:write". The
scope attribute is a space-delimited list per RFC 6749 §3.3 — a
client parsing the challenge to decide what to re-request needs that exact
shape, so it is built here rather than at each raise site.
Source code in jafaal/exceptions.py
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | |
NotFoundError ¶
Bases: JafaalError
The requested resource does not exist (404).
Source code in jafaal/exceptions.py
111 112 113 114 115 116 | |
PasswordChangeRequiredError ¶
Bases: AuthenticationError
The password is correct but must be replaced before it can be used.
Raised when a credential was written with must_change=True — an operator
seeding the first administrator, or a CLI reset-password. Such a password
is known to whoever set it, so allowing it to stay in use indefinitely would
make a bootstrap credential a permanent one.
Distinct from :class:InvalidCredentialsError on purpose: the caller needs
to know the password was right and that the remedy is to replace it, not to
retry. There is no enumeration concern in the distinction, because reaching
it already required presenting the correct password.
Source code in jafaal/exceptions.py
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 | |
PreconditionFailedError ¶
Bases: JafaalError
A precondition for the request was not met (412).
Source code in jafaal/exceptions.py
127 128 129 130 131 132 | |
RateLimitedError ¶
Bases: JafaalError
The caller has been rate limited (429).
retry_after (seconds) is surfaced as the Retry-After header.
Source code in jafaal/exceptions.py
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | |
ServiceUnavailableError ¶
Bases: JafaalError
A required dependency is temporarily unavailable (503).
Source code in jafaal/exceptions.py
175 176 177 178 179 180 | |
SessionExpiredError ¶
Bases: AuthenticationError
The server-side session is missing or expired.
Source code in jafaal/exceptions.py
219 220 221 222 223 224 | |
StaleRefreshTokenError ¶
Bases: AuthenticationError
A rotated/replayed refresh token was presented; clear the cookie.
Replaces the old ClearRefreshTokenCookieHTTPException: the edge handler
reads :attr:clear_refresh_cookie and emits the refresh-cookie deletion
headers on the response.
Source code in jafaal/exceptions.py
253 254 255 256 257 258 259 260 261 262 263 264 | |
StepUpReauthRequiredError ¶
Bases: AuthenticationError
Step-up needs a fresh identity-provider re-authentication.
Raised for an SSO-only account (no local password and no MFA) that has at
least one usable identity-provider link: a valid access token alone cannot
satisfy step-up, so the caller must complete a fresh IdP re-authentication
to obtain a single-use step-up grant and then retry the operation.
reauth_idp_ids lists the linked providers eligible for re-authentication.
Aligns with RFC 9470 (OAuth 2.0 Step-Up Authentication): the
WWW-Authenticate header advertises insufficient_user_authentication
so a standards-aware client knows to trigger a stronger authentication.
Source code in jafaal/exceptions.py
301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 | |
StoreUnavailableError ¶
Bases: ServiceUnavailableError
A security state store (lockout counters, MFA secret) is unreachable.
Unifies the former AuthSecurityStoreUnavailableError and
MFASecretStoreUnavailableError.
Source code in jafaal/exceptions.py
444 445 446 447 448 449 450 451 452 | |
TokenExpiredError ¶
Bases: AuthenticationError
A JWT (access/refresh) has expired.
Source code in jafaal/exceptions.py
203 204 205 206 207 208 | |
UnprocessableError ¶
Bases: InvalidRequestError
The request is well-formed but cannot be processed (422).
Source code in jafaal/exceptions.py
103 104 105 106 107 108 | |
UpstreamError ¶
Bases: JafaalError
An upstream provider returned a bad response (502).
Source code in jafaal/exceptions.py
159 160 161 162 163 164 | |
UpstreamTimeoutError ¶
Bases: UpstreamError
An upstream provider timed out (504).
Source code in jafaal/exceptions.py
167 168 169 170 171 172 | |
RouterPrefixes
dataclass
¶
Sub-prefixes for the aggregated routers (relative to the host's API root).
Defaults assume the aggregate is mounted under /api/v1 and match the
path assumptions in :class:~jafaal.settings.AuthSettings.
Source code in jafaal/factory.py
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | |
Base ¶
Bases: DeclarativeBase
JAFAAL's convenience declarative base.
Under Option B the host owns the registry: define your own
:class:~sqlalchemy.orm.DeclarativeBase, build Users on it, and pass it
to :func:map_models. Use this default base only if you would rather not own
one — build the user model on it and call map_models() without a base.
Source code in jafaal/orm.py
105 106 107 108 109 110 111 112 | |
AccountLocked
dataclass
¶
Progressive lockout was applied to a login / MFA / step-up subject.
Emitted best-effort when a lockout tier trips so the host can notify the
account owner. subject is the locked value (a username or an IP address),
subject_kind distinguishes the two, and store names the flow
("Login" / "MFA" / "Step-up").
Source code in jafaal/ports.py
300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | |
AuthenticatorChanged
dataclass
¶
An authentication factor was added to or removed from an account.
Covers TOTP enable/disable, backup-code regeneration, and passkey
registration/deletion. Binding or unbinding an authenticator changes how
the account can be signed into, so the owner has to hear about it out of
band — an attacker who enrols their own factor (or strips the victim's)
otherwise does so in total silence. remaining_factors lets a host warn
loudly when an account is left with none.
Source code in jafaal/ports.py
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 | |
AuthEventSink ¶
Bases: Protocol
Host-owned delivery of JAFAAL's outbound notifications.
JAFAAL performs the security-critical work (mint/hash/store token, single-use + expiry, enumeration-safe response) and emits these events; the host delivers them via email/SMS/websocket/queue/log. All methods are awaited best-effort — for enumeration-safe flows JAFAAL swallows and logs delivery failures so they cannot change the HTTP response or leak whether an account exists.
Source code in jafaal/ports.py
367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 | |
EmailVerificationRequested
dataclass
¶
A sign-up needs email verification; deliver token to email.
Source code in jafaal/ports.py
249 250 251 252 253 254 255 256 257 258 | |
IdpAccountLinked
dataclass
¶
An identity provider was linked to an existing account by matching email.
Emitted when an SSO login adopts a pre-existing local account rather than creating one — a new way to sign in that the owner did not initiate from a session they already held. Tell them out of band, so a link they did not expect is visible rather than silent.
Source code in jafaal/ports.py
329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 | |
IdpIdentity
dataclass
¶
An identity resolved from an external identity provider.
Handed to the host so it can provision (or sync) its own user row with its
own profile shape/defaults. claims carries the raw mapped IdP claims so
the host can pick whatever additional fields it wants.
Source code in jafaal/ports.py
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | |
NewDeviceLogin
dataclass
¶
A user signed in from a device/browser not seen on any prior session.
Emitted best-effort after a successful login so the host can alert the user
("new sign-in from …"). device_description is a human-readable summary
parsed from the User-Agent (browser + OS).
Source code in jafaal/ports.py
284 285 286 287 288 289 290 291 292 293 294 295 296 297 | |
NullAuthEventSink ¶
Default no-op sink — a host that skips these flows implements nothing.
Source code in jafaal/ports.py
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 | |
NullPasswordBreachChecker ¶
Default checker that treats every password as not breached (no-op).
Source code in jafaal/ports.py
509 510 511 512 513 | |
PasswordBreachChecker ¶
Bases: Protocol
Host-owned check for whether a password appears in a breach corpus/blocklist.
Consulted during sign-up and password change, after the length/complexity
policy passes and before the password is hashed. Return True to reject
the password. This is the NIST SP 800-63B-recommended companion to the
length_only policy — a common implementation is an HIBP k-anonymity range
query or a local blocklist.
It runs in the request path (synchronously), so keep it fast and fail open
(return False) on an upstream error, so a breach-service outage cannot
block all password changes.
Source code in jafaal/ports.py
491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 | |
PasswordPolicy
dataclass
¶
Minimum-length policy resolved by user tier, plus the policy type.
Source code in jafaal/ports.py
192 193 194 195 196 197 198 199 200 201 202 | |
min_length_for ¶
min_length_for(*, is_superuser)
Return the minimum length for an admin/superuser or regular account.
Source code in jafaal/ports.py
200 201 202 | |
PasswordResetRequested
dataclass
¶
A password reset was requested; deliver token to email.
Source code in jafaal/ports.py
237 238 239 240 241 242 243 244 245 246 | |
RefreshTokenTheftDetected
dataclass
¶
A rotated refresh token was replayed past the grace window (likely theft).
Emitted when reuse detection invalidates a token family, so the host can force a re-login notification / security alert for the affected user.
Source code in jafaal/ports.py
317 318 319 320 321 322 323 324 325 326 | |
ScopeResolver ¶
Bases: Protocol
Host-owned mapping from a user to the scopes their tokens carry.
JAFAAL's default (:class:TieredScopeResolver) is deliberately simple: two
tiers, keyed on is_superuser. That covers the common case and nothing
else — it cannot express "this user is a billing admin", per-organisation
roles, or any grant that is not a boolean on the user row. Authorisation
models are application domain, not authentication plumbing, so the mapping is
a port: implement it and JAFAAL stamps whatever scopes you return into the
tokens it mints.
The resolver runs at token issuance (login, refresh, SSO/PKCE exchange), and
again per request when
:attr:~jafaal.settings.AuthSettings.reauthorize_scopes_per_request is set —
where the result is intersected with the token's existing scopes, so
re-resolution can only ever narrow a live token's authority, never widen it.
Keep it fast and side-effect free; it is on the login path. It is called with the user alone — a resolver that needs more (roles from another table, say) should read them through its own session or cache rather than expect one to be passed in, since JAFAAL calls it from several transaction contexts.
Source code in jafaal/ports.py
438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 | |
scopes_for ¶
scopes_for(user)
Return the scopes to stamp into user's tokens.
Source code in jafaal/ports.py
462 463 464 | |
SettingsProvider ¶
Bases: Protocol
Host-owned dynamic settings (password policy + sign-up toggles).
Read-only configuration, independent of the caller's transaction, so these
methods take no session — a DB-backed adapter manages its own read (JAFAAL's
:func:jafaal.orm.session_scope is available) and a static adapter simply
returns constants.
Source code in jafaal/ports.py
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | |
get_password_policy ¶
get_password_policy()
Return the active password policy.
Source code in jafaal/ports.py
223 224 225 | |
get_signup_config ¶
get_signup_config()
Return the active sign-up configuration.
Source code in jafaal/ports.py
227 228 229 | |
SignupApproved
dataclass
¶
A pending sign-up was approved; notify the user.
Source code in jafaal/ports.py
274 275 276 277 278 279 280 281 | |
SignupConfig
dataclass
¶
Host sign-up toggles.
Source code in jafaal/ports.py
205 206 207 208 209 210 211 | |
SignupPendingAdminApproval
dataclass
¶
A newly verified sign-up is awaiting admin approval.
JAFAAL emits one event with the new user's context; the host fans out to whichever admins it wants, in whatever locale.
Source code in jafaal/ports.py
261 262 263 264 265 266 267 268 269 270 271 | |
TieredScopeResolver ¶
Default resolver: the :class:~jafaal.scopes.ScopeCatalog's two tiers.
Returns the catalog's admin tuple for a user whose is_superuser
attribute is truthy and regular otherwise. A model without that attribute
gets regular, so the two-tier default is a convenience rather than a
schema requirement — the host adds the column if it wants the split, or
installs its own resolver if its authorisation model is richer than a
boolean.
Source code in jafaal/ports.py
467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 | |
scopes_for ¶
scopes_for(user)
Return the catalog tier matching the user's superuser flag.
Source code in jafaal/ports.py
478 479 480 481 482 483 | |
UserProtocol ¶
Bases: Protocol
The user attributes JAFAAL reads across the boundary.
Satisfied by a host user model built on :class:jafaal.UserMixin: id,
username, email, is_active, is_verified, and the
mfa_enabled property. JAFAAL never reads app-specific profile fields.
is_superuser is deliberately not here. It is an authorisation
concept, and authorisation is the host's domain: it is read only by the
default :class:TieredScopeResolver, which is one swappable implementation
of the :class:ScopeResolver port. A host whose model has no such flag —
because it uses roles, organisations, or per-tenant grants — supplies its own
resolver and never needs the column. Requiring it on the protocol would have
made a two-tier authorisation model a condition of using the library.
Source code in jafaal/ports.py
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | |
UserRepository ¶
Bases: Protocol
Host-owned persistence for the user table.
Methods run inside the caller's transaction and therefore take the active
:class:~sqlalchemy.orm.Session. Implementations return objects satisfying
:class:UserProtocol (typically the host's Users ORM instance).
Source code in jafaal/ports.py
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | |
get_by_id ¶
get_by_id(user_id, db)
Return the user with user_id, or None.
Source code in jafaal/ports.py
117 118 119 | |
get_by_email ¶
get_by_email(email, db)
Return the user with email, or None.
Source code in jafaal/ports.py
121 122 123 | |
get_by_username ¶
get_by_username(username, db)
Return the user with username, or None.
Source code in jafaal/ports.py
125 126 127 | |
create_local_user ¶
create_local_user(
username, email, db, *, is_active, is_verified
)
Create a user row for a local sign-up and return it.
JAFAAL validates the password and persists the credential separately in
its own users_local_credentials table; the host only creates the
user/profile row here (with the given active/verified state and any
host-specific defaults). username/email are passed as supplied;
the host applies its own normalization and uniqueness checks.
Flush, do not commit. The primary key must be populated for the credential write that follows, but the two rows have to land in one transaction: committing here leaves a credential-less account squatting the username and email if anything downstream fails. JAFAAL commits both together when it writes the credential.
Source code in jafaal/ports.py
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | |
provision_from_idp ¶
provision_from_idp(identity, db)
Create a user row from an IdP identity and return it.
The account has no local password (JAFAAL persists no credential). Profile shape and defaults are the host's concern.
Source code in jafaal/ports.py
154 155 156 157 158 159 160 | |
sync_from_idp ¶
sync_from_idp(user_id, claims, db)
Optionally sync host-owned profile fields from refreshed IdP claims.
claims is the mapped IdP claim dict (e.g. email, name), plus
email_verified so the host can apply its own policy. Called on
subsequent logins when IdP→user sync is enabled; the host decides which
fields to update and resolves any email conflicts.
email is present only when the provider asserted it verified —
JAFAAL withholds an unverified address rather than hand the host
something it might write onto the user row, since the local email is
where password resets are delivered.
Source code in jafaal/ports.py
162 163 164 165 166 167 168 169 170 171 172 173 174 175 | |
set_email_verified ¶
set_email_verified(user_id, db, *, activate)
Mark the user's email address as verified.
When activate is True the account is also activated (used when
email verification is the last gate before login); when False the
account stays inactive (e.g. admin approval is still pending).
Source code in jafaal/ports.py
177 178 179 180 181 182 183 184 | |
NoOpRateLimiter ¶
Default limiter that enforces nothing (returns the endpoint unchanged).
Source code in jafaal/rate_limit.py
49 50 51 52 53 54 55 56 | |
RateLimiter ¶
Bases: Protocol
Maps a JAFAAL rate-limit category to an endpoint decorator.
The host implementation resolves the category ("sensitive" / "write")
to a concrete budget and returns the decorator its limiter uses (e.g.
slowapi's Limiter.limit("10/minute")).
Source code in jafaal/rate_limit.py
37 38 39 40 41 42 43 44 45 46 | |
AuthorizationRedirectResponse ¶
Bases: BaseModel
Where to send the browser once a local authorization request completes.
Returned by /auth/login (and the second-factor endpoints) when the
request carried an auth_request handle from /auth/authorize. The
login then produces an RFC 6749 §4.1.2 authorization response rather than
a token response: the URL carries a single-use code, the client's
state, and iss — and no token, which is the point of the code flow.
The host's login page navigates to it; JAFAAL does not redirect on its own, because the page is script and needs to clear its own state first.
Attributes:
| Name | Type | Description |
|---|---|---|
redirect_to |
StrictStr
|
The client's registered |
Source code in jafaal/schema.py
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | |
LogoutResponse ¶
Bases: BaseModel
Response payload returned by the logout endpoint.
Attributes:
| Name | Type | Description |
|---|---|---|
message |
StrictStr
|
Human-readable confirmation message. |
Source code in jafaal/schema.py
332 333 334 335 336 337 338 339 340 341 342 | |
MFALoginRequest ¶
Bases: BaseModel
Schema for MFA login requests.
Attributes:
| Name | Type | Description |
|---|---|---|
mfa_token |
StrictStr
|
The opaque, single-use ticket returned by |
mfa_code |
StrictStr
|
Either a 6-digit TOTP code or a backup code. |
Source code in jafaal/schema.py
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | |
MFARequiredResponse ¶
Bases: BaseModel
Response indicating MFA verification is required.
Attributes:
| Name | Type | Description |
|---|---|---|
mfa_required |
StrictBool
|
Indicates whether MFA is required. |
mfa_token |
StrictStr
|
Opaque, single-use ticket proving the password factor was
satisfied by this caller. Hold it in memory (never persist it) and
present it to |
username |
StrictStr
|
Username for which MFA is required, echoed back for display. It is not a credential and does not address the pending login. |
message |
StrictStr
|
Message describing the requirement. |
Source code in jafaal/schema.py
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | |
PasswordChangeRequest ¶
Bases: StepUpVerification
Self-service password change, on top of the step-up factors.
Attributes:
| Name | Type | Description |
|---|---|---|
new_password |
StrictStr
|
The replacement password, held to the account tier's policy and screened against the installed breach corpus. |
revoke_other_sessions |
bool
|
Whether to end the caller's other sessions. |
Source code in jafaal/schema.py
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | |
PasswordChangeResponse ¶
Bases: BaseModel
Result of a successful password change.
Source code in jafaal/schema.py
121 122 123 124 125 126 127 128 129 130 | |
SignUpRequest ¶
Bases: BaseModel
Minimal local sign-up request.
JAFAAL only needs credentials to create the account and its password; any
additional profile fields a host collects at sign-up are the host's own
concern (its UserRepository fills them). username/email are
passed to the host repository as supplied.
Source code in jafaal/schema.py
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | |
StepUpVerification ¶
Bases: BaseModel
Generic step-up verification payload.
Used by sensitive account-level operations (API-key creation, MFA
backup-code regeneration, IdP unlink, ...) to require fresh proof of
identity beyond a valid access token. Accounts with a local password must
supply current_password; when MFA is enabled an mfa_code is also
required. An SSO-only account with no MFA has no factor to verify, so these
operations are refused until MFA is enrolled — step-up fails closed rather
than passing on a valid access token alone.
Attributes:
| Name | Type | Description |
|---|---|---|
current_password |
StrictStr | None
|
Caller's existing password. Required when the account has a local password; may be omitted for SSO-only accounts (which must then satisfy step-up via MFA). |
mfa_code |
StrictStr | None
|
TOTP or backup code, required when MFA is enabled. |
Source code in jafaal/schema.py
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 | |
TokenIntrospectionResponse ¶
Bases: BaseModel
RFC 7662 token introspection response.
active is the only guaranteed field; the rest are populated only for an
active token. token_use and sid are JAFAAL extensions (§2.2 permits
them), reporting the token's own use and its session id.
The extension is named token_use rather than typ for the same reason
the payload claim is: §2.2 already defines token_type as the RFC 6749
§7.1 type (Bearer), and RFC 9068 uses typ for the JOSE header's
media type. A third spelling of "type" meaning a third thing is how a client
reads the wrong one.
Attributes:
| Name | Type | Description |
|---|---|---|
active |
StrictBool
|
Whether the token is currently valid. |
sub |
StrictStr | None
|
Subject (user) identifier. |
scope |
StrictStr | None
|
Space-delimited granted scopes. |
token_use |
StrictStr | None
|
JAFAAL token use ( |
token_type |
StrictStr | None
|
|
client_id |
StrictStr | None
|
OAuth client identifier the token was issued to. |
exp |
StrictInt | None
|
Expiry (epoch seconds). |
iat |
StrictInt | None
|
Issued-at (epoch seconds). |
nbf |
StrictInt | None
|
Not-before (epoch seconds). |
iss |
StrictStr | None
|
Issuer. |
aud |
StrictStr | None
|
Audience. |
jti |
StrictStr | None
|
Token identifier. |
sid |
StrictStr | None
|
Session identifier (JAFAAL extension). |
Source code in jafaal/schema.py
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 | |
TokenResponseMobile ¶
Bases: BaseModel
The RFC 6749 §5.1 token response, for token_delivery="body".
Attributes:
| Name | Type | Description |
|---|---|---|
session_id |
StrictStr
|
Session identifier (JAFAAL extension). |
access_token |
StrictStr
|
Bearer access token. |
refresh_token |
StrictStr
|
Refresh token. |
token_type |
Literal['Bearer']
|
Always |
expires_in |
StrictInt
|
Seconds until the access token expires. |
refresh_token_expires_in |
StrictInt
|
Seconds until the refresh token expires (JAFAAL extension). |
scope |
StrictStr | None
|
Space-delimited scopes the access token actually carries. |
Source code in jafaal/schema.py
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 | |
TokenResponseWeb ¶
Bases: BaseModel
Token response for a client registered with token_delivery="cookie".
The RFC 6749 §5.1 response minus refresh_token, which is delivered as an
HttpOnly, SameSite=Strict cookie instead (RFC 9700 §7.2: do not hand
a refresh token to page script). session_id, csrf_token and
refresh_token_expires_in are JAFAAL extensions, which §5.1 permits.
Attributes:
| Name | Type | Description |
|---|---|---|
session_id |
StrictStr
|
Session identifier. |
access_token |
StrictStr
|
Bearer access token. |
csrf_token |
StrictStr
|
CSRF token bound to the session. |
token_type |
Literal['Bearer']
|
Always |
expires_in |
StrictInt
|
Seconds until the access token expires. |
refresh_token_expires_in |
StrictInt
|
Seconds until the refresh token expires. |
scope |
StrictStr | None
|
Space-delimited scopes the access token actually carries. |
Source code in jafaal/schema.py
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | |
ScopeCatalog
dataclass
¶
Scopes a token carries per access tier, plus Swagger descriptions.
Attributes:
| Name | Type | Description |
|---|---|---|
regular |
tuple[str, ...]
|
Scopes stamped into a non-superuser's token. |
admin |
tuple[str, ...]
|
Scopes stamped into a superuser's token (a superset of
|
descriptions |
Mapping[str, str]
|
Scope -> human description, shown in the Swagger
|
Source code in jafaal/scopes.py
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | |
validate ¶
validate()
Assert the catalog is internally consistent.
Every scope advertised in descriptions must be one the server
actually mints, and every minted scope must be advertised — a drift
means either the UI offers a scope that is never enforced or a token
carries an undocumented scope. regular must also be a subset of
admin.
Raises:
| Type | Description |
|---|---|
ValueError
|
If the catalog is inconsistent. |
Source code in jafaal/scopes.py
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | |
extend ¶
extend(*, regular=(), admin=(), descriptions=None)
Return a new catalog with the host's application scopes added on top.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
regular
|
tuple[str, ...]
|
Extra scopes for the regular (and, implicitly, admin) tier. |
()
|
admin
|
tuple[str, ...]
|
Extra scopes for the admin tier (include the |
()
|
descriptions
|
Mapping[str, str] | None
|
Descriptions for the added scopes. |
None
|
Returns:
| Type | Description |
|---|---|
ScopeCatalog
|
A new :class: |
Source code in jafaal/scopes.py
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 | |
ApiKeySettings
dataclass
¶
API-key format and transport policy.
Attributes:
| Name | Type | Description |
|---|---|---|
prefix |
str
|
Prefix for generated API keys ( |
allow_query_param |
bool
|
Whether API keys may be supplied via the
|
Source code in jafaal/settings.py
605 606 607 608 609 610 611 612 613 614 615 616 617 618 | |
AuditSettings
dataclass
¶
Privacy policy for the jafaal.audit stream.
Attributes:
| Name | Type | Description |
|---|---|---|
include_pii |
bool
|
When |
Source code in jafaal/settings.py
626 627 628 629 630 631 632 633 634 635 636 637 | |
AuthSettings
dataclass
¶
Immutable, host-supplied configuration for the auth library.
Only values describing the deployment as a whole live here; everything else is grouped by concern (see the module docstring).
Attributes:
| Name | Type | Description |
|---|---|---|
secrets |
Secrets
|
Key material and its rotation fallbacks. The one required group. |
base_url |
str
|
Public base URL of the host app. Used to build SSO redirect and error URLs, and as the default JWT issuer/audience, WebAuthn RP ID and origin, and CSRF trusted origin. |
app_name |
str
|
Human-readable application name; used as the MFA TOTP issuer shown in authenticator apps and as the default WebAuthn RP name. |
environment |
str
|
Deployment environment. Must be one of
:data: |
store_key_prefix |
str
|
Namespace prefix for state-store keys (lockout counters, MFA setup secrets, WebAuthn challenges, ...). |
login_token_url |
str
|
URL FastAPI's Swagger Authorize dialog posts the
username/password form to. Cosmetic — it configures the
|
login_ui_url |
str
|
Absolute URL of the host's login page, used by
|
login_ip_lockout_enabled |
bool
|
When |
allow_in_memory_state_store_when_deployed |
bool
|
Permit the process-local
in-memory :class: |
allow_no_rate_limit_when_deployed |
bool
|
Permit a deployed environment to run with the no-op rate limiter. Off by default; mirror of the above. |
tokens |
TokenSettings
|
JWT issuance and revocation policy. |
sessions |
SessionSettings
|
Session lifetime and refresh-cookie delivery. |
passwords |
PasswordSettings
|
Argon2 cost and length bounds. |
mfa |
MfaSettings
|
TOTP replay policy. |
webauthn |
WebAuthnSettings
|
Passkey Relying-Party identity and ceremony policy. |
sso |
SsoSettings
|
Identity-provider flows and step-up. |
network |
NetworkSettings
|
Proxy trust and SSRF policy. |
rate_limits |
RateLimitSettings
|
Canonical request budgets. |
api_keys |
ApiKeySettings
|
API-key format and transport policy. |
audit |
AuditSettings
|
Audit-stream privacy policy. |
Source code in jafaal/settings.py
792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 | |
is_deployed
property
¶
is_deployed
Whether the environment is a deployed one.
True for every name in :data:DEPLOYED_ENVIRONMENTS. The value is
validated at construction, so an unrecognised environment can never
silently fall through to False.
resolved_audience
property
¶
resolved_audience
JWT audience, falling back to :attr:base_url when unset.
resolved_client_id
property
¶
resolved_client_id
client_id claim value, falling back to the resolved audience.
RFC 9068 §2.2 requires client_id on an access token. JAFAAL issues
first-party tokens and has no client registry, so the audience (i.e. the
application the token is for) is the meaningful identifier unless the
host sets one explicitly.
resolved_csrf_trusted_origins
property
¶
resolved_csrf_trusted_origins
Origins permitted to drive the cookie-issuing web flows.
Returns the explicit :attr:SessionSettings.csrf_trusted_origins when
set, otherwise the origin of :attr:base_url. Empty when neither is
available, in which case the Origin comparison is skipped and only
the browser's Sec-Fetch-Site signal is enforced.
effective_refresh_cookie_name
property
¶
effective_refresh_cookie_name
Refresh-cookie name including any __Secure- / __Host- prefix.
The prefix is applied only in a deployed environment, where the cookie
is served with Secure — browsers reject __Secure- / __Host-
cookies that arrive without it, which would otherwise break local http
development. Reads and writes of the refresh cookie must go through this
name so the set/clear/read sides stay in lockstep.
resolved_webauthn_rp_id
property
¶
resolved_webauthn_rp_id
WebAuthn Relying Party ID, falling back to the base_url host.
Empty when neither is available — the WebAuthn endpoints treat that as a misconfiguration and fail fast.
resolved_webauthn_rp_name
property
¶
resolved_webauthn_rp_name
WebAuthn Relying Party display name, falling back to :attr:app_name.
resolved_webauthn_origins
property
¶
resolved_webauthn_origins
Expected WebAuthn origins, falling back to the origin of base_url.
oauth_client ¶
oauth_client(client_id)
Return the registered client with client_id, or None.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client_id
|
str
|
The identifier presented by the caller. |
required |
Returns:
| Type | Description |
|---|---|
OAuthClient | None
|
The matching :class: |
Source code in jafaal/settings.py
932 933 934 935 936 937 938 939 940 941 942 943 944 | |
__repr__ ¶
__repr__()
Render the settings, delegating secret redaction to each group's repr.
Source code in jafaal/settings.py
946 947 948 | |
MfaSettings
dataclass
¶
TOTP replay-protection policy.
Attributes:
| Name | Type | Description |
|---|---|---|
totp_replay_fail_open |
bool
|
TOTP single-use replay protection is
defense-in-depth on top of the (unchanged) TOTP signature check, but
it needs the shared state store. When |
Source code in jafaal/settings.py
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 | |
NetworkSettings
dataclass
¶
Proxy trust, SSRF policy, and the outbound user agent.
Attributes:
| Name | Type | Description |
|---|---|---|
trusted_proxies |
tuple[str, ...]
|
Peers and forwarding hops whose |
ssrf_allowed_hosts |
tuple[str, ...]
|
Hosts/CIDRs exempted from the SSRF private-address guard on outbound OIDC calls. |
user_agent |
str
|
|
Source code in jafaal/settings.py
556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 | |
OAuthClient
dataclass
¶
A registered public client, and the policy that applies to its tokens.
The client is the unit of policy. Delivery mode and scope ceiling are
properties of the registration, never of the request: a value a caller can
choose per-request is a value an attacker can choose, and JAFAAL previously
carried both in a header (X-Client-Type) that had to be defended with
mismatch detection. Configuration removes the attack surface instead of
guarding it.
Registration also supplies the one thing RFC 9700 §4.1 makes mandatory and
that is impossible without it: exact redirect_uri matching. Without a
registered list there is nothing to match against, and an authorization code
can be steered to an attacker-controlled target.
Clients are public (RFC 8252): a native app or browser cannot keep a secret, so PKCE — not client authentication — binds a code to its requester.
Attributes:
| Name | Type | Description |
|---|---|---|
client_id |
str
|
The identifier the client sends as |
redirect_uris |
tuple[str, ...]
|
Every URI the client may receive an authorization code
at, matched exactly (byte-for-byte, per RFC 9700 §4.1.3 — no
prefix, wildcard, or path-suffix matching). A plain- |
token_delivery |
str
|
|
scopes |
tuple[str, ...]
|
Ceiling on what this client's tokens may carry, intersected with
what the host's :class: |
name |
str
|
Human-readable label, used in logs and audit records. |
Source code in jafaal/settings.py
660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 | |
uses_cookie_delivery
property
¶
uses_cookie_delivery
Whether this client's refresh token is delivered as a cookie.
The single predicate every delivery decision branches on — cookie write,
CSRF binding, off-site rejection, and the 202 MFA status — so those
four cannot drift apart.
permits ¶
permits(redirect_uri)
Return whether redirect_uri is registered for this client.
Compared in constant time and byte-for-byte. RFC 9700 §4.1.3 requires exact matching precisely because every relaxation (prefix, wildcard, sub-path) has been used to exfiltrate authorization codes.
Source code in jafaal/settings.py
719 720 721 722 723 724 725 726 727 728 729 | |
narrow ¶
narrow(granted)
Intersect granted with this client's ceiling.
Strictly narrowing: a client can only ever receive less than the user holds, never more. An empty ceiling means no narrowing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
granted
|
tuple[str, ...]
|
The scopes the user is entitled to. |
required |
Returns:
| Type | Description |
|---|---|
tuple[str, ...]
|
The scopes this client's token may carry. |
Source code in jafaal/settings.py
741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 | |
PasswordSettings
dataclass
¶
Argon2 cost parameters and the accepted password length bound.
Attributes:
| Name | Type | Description |
|---|---|---|
argon2_time_cost |
int
|
Argon2 time cost (iterations). |
argon2_memory_cost |
int
|
Argon2 memory cost, in KiB. |
argon2_parallelism |
int
|
Argon2 parallelism (lanes). |
max_length |
int
|
Maximum accepted password length, enforced before hashing so an unauthenticated caller cannot force unbounded Argon2 work. Must be at least 64 so long passphrases are accepted (NIST SP 800-63B). Passwords are never truncated. |
Source code in jafaal/settings.py
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 | |
RateLimitSettings
dataclass
¶
Canonical request budgets for the host's :class:~jafaal.rate_limit.RateLimiter.
Attributes:
| Name | Type | Description |
|---|---|---|
sensitive |
str
|
Budget for sensitive endpoints (login, MFA, password reset, sign-up, OAuth, API-key minting). |
write |
str
|
Budget for write endpoints (logout, refresh, session and API-key revocation, introspection). |
Source code in jafaal/settings.py
585 586 587 588 589 590 591 592 593 594 595 596 597 | |
Secrets
dataclass
¶
Key material, and the rotation fallbacks that keep it rotatable.
Attributes:
| Name | Type | Description |
|---|---|---|
secret_key |
str
|
HMAC key. Signs and verifies HS256 JWTs, and is stretched
(HKDF, per purpose) into the keys that MAC every stored token digest
— refresh tokens, CSRF tokens, API keys, reset/sign-up/link tokens,
the WebAuthn user handle. Required regardless of |
fernet_key |
str
|
Fernet key used to encrypt at-rest tokens (IdP client
secrets, MFA secrets, rotated refresh tokens). A url-safe base64
32-byte key as produced by |
secret_key_fallbacks |
tuple[str, ...]
|
Additional HMAC keys accepted when verifying
(never used to sign or to write a new digest), so credentials issued
before a |
fernet_key_fallbacks |
tuple[str, ...]
|
Additional Fernet keys accepted when decrypting
(never used to encrypt), enabling |
private_key |
str
|
PEM private key used to sign JWTs when
:attr: |
private_key_fallbacks |
tuple[str, ...]
|
Verify-only keys (PEM, public or private) kept in the published JWKS during a signing-key rotation overlap. |
Source code in jafaal/settings.py
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 | |
__repr__ ¶
__repr__()
Render with every key-bearing field as <redacted>.
Source code in jafaal/settings.py
191 192 193 | |
SessionSettings
dataclass
¶
Session lifetime, revocation strictness, and refresh-cookie delivery.
Attributes:
| Name | Type | Description |
|---|---|---|
idle_timeout_enabled |
bool
|
Whether idle-session expiry is enforced. The absolute lifetime below is always enforced and does not depend on this flag. |
idle_timeout_hours |
int
|
Idle-session timeout, in hours. |
absolute_timeout_hours |
int
|
Hard ceiling on how long a session may live,
measured from |
strict_binding |
bool
|
When |
refresh_cookie_name |
str
|
Name of the refresh-token cookie. |
refresh_cookie_path |
str
|
Path scope of the refresh-token cookie. Must line up with where the auth router is mounted, or web sessions silently fail to refresh. |
refresh_cookie_prefix |
str
|
Optional cookie-name-prefix hardening, applied
only in a deployed environment (browsers reject these prefixes on
a cookie that arrives without |
csrf_trusted_origins |
tuple[str, ...]
|
Origins allowed to drive the web refresh flow and
the cookie-issuing login endpoints. Defaults to the origin of
|
Source code in jafaal/settings.py
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 | |
SsoSettings
dataclass
¶
Identity-provider flows: transport policy and step-up.
Notably absent is anything describing where to send the browser after a
flow. Post-login, post-link and post-step-up targets all come from the
initiating request's redirect_uri, matched exactly against the calling
:class:OAuthClient's registration. A configured frontend path would be a
second, weaker redirect rule sitting beside the exact-match one — and the
weaker rule is the one an attacker would use.
Attributes:
| Name | Type | Description |
|---|---|---|
idp_require_https |
bool
|
When |
step_up_idp_reauth_enabled |
bool
|
Whether an SSO-only account (no local
password and no MFA) may satisfy step-up verification by
re-authenticating at a linked identity provider. When |
step_up_reauth_max_age_seconds |
int
|
Maximum age of the IdP authentication
(the ID token |
step_up_grant_ttl_seconds |
int
|
Lifetime of the single-use step-up grant minted after a successful IdP re-authentication; the caller must retry the sensitive operation within this window. |
id_token_leeway_seconds |
int
|
Clock-skew tolerance applied to an IdP ID
token's |
max_response_bytes |
int
|
Largest response body accepted from an identity provider (discovery, JWKS, userinfo). Timeouts bound how long JAFAAL waits, not how much it accepts, and the JWKS is cached — so without a cap one hostile response is a persistent memory cost. |
Source code in jafaal/settings.py
494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 | |
TokenSettings
dataclass
¶
JWT issuance, lifetimes, and the opt-in immediate-revocation controls.
Attributes:
| Name | Type | Description |
|---|---|---|
algorithm |
str
|
JWT signing algorithm. |
access_token_expire_minutes |
int
|
Access-token lifetime, in minutes. |
refresh_token_expire_days |
int
|
Refresh-token lifetime, in days. |
issuer |
str
|
JWT |
audience |
str
|
JWT |
client_id |
str
|
Value of the |
leeway_seconds |
int
|
Clock-skew tolerance applied to the |
denylist_enabled |
bool
|
When |
reauthorize_scopes_per_request |
bool
|
When |
Source code in jafaal/settings.py
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 | |
is_asymmetric
property
¶
is_asymmetric
Whether :attr:algorithm signs with a private key rather than a shared secret.
WebAuthnSettings
dataclass
¶
Relying-Party identity and ceremony policy for passkeys.
Attributes:
| Name | Type | Description |
|---|---|---|
rp_id |
str
|
Relying Party ID — the registrable domain passkeys are scoped to
(e.g. |
rp_name |
str
|
Human-readable Relying Party name shown by the authenticator.
Defaults to |
origins |
tuple[str, ...]
|
Exact origins (scheme + host + port) a ceremony may complete
from. Defaults to the origin of |
user_verification |
str
|
User-verification requirement for the second-factor
ceremony: |
attestation |
str
|
Attestation conveyance requested at registration. |
second_factor_enabled |
bool
|
When |
passkey_login_satisfies_mfa |
bool
|
Whether a passwordless passkey login
completes on its own for an account that also has TOTP enrolled.
|
challenge_ttl_seconds |
int
|
Lifetime of a challenge held in the state store before it must be redeemed. Kept short (a ceremony is interactive and immediate) to bound replay of a leaked challenge. |
Source code in jafaal/settings.py
426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 | |
InMemoryStateStore ¶
Process-local :class:StateStore backed by a dict with per-key TTL expiry.
Correct for a single process only — it is not shared across workers or replicas, so a multi-worker/replica deployment must configure a distributed backend instead. Access is guarded by a lock because FastAPI runs sync handlers in a threadpool.
Source code in jafaal/state_store.py
146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 | |
StateStore ¶
Bases: Protocol
Ephemeral keyed state (counters, TTL flags, small blobs).
The single seam through which the auth/MFA stores read and write short-lived shared state, so a store never needs to know whether it is backed by a process-local dict or Redis.
Source code in jafaal/state_store.py
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | |
set_if_absent ¶
set_if_absent(key, value, ttl_seconds)
Atomically create key only if it does not already exist.
The single-writer primitive behind "claim this exactly once" semantics
(single-use TOTP timesteps, and any future one-shot marker). It must be
atomic in the backend: a check-then-set pair in calling code lets two
concurrent requests both observe "absent" and both proceed, which is
precisely the race this primitive exists to remove.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
str
|
The key to claim. |
required |
value
|
bytes
|
The value to store when the claim succeeds. |
required |
ttl_seconds
|
int
|
Lifetime of the claim; it expires automatically so the key space stays bounded. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True when this call created the key (the caller owns the claim), |
bool
|
False when it already existed (someone else claimed it first). |
Source code in jafaal/state_store.py
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | |
increment ¶
increment(key, ttl_seconds)
Atomically add 1 to the counter at key and return the new value.
The counter carries ttl_seconds TTL, so it is self-expiring — used by
the reference rate limiter for fixed-window request counting.
Source code in jafaal/state_store.py
127 128 129 130 131 132 133 | |
StateStoreUnavailableError ¶
Bases: StoreUnavailableError
Raised by a :class:StateStore when its backing store is unreachable.
Lets the auth/MFA stores react to an infrastructure outage (surface a 503,
or swallow a best-effort cleanup) without importing anything about the
concrete backend. :class:InMemoryStateStore never raises it.
Source code in jafaal/state_store.py
31 32 33 34 35 36 37 | |
TieredFailureOutcome
dataclass
¶
Result of an atomic tiered-lockout increment.
Attributes:
| Name | Type | Description |
|---|---|---|
count |
int
|
The failure counter value after this attempt. |
locked_until_epoch |
int | None
|
Wall-clock epoch (seconds) the lock is active until,
or |
newly_locked |
bool
|
True only when this call created (or renewed) the lock. |
Source code in jafaal/state_store.py
71 72 73 74 75 76 77 78 79 80 81 82 83 84 | |
IntPKUserMixin ¶
Bases: UserMixin
User columns with an auto-incrementing integer primary key.
Use for applications that prefer compact, sequential identifiers.
Source code in jafaal/user_model.py
210 211 212 213 214 215 216 217 218 219 | |
UserMixin ¶
Auth-relevant user columns, excluding the primary key.
This mixin is not mapped on its own. Combine it with a concrete
primary-key mixin (:class:IntPKUserMixin or :class:UUIDPKUserMixin)
and the application's declarative Base to produce a mapped user
model. Host applications add their own profile columns and relationships
on the concrete subclass.
Source code in jafaal/user_model.py
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | |
UUIDPKUserMixin ¶
Bases: UserMixin
User columns with a UUID primary key.
The identifier defaults to a random UUID4 generated application-side, so it is available before the row is flushed and does not leak account counts or creation order.
Source code in jafaal/user_model.py
222 223 224 225 226 227 228 229 230 231 232 233 234 | |
AuthContext
dataclass
¶
Unified authentication context.
Carries the resolved user identity and scopes regardless of whether authentication was via JWT or API key.
Attributes:
| Name | Type | Description |
|---|---|---|
user_id |
UserId
|
Authenticated user's ID. |
scopes |
list[str]
|
List of granted scope strings. |
auth_type |
str
|
Source of authentication
( |
Source code in jafaal/_internal/internal_dependencies.py
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | |
get_password_hasher ¶
get_password_hasher()
Return the process-wide password hasher.
When JAFAAL is configured, the hasher is built from the Argon2 cost
parameters in :class:~jafaal.settings.AuthSettings (argon2_time_cost /
argon2_memory_cost / argon2_parallelism), cached, and transparently
rebuilt if :func:jafaal.configure is called again (mirroring
get_token_manager). Before configuration it falls back to the
default-cost singleton, so isolated password hashing/verification works
without installing settings. Argon2 hashes are self-describing, so a hash
produced at one cost still verifies (and is transparently upgraded via
verify_and_update) at another.
Returns:
| Name | Type | Description |
|---|---|---|
PasswordHasher |
PasswordHasher
|
The active password hasher. |
Source code in jafaal/_internal/password_hasher.py
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 | |
cleanup_expired_pending_mfa_logins ¶
cleanup_expired_pending_mfa_logins()
Evict all expired pending MFA login entries (no-op; TTL-managed).
Returns the number of entries evicted (always 0 — the backend expires
pending entries by TTL).
Source code in jafaal/_internal/security_stores.py
822 823 824 825 826 827 828 | |
clear_pending_mfa_for_user ¶
clear_pending_mfa_for_user(user_id)
Remove pending MFA login entries for a user across credential changes.
Called from password-change paths so that an attacker who already submitted the now-rotated password and is sitting at the pending-MFA step cannot still complete the login. Storage outages are logged and swallowed because the password rotation itself must remain successful; pending entries expire naturally after their 5-minute TTL.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
UserId
|
User ID whose pending MFA entries should be removed. |
required |
Returns:
| Type | Description |
|---|---|
int
|
Number of pending MFA entries removed (zero on storage outage). |
Source code in jafaal/_internal/security_stores.py
831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 | |
get_failed_login_attempts ¶
get_failed_login_attempts()
Dependency injection for failed-login attempt storage.
Source code in jafaal/_internal/security_stores.py
807 808 809 | |
get_pending_mfa_store ¶
get_pending_mfa_store()
Dependency injection for pending MFA storage.
Source code in jafaal/_internal/security_stores.py
812 813 814 | |
get_step_up_attempts ¶
get_step_up_attempts()
Dependency injection for step-up attempt tracking.
Source code in jafaal/_internal/security_stores.py
817 818 819 | |
get_token_manager ¶
get_token_manager()
Return a process-wide :class:TokenManager built from settings.
The instance is cached and transparently rebuilt if :func:jafaal.configure
is called again (detected via the settings generation counter).
Returns:
| Name | Type | Description |
|---|---|---|
TokenManager |
TokenManager
|
Token manager bound to the installed |
Source code in jafaal/_internal/token_manager.py
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 | |
jafaal_exception_handler
async
¶
jafaal_exception_handler(request, exc)
Translate a :class:JafaalError into a JSON HTTP response.
Emits JAFAAL's {"detail", "code"} shape, or the RFC 6749 §5.2
{"error", "error_description"} shape for an
:class:~jafaal.exceptions.OAuthError (see :func:_body), with the error's
status code and header hints (e.g. WWW-Authenticate, Retry-After).
When exc.clear_refresh_cookie is set (stale refresh token) the
refresh-cookie deletion headers are added to the response.
Token-endpoint errors also carry Cache-Control: no-store per RFC 6749
§5.1, so an intermediary never caches an authorization failure.
Source code in jafaal/error_handler.py
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | |
register_exception_handlers ¶
register_exception_handlers(app)
Install JAFAAL's edge exception handler on the host FastAPI app.
Call once at startup, before serving requests. Safe to install up-front: it
is a no-op until the core raises a :class:JafaalError.
Source code in jafaal/error_handler.py
68 69 70 71 72 73 74 75 76 77 | |
create_auth_router ¶
create_auth_router(
*,
app=None,
rate_limiter=None,
prefixes=None,
verify=True,
)
Build the aggregated JAFAAL auth router.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
app
|
FastAPI | None
|
When provided, the :class: |
None
|
rate_limiter
|
RateLimiter | None
|
The host's rate limiter. Installed before the sub-routers are imported so their decorators resolve it. Defaults to the no-op limiter already in effect (no enforcement). |
None
|
prefixes
|
RouterPrefixes | None
|
Override the default sub-prefixes (keep them in lockstep with
:class: |
None
|
verify
|
bool
|
Run :func: |
True
|
Returns:
| Type | Description |
|---|---|
APIRouter
|
An app.include_router(create_auth_router(app=app), prefix="/api/v1") |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
Source code in jafaal/factory.py
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 | |
shutdown
async
¶
shutdown(*, drain_events_timeout=5.0)
Release JAFAAL's process-wide resources. Call from the ASGI lifespan.
JAFAAL holds three things that outlive a request and are not garbage: the
pooled HTTP client used for outbound OIDC calls (keep-alive sockets to every
configured identity provider), the optional background maintenance thread,
and any in-flight :class:~jafaal.ports.AuthEventSink deliveries. Without an
explicit shutdown those leak on reload, keep a process alive longer than it
should, and drop notifications that were mid-flight::
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
jafaal.maintenance.start_background_scheduler()
yield
await jafaal.shutdown()
Never raises: shutdown must not be able to fail a clean stop.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
drain_events_timeout
|
float
|
Seconds to wait for pending event deliveries. |
5.0
|
Source code in jafaal/factory.py
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 | |
verify_configuration ¶
verify_configuration()
Assert that every required host-supplied component is installed.
JAFAAL resolves several host adapters lazily, so a missing one otherwise
surfaces as a RuntimeError on the first request that needs it. Call this
once at startup (e.g. in a FastAPI lifespan handler) to fail fast with a
single, clear message listing everything that is missing.
Checks the components JAFAAL cannot default: the ORM model mapping
(:func:jafaal.map_models), the settings object, the session factory, the
user repository, and the settings provider. The event sink, state store,
rate limiter, and scope catalog all have working defaults and so are not
required here. Also enforces
:func:_ensure_state_store_safe_for_deployment.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If any required component is missing (the message enumerates all of them), or if the in-memory state store is used in a deployed environment without the opt-out. |
Source code in jafaal/factory.py
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | |
get_jwks ¶
get_jwks()
Return the JWK Set of public keys that verify JAFAAL's JWTs.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A JWK Set dict ( |
dict[str, Any]
|
callers serving this over HTTP should check |
dict[str, Any]
|
attr: |
dict[str, Any]
|
packaged route does. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If JAFAAL has not been configured yet. |
Source code in jafaal/jwks.py
38 39 40 41 42 43 44 45 46 47 48 49 50 | |
create_metadata_router ¶
create_metadata_router(
auth_prefix="/auth", *, path=METADATA_PATH
)
Build the router serving the RFC 8414 discovery document.
The core auth prefix is injected rather than imported because it is a
deployment choice owned by :class:jafaal.RouterPrefixes; the advertised
endpoint URLs must follow wherever the host actually mounted the router.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
auth_prefix
|
str
|
Prefix the core auth router is mounted under. |
'/auth'
|
path
|
str
|
Route path to expose the document at. Defaults to the aggregate
root; :func: |
METADATA_PATH
|
Returns:
| Name | Type | Description |
|---|---|---|
An |
APIRouter
|
class: |
Source code in jafaal/metadata.py
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 | |
get_authorization_server_metadata ¶
get_authorization_server_metadata(
*, api_root, auth_prefix="/auth"
)
Build the RFC 8414 metadata document for this deployment.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
api_root
|
str
|
Absolute URL the aggregate auth router is mounted at, e.g.
|
required |
auth_prefix
|
str
|
Prefix the core auth router is mounted under, i.e.
:attr: |
'/auth'
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The metadata document, ready to be serialised as JSON. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If JAFAAL has not been configured yet. |
Source code in jafaal/metadata.py
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | |
issuer_derived_metadata_path ¶
issuer_derived_metadata_path()
Return the RFC 8414 §3 metadata path for the configured issuer.
§3 forms the URL by inserting /.well-known/oauth-authorization-server
between the issuer's host and its path component — so an issuer of
https://app.example/api/v1 publishes at
/.well-known/oauth-authorization-server/api/v1. The naive
<issuer>/.well-known/... is not the spec location whenever the issuer
carries a path, which it does for every deployment mounted under an API
prefix.
Returns:
| Type | Description |
|---|---|
str
|
The absolute path to register on the host application. |
Source code in jafaal/metadata.py
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | |
autonomous_session ¶
autonomous_session()
A separate session that commits independently of the caller's transaction.
The deliberate exception to "the caller owns the transaction", for writes whose durability must not depend on the surrounding request succeeding. There is exactly one such case in JAFAAL today: claiming a single-use OAuth state. Replay protection has to stick — if the claim were rolled back when the callback later fails, an attacker could deliberately fail the flow to release the state and replay the authorization code.
Committing it separately also keeps the caller's transaction (and its pooled connection) from being held open across the several outbound HTTP calls the SSO callback then makes.
Yields:
| Type | Description |
|---|---|
Generator[Session]
|
A fresh session, committed on clean exit and rolled back on failure. |
Source code in jafaal/orm.py
414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 | |
configure_sessionmaker ¶
configure_sessionmaker(factory)
Install the host's session factory.
Call once at startup with a sessionmaker bound to the application's
engine. Both the :func:get_db request dependency and background
maintenance tasks obtain sessions from it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
factory
|
sessionmaker[Session]
|
A configured |
required |
Source code in jafaal/orm.py
250 251 252 253 254 255 256 257 258 259 260 | |
get_active_base ¶
get_active_base()
Return the declarative base JAFAAL's models are mapped onto.
Model modules call this at import time to obtain their base, so importing a
model module (or any CRUD/router that imports one) before :func:map_models
is a configuration error.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If :func: |
Source code in jafaal/orm.py
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | |
is_models_mapped ¶
is_models_mapped()
Return whether :func:map_models has been called.
Source code in jafaal/orm.py
186 187 188 | |
map_models ¶
map_models(base=None, *, user_model=None)
Define and map JAFAAL's companion tables into base's registry.
Call once at startup, after defining your user model and before
:func:jafaal.create_auth_router or any database use. JAFAAL's models are
mapped onto the base you pass, so your user model and JAFAAL's tables share
one registry — which is what resolves the users.id foreign keys.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base
|
type[DeclarativeBase] | None
|
The host's :class: |
None
|
user_model
|
type | None
|
The host's user class. Passing it explicitly is what lets the
class be called anything — |
None
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If called again with a different base or user model, or if
a model references a class that is not mapped on |
Source code in jafaal/orm.py
191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | |
savepoint ¶
savepoint(db)
Run a block inside a SAVEPOINT so a failed flush stays recoverable.
A statement that fails mid-transaction (a UNIQUE violation on flush, say) leaves SQLAlchemy's transaction in a pending-rollback state: every later statement on that session raises until someone unwinds it. Because JAFAAL runs inside the caller's transaction it must not unwind the whole thing — that would discard the host's pending work too — so a CRUD helper that wants to catch a constraint violation and translate it (e.g. into a 409) brackets the flush in a savepoint and rolls back only that.
Delegates to Session.begin_nested() used as a context manager, which is
the only form that works: unwinding the savepoint by hand
(nested.rollback() in an except) leaves the parent transaction
holding the captured flush exception, so the caller's later commit()
still raises PendingRollbackError. SQLAlchemy's own __exit__ clears
it. tests/test_transactions.py pins this behaviour.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db
|
Session
|
The active session. |
required |
Yields:
| Type | Description |
|---|---|
Generator[Session]
|
The same session, for convenience. |
Source code in jafaal/orm.py
440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 | |
session_scope ¶
session_scope()
Context manager yielding a session for non-request (background) work.
Used by the maintenance tasks. The caller is responsible for committing
(typically via :func:unit_of_work); the session is closed on exit, rolling
back any pending transaction.
Source code in jafaal/orm.py
469 470 471 472 473 474 475 476 477 478 479 480 481 | |
unit_of_work ¶
unit_of_work(db)
Commit db on clean exit, roll it back on failure.
The supported way for a host to compose JAFAAL calls with its own writes::
with jafaal.unit_of_work(db):
user = repo.create_local_user(...)
identity_service.set_local_password_hash(user.id, hashed)
db.add(MyProfile(user_id=user.id))
# one commit; any failure rolls back all three
Re-entrant: when a unit of work is already open on this session, the inner block joins it and the outermost scope decides the outcome. That is what makes "JAFAAL never commits under you" hold even when a JAFAAL service calls another internally.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db
|
Session
|
The session to own for the duration of the block. |
required |
Yields:
| Type | Description |
|---|---|
Generator[Session]
|
The same session, for convenience. |
Raises:
| Type | Description |
|---|---|
Exception
|
Whatever the wrapped block raised, after rolling back. |
Source code in jafaal/orm.py
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 | |
configure_event_sink ¶
configure_event_sink(sink)
Install the host's :class:AuthEventSink (defaults to a no-op sink).
Source code in jafaal/ports.py
574 575 576 | |
configure_password_breach_checker ¶
configure_password_breach_checker(checker)
Install the host's :class:PasswordBreachChecker (defaults to a no-op).
Source code in jafaal/ports.py
584 585 586 | |
configure_scope_resolver ¶
configure_scope_resolver(resolver)
Install the host's :class:ScopeResolver.
Call once at startup, before tokens are issued. Defaults to
:class:TieredScopeResolver (the is_superuser two-tier mapping).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
resolver
|
ScopeResolver
|
The host's scope-resolution adapter. |
required |
Source code in jafaal/ports.py
594 595 596 597 598 599 600 601 602 603 | |
configure_settings_provider ¶
configure_settings_provider(provider)
Install the host's :class:SettingsProvider. Call once at startup.
Source code in jafaal/ports.py
555 556 557 | |
configure_user_repository ¶
configure_user_repository(repository)
Install the host's :class:UserRepository. Call once at startup.
Source code in jafaal/ports.py
536 537 538 | |
get_event_sink ¶
get_event_sink()
Return the installed :class:AuthEventSink (NullAuthEventSink by default).
Source code in jafaal/ports.py
579 580 581 | |
get_password_breach_checker ¶
get_password_breach_checker()
Return the installed :class:PasswordBreachChecker (no-op by default).
Source code in jafaal/ports.py
589 590 591 | |
get_scope_resolver ¶
get_scope_resolver()
Return the installed :class:ScopeResolver (:class:TieredScopeResolver by default).
Source code in jafaal/ports.py
606 607 608 | |
get_settings_provider ¶
get_settings_provider()
Return the installed :class:SettingsProvider.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If none has been configured. |
Source code in jafaal/ports.py
560 561 562 563 564 565 566 | |
get_user_repository ¶
get_user_repository()
Return the installed :class:UserRepository.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If none has been configured. |
Source code in jafaal/ports.py
541 542 543 544 545 546 547 | |
reset_ports ¶
reset_ports()
Clear all installed adapters. Intended for test isolation.
Source code in jafaal/ports.py
831 832 833 834 835 836 837 | |
configure_rate_limiter ¶
configure_rate_limiter(limiter)
Install the host-provided rate limiter.
Call this before the routers are imported (create_auth_router() does so
automatically) so the endpoint decorators resolve the configured limiter.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
limiter
|
RateLimiter
|
A :class: |
required |
Source code in jafaal/rate_limit.py
62 63 64 65 66 67 68 69 70 71 | |
get_rate_limiter ¶
get_rate_limiter()
Return the configured rate limiter (the no-op default until configured).
Source code in jafaal/rate_limit.py
74 75 76 | |
reset_rate_limiter ¶
reset_rate_limiter()
Reset to the no-op limiter. Intended for tests.
:func:limit binds the configured limiter lazily and watches the limiter
slot's generation counter, so resetting (or reconfiguring) the limiter
re-binds every decorated route on its next request — no import-order
juggling required.
Source code in jafaal/rate_limit.py
89 90 91 92 93 94 95 96 97 | |
configure_scopes ¶
configure_scopes(catalog)
Install the host's scope catalog (JAFAAL's scopes extended with app scopes).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
catalog
|
ScopeCatalog
|
The full catalog, typically |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the catalog is inconsistent (see :meth: |
Source code in jafaal/scopes.py
133 134 135 136 137 138 139 140 141 142 143 | |
get_scope_catalog ¶
get_scope_catalog()
Return the configured scope catalog (JAFAAL's own until configured).
Source code in jafaal/scopes.py
146 147 148 | |
reset_scopes ¶
reset_scopes()
Reset to JAFAAL's own catalog. Intended for tests.
Source code in jafaal/scopes.py
183 184 185 | |
configure ¶
configure(settings)
Install the host-supplied :class:AuthSettings for the process.
Call once at application startup, before serving requests. Re-calling replaces the settings and invalidates any settings-derived caches.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
AuthSettings
|
The fully-built, validated settings instance. |
required |
Source code in jafaal/settings.py
1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 | |
get_settings ¶
get_settings()
Return the installed :class:AuthSettings.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If :func: |
Source code in jafaal/settings.py
1068 1069 1070 1071 1072 1073 1074 | |
reset ¶
reset()
Clear the installed settings. Intended for test isolation.
Source code in jafaal/settings.py
1091 1092 1093 | |
configure_state_store ¶
configure_state_store(store)
Install the host-provided state store (e.g. a Redis-backed adapter).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
StateStore
|
A :class: |
required |
Source code in jafaal/state_store.py
270 271 272 273 274 275 276 | |
get_state_store ¶
get_state_store()
Return the configured state store (the in-memory default until configured).
Source code in jafaal/state_store.py
279 280 281 | |
reset_state_store ¶
reset_state_store()
Reset to a fresh in-memory store. Intended for tests.
Source code in jafaal/state_store.py
284 285 286 | |
get_sid_from_access_token ¶
get_sid_from_access_token(
request, access_token, identity_service
)
Retrieve the session ID from the access token.
Resolves and caches the :class:~jafaal.principal.Principal
on request.state then extracts the session ID from the
:class:~jafaal.principal.AccessTokenCred.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Request
|
Current HTTP request for state caching. |
required |
access_token
|
Annotated[str, Depends(get_access_token)]
|
JWT from the Authorization header. |
required |
identity_service
|
Annotated[IdentityService, Depends(get_identity_service)]
|
Per-request IdentityService. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
str |
str
|
Session ID ( |
Raises:
| Type | Description |
|---|---|
JafaalError
|
401 if the token is invalid, expired, or the credential type is unexpected. |
Source code in jafaal/_internal/internal_dependencies.py
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | |
get_sid_from_refresh_token ¶
get_sid_from_refresh_token(validated)
Retrieves the session ID ('sid') from a validated refresh token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
validated
|
Annotated[ValidatedRefreshToken, Depends(get_validated_refresh_token)]
|
The validated refresh token and its claims. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The session ID associated with the provided refresh token. |
Source code in jafaal/_internal/internal_dependencies.py
513 514 515 516 517 518 519 520 521 522 523 524 525 | |
get_sub_from_access_token ¶
get_sub_from_access_token(
request, access_token, identity_service
)
Retrieve the user ID from the access token.
Resolves and caches the :class:~jafaal.principal.Principal
on request.state then returns principal.user_id.
Subsequent calls within the same request hit the cache
instead of issuing another DB lookup.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Request
|
Current HTTP request for state caching. |
required |
access_token
|
Annotated[str, Depends(get_access_token)]
|
JWT from the Authorization header. |
required |
identity_service
|
Annotated[IdentityService, Depends(get_identity_service)]
|
Per-request IdentityService. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
int |
UserId
|
Authenticated user's primary key. |
Raises:
| Type | Description |
|---|---|
JafaalError
|
401 if the token is invalid, expired, or the user is not found. |
Source code in jafaal/_internal/internal_dependencies.py
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | |
get_sub_from_refresh_token ¶
get_sub_from_refresh_token(validated)
Retrieves the user ID ('sub' claim) from a validated refresh token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
validated
|
Annotated[ValidatedRefreshToken, Depends(get_validated_refresh_token)]
|
The validated refresh token and its claims. |
required |
Returns:
| Type | Description |
|---|---|
UserId
|
The user ID associated with the provided refresh token. |
Source code in jafaal/_internal/internal_dependencies.py
498 499 500 501 502 503 504 505 506 507 508 509 510 | |
validate_access_token_or_api_key
async
¶
validate_access_token_or_api_key(
request,
identity_service,
access_token=Depends(oauth2_scheme),
api_key_header=Depends(header_api_key_scheme),
api_key_query=Query(None, alias="api_key"),
)
Accept either a JWT bearer token or an API key.
API keys should be supplied via the X-API-Key request header.
Query-string delivery (?api_key=) is disabled by default because
credentials in query strings appear in access logs, proxy histories,
and browser history. It can be enabled via the
allow_api_key_query_param setting on
:class:~jafaal.settings.AuthSettings for self-hosted deployments
that require it (e.g. webhook integrations that cannot set custom
headers).
Tries JWT first (Authorization: Bearer header). If none is
present, falls back to the X-API-Key header, then the
?api_key= query parameter if allowed. Raises 401 if none
is supplied.
Delegates to :class:~jafaal.identity_service.IdentityService
for credential resolution and caches the resolved
:class:~jafaal.principal.Principal on
request.state.principal so that other dependencies in
the same request can share the result without a second DB
lookup.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request
|
Request
|
The current HTTP request. |
required |
identity_service
|
Annotated[IdentityService, Depends(get_identity_service)]
|
Per-request IdentityService. |
required |
access_token
|
str | None
|
Optional Bearer token from the Authorization header. |
Depends(oauth2_scheme)
|
api_key_header
|
str | None
|
Optional API key from the
|
Depends(header_api_key_scheme)
|
api_key_query
|
str | None
|
Optional API key from the
|
Query(None, alias='api_key')
|
Returns:
| Type | Description |
|---|---|
AuthContext
|
AuthContext with resolved user_id, scopes, and |
AuthContext
|
auth_type ( |
Raises:
| Type | Description |
|---|---|
JafaalError
|
401 if no valid credential is provided. |
Source code in jafaal/_internal/internal_dependencies.py
581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 | |
configure_api_key_scopes ¶
configure_api_key_scopes(scopes)
Install the scopes an API key is allowed to grant.
Call once at startup, before serving requests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scopes
|
Iterable[str]
|
The scope strings API keys may carry. |
required |
Source code in jafaal/api_keys/utils.py
23 24 25 26 27 28 29 30 31 | |
get_api_key_scopes ¶
get_api_key_scopes()
Return the configured API-key scope allow-list (empty until configured).
Source code in jafaal/api_keys/utils.py
34 35 36 | |
reset_api_key_scopes ¶
reset_api_key_scopes()
Reset the API-key scope allow-list to empty. Intended for tests.
Source code in jafaal/api_keys/utils.py
39 40 41 | |
check_auth_scopes ¶
check_auth_scopes(auth, security_scopes)
Validate scopes from a unified AuthContext.
Use this in place of :func:check_scopes on endpoints that accept both
JWT and API key auth. The underlying AuthContext is resolved by
:func:validate_access_token_or_api_key, which goes through
IdentityService (asserting the user exists and is active).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
auth
|
Annotated[AuthContext, Depends(validate_access_token_or_api_key)]
|
Resolved AuthContext from validate_access_token_or_api_key. |
required |
security_scopes
|
SecurityScopes
|
Required scopes for the endpoint. |
required |
Raises:
| Type | Description |
|---|---|
MissingScopeError
|
403 if any required scope is missing from the AuthContext. |
Source code in jafaal/dependencies.py
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | |
clear_password ¶
clear_password(user_id, db)
Remove a user's local password credential, leaving the account SSO-only.
A no-op when the account already has no local credential. Carries the same
"no step-up, no authorization check" warning as :func:set_password, and
like it, participates in the caller's transaction without committing and
revokes everything the removed password could still reach — leaving those
alive would make the removal cosmetic.
Leaves the account able to authenticate only through a linked identity provider or a registered passkey. Clearing the password of an account with neither locks the user out.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
UserId
|
The user whose credential should be removed. |
required |
db
|
Session
|
Active database session. |
required |
Source code in jafaal/identity_service.py
1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 | |
set_password ¶
set_password(
user_id,
password,
db,
*,
human_chosen=True,
must_change=False,
)
Set or replace a user's local password, outside an HTTP request.
The out-of-band half of credential management: seeding the first
administrator (which no HTTP flow can do — sign-up cannot grant
is_superuser), a CLI reset-password, or a migration importing
accounts. Ordinary self-service registration should go through
/auth/sign-up, which additionally gets enumeration safety and the
verification/approval gates.
.. warning:: This performs no step-up verification and no authorization check — it cannot, having no request to authenticate. JAFAAL's own routes reach the same credential store through step-up-gated endpoints; this is the equivalent for code that has already established the caller is allowed to do it. Never wire it to an HTTP handler without that gate.
Revokes everything the previous password could still reach — sessions, API keys, outstanding reset tokens, pending MFA tickets, step-up grants, live passkey-registration challenges — exactly as every other password path does. That is what makes an operator-driven reset evict an intruder rather than merely inconvenience them, so it is not optional. On an account created moments ago there is nothing to revoke and the sweep is a no-op.
Participates in the caller's transaction and does not commit — wrap it in
:func:jafaal.unit_of_work (or your own session.begin()) so the
credential lands atomically with whatever else you are writing::
with jafaal.unit_of_work(db):
user = repo.create_local_user("admin", "admin@example.com", db,
is_active=True, is_verified=True)
jafaal.set_password(user.id, os.environ["ADMIN_PASSWORD"], db)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user_id
|
UserId
|
The user to write the credential for. The account's tier selects the admin or regular minimum length. |
required |
password
|
str
|
The plaintext password. |
required |
db
|
Session
|
Active database session. |
required |
human_chosen
|
bool
|
Whether a person picked this password (the default). Human
passwords are held to the host's policy and screened against the
installed breach corpus. Pass |
True
|
must_change
|
bool
|
Require the account owner to replace this password before
they can sign in. Recommended whenever you chose the password —
seeding an administrator, or a support-desk reset — because such a
password is known to whoever set it. Login then fails with
:class: |
False
|
Raises:
| Type | Description |
|---|---|
NotFoundError
|
If |
PasswordPolicyError
|
If the password is human-chosen and fails the configured policy or appears in the installed breach corpus. |
Source code in jafaal/identity_service.py
1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 | |
authenticate_user ¶
authenticate_user(username, password, password_hasher, db)
Authenticates a user by verifying the provided username and password.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
username
|
str
|
The username of the user attempting to authenticate. |
required |
password
|
str
|
The plaintext password provided by the user. |
required |
password_hasher
|
PasswordHasher
|
An instance of the password hasher for verifying and updating password hashes. |
required |
db
|
Session
|
The database session used for querying and updating user data. |
required |
Returns:
| Type | Description |
|---|---|
UserProtocol
|
jafaal_ports.UserProtocol: The authenticated user object if authentication is successful. |
Raises:
| Type | Description |
|---|---|
JafaalError
|
If the username does not exist, the password is invalid, or
the password exceeds |
Source code in jafaal/utils.py
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 | |
complete_login ¶
complete_login(
response,
request,
user,
client,
token_manager,
db,
requested_scope=None,
)
Mint a session and its token bundle for an authenticated user.
Shared by password login and the authorization-code exchange, so both flows produce byte-identical token semantics, auditing, and new-device detection.
Token delivery follows client.token_delivery. For a browser client
(cookie) the response follows RFC 9700 §7.2 rather than RFC 6749 §5.1
literally: the access token is returned in the body for in-memory storage,
while the refresh token is set as an HttpOnly, SameSite=Strict
cookie instead of being handed to page script.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
response
|
Response
|
The HTTP response object, used to set the refresh cookie. |
required |
request
|
Request
|
The HTTP request, for IP and device fingerprinting. |
required |
user
|
UserProtocol
|
The authenticated user. |
required |
client
|
OAuthClient
|
The registered client the tokens are issued to. |
required |
token_manager
|
TokenManager
|
Utility for token generation. |
required |
db
|
Session
|
Database session for storing session information. |
required |
requested_scope
|
Sequence[str] | None
|
The |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
dict |
dict
|
The RFC 6749 §5.1 token response (see :func: |
Source code in jafaal/utils.py
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 | |
create_tokens ¶
create_tokens(
user,
token_manager,
session_id=None,
client=None,
requested_scope=None,
)
Generates session tokens for a user, including access token, refresh token, and CSRF token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
user
|
UserProtocol
|
The user object for whom the tokens are being created. |
required |
token_manager
|
TokenManager
|
The token manager responsible for token creation. |
required |
session_id
|
str | None
|
An optional session ID. If not provided, a new unique session ID is generated. |
None
|
client
|
OAuthClient | None
|
The registered client the tokens are issued to. Its scope ceiling narrows what the tokens carry. |
None
|
requested_scope
|
Sequence[str] | None
|
The |
None
|
Returns:
| Type | Description |
|---|---|
tuple[str, datetime, str, datetime, str, str]
|
tuple[str, datetime, str, datetime, str, str]: A tuple containing: - session_id (str): The session identifier. - access_token_exp (datetime): Expiration datetime of the access token. - access_token (str): The access token string. - refresh_token_exp (datetime): Expiration datetime of the refresh token. - refresh_token (str): The refresh token string. - csrf_token (str): The CSRF token string. |
Source code in jafaal/utils.py
108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | |
__getattr__ ¶
__getattr__(name)
Lazily import the model-touching public API on first access (PEP 562).
Source code in jafaal/__init__.py
261 262 263 264 265 266 267 268 | |