Client integration reference¶
The HTTP contract a client codes against: endpoints, wire formats, scopes, and error semantics.
For step-by-step tutorials, see the runnable example and its walkthroughs:
examples/ —
web
and
mobile.
Paths below are relative to wherever you mounted the router (the example mounts
it at /api/v1).
This reference is only for trusted, statically configured, first-party public
clients owned by the JAFAAL host. They are registered in
AuthSettings.oauth_clients, use PKCE instead of a client
secret, and do not represent third-party integrations. v0.1 has no consent,
grant, confidential-client, or dynamic-registration surface.
Registered clients decide the response shape¶
Every token-issuing request names a client_id registered in
AuthSettings.oauth_clients. The registration — not the
request — decides delivery mode and the scope ceiling, so a caller cannot widen
its own grant or switch how the refresh token comes back.
token_delivery |
Refresh token arrives as | For |
|---|---|---|
"cookie" |
HttpOnly, SameSite=Strict cookie |
Browsers. Page script never touches it (RFC 9700 §7.2) |
"body" |
A refresh_token field in the JSON |
Native apps, which have no cookie jar |
That single setting is the only difference between the web and mobile flows.
Discovery¶
| Method | Path | Notes |
|---|---|---|
GET |
/.well-known/oauth-authorization-server |
RFC 8414. Carries no extension members — everything needed to drive JAFAAL is a standard field |
GET |
/.well-known/jwks.json |
RFC 7517. 404 under HS256, which is correct: there is no public key, and jwks_uri is omitted from the metadata rather than serving an empty key set |
Do not hard-code endpoint URLs; read them from the metadata document.
Authentication¶
| Method | Path | Body | Returns |
|---|---|---|---|
POST |
/auth/login |
form: username, password, client_id, optional auth_request |
Token bundle, or a 202 MFA challenge |
POST |
/auth/mfa/verify |
JSON: mfa_token, mfa_code, client_id |
Token bundle |
POST |
/auth/refresh |
form: client_id |
Rotated token bundle |
POST |
/auth/password/change |
JSON: new_password, current_password/mfa_code as applicable, optional revoke_other_sessions |
{"message", "revoked_sessions"} |
POST |
/auth/password/renew |
JSON: username, current_password, new_password, mfa_code as applicable |
{"message", "revoked_sessions"} |
POST |
/auth/password/user/{user_id} |
JSON: admin's current_password/mfa_code, new_password, optional must_change |
{"message", "revoked_sessions"} |
POST |
/auth/logout |
form: client_id |
{"message": "Logout successful"} |
/auth/login is a first-party endpoint, not an OAuth grant. It is
deliberately not the resource-owner password-credentials grant (removed by
OAuth 2.1) and is not advertised in the discovery document.
Changing a password¶
/auth/password/change requires step-up: a valid access token is not enough,
because a password change grants persistent account access. Send
current_password when the account has a local password and mfa_code when MFA
is enabled. An SSO-only account with no MFA has no factor to verify and is
refused until it re-authenticates with its provider.
On success every credential the old password could still reach is revoked —
other sessions, API keys, outstanding reset tokens, pending MFA tickets, step-up
grants. The caller's own session is preserved, so the client keeps its tokens
and the user is not logged out of the device they changed it from. Pass
revoke_other_sessions: false for a routine rotation.
This is also how a password_change_required condition is cleared.
Administrative reset¶
/auth/password/user/{user_id} sets another account's password. It needs the
users:write scope and step-up from the calling administrator, and a
non-superuser can only ever target itself.
By default the owner must replace the new password at first sign-in
(must_change), because a password an administrator chose is known to that
administrator — without it, a reset is standing access to the account. The owner
completes the loop at /auth/password/renew, which is unauthenticated by
necessity (the account cannot obtain a token until the password is replaced) and
verifies exactly the factors a login would.
[!IMPORTANT] JAFAAL's built-in notion of "may administer" is two tiers: superuser or not. If yours is richer — tenancy, support roles, delegated admins — gate this endpoint yourself or implement a
ScopeResolverthat reflects your model. Shipping it unguarded on a multi-tenant deployment would let any superuser reach every tenant.
Token bundle¶
Cookie clients:
{
"session_id": "…",
"access_token": "eyJ…",
"csrf_token": "…",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token_expires_in": 604800,
"scope": "profile users:read identity_providers:read"
}
Body clients get refresh_token instead of csrf_token. scope is what the
token actually carries, which may be narrower than requested (RFC 6749 §3.3
permits this, and §5.1 requires reporting it) — drive your UI off this field.
MFA challenge¶
{
"mfa_required": true,
"mfa_token": "…",
"username": "demo",
"message": "MFA verification required"
}
mfa_token is an opaque, single-use, five-minute ticket proving the password
factor was satisfied by this caller. Hold it in memory; never persist it.
username is echoed for display and is not a credential. mfa_code accepts a
TOTP code or a single-use backup code (XXXX-XXXX).
OAuth 2.0¶
| Method | Path | Purpose |
|---|---|---|
GET |
/auth/authorize |
RFC 6749 §4.1 authorization endpoint |
POST |
/auth/token |
authorization_code and refresh_token grants |
POST |
/auth/introspect |
RFC 7662; requires an auth:introspect token |
POST |
/auth/revoke |
RFC 7009; returns 200 even for unknown tokens |
/auth/authorize query parameters: response_type=code (the only supported
value), client_id, redirect_uri, code_challenge,
code_challenge_method=S256, and optional state, scope, idp.
Omit idp and the browser goes to the host's login_ui_url with an
auth_request handle, which that page posts back to /auth/login. Nothing about
the grant is re-read from the login request — client, redirect URI, PKCE
challenge and scope all come from the parked request — so a compromised login
page cannot widen or redirect a grant.
Four bindings must hold for a code to be redeemed: PKCE, client_id,
byte-for-byte redirect_uri, and single use. Every failure returns the same
invalid_grant, so the endpoint is not an oracle. Do not branch on the reason.
Authorization responses — success and error — carry iss (RFC 9207). Validate
it along with state.
Refresh-token rotation¶
Every refresh rotates the token.
| Property | Behaviour |
|---|---|
| Rotation | A new refresh token on every refresh; the old one is superseded |
| Grace window | ~60s, so a lost response can be retried idempotently |
| Reuse past grace | Treated as theft — the entire token family is invalidated |
| Family scope | Every session descended from that login |
Two client obligations follow: keep exactly one refresh in flight, and persist the rotated token before using it. On refresh failure, do not retry with the old token — re-authenticate.
CSRF (cookie clients only)¶
State-changing requests must carry X-CSRF-Token. Two independent layers apply:
- Off-site rejection, always enforced under cookie delivery, using
OriginandSec-Fetch-Site. Both are forbidden header names, so page script cannot forge or strip them — unlike a custom header, which a cross-site attacker simply omits. - Token binding, when the client sends one.
/auth/refresh accepts a bootstrap call with no X-CSRF-Token: on page
reload the in-memory tokens are gone while the HttpOnly cookie persists. Layer 1
is what makes that safe. If the header is sent, it must be valid.
Scopes¶
JAFAAL ships only auth/identity scopes; a host extends the catalog with its own
via DEFAULT_SCOPE_CATALOG.extend(...).
| Scope | Tier | Meaning |
|---|---|---|
profile |
regular | Privileges over the user's own profile |
users:read |
regular | Read privileges over users |
identity_providers:read |
regular | Read privileges over identity providers |
users:write |
admin | Write privileges over users |
sessions:read / sessions:write |
admin | View / manage sessions |
identity_providers:write |
admin | Configure identity providers |
auth:introspect |
— | Call the introspection endpoint |
A credential's authority is bounded three times: the host's ScopeResolver, then
the registered client's ceiling, then the client's scope request.
OAuth protocol errors¶
/auth/authorize, /auth/token, /auth/introspect, and /auth/revoke
parse protocol parameters before FastAPI request-model validation. A missing,
empty, non-text, malformed, or repeated parameter receives HTTP 400
invalid_request; an OAuth endpoint never emits FastAPI's 422 detail array.
Repeated extension parameters are rejected too, rather than silently choosing
one value.
Token, introspection, and revocation request errors use the OAuth JSON shape, so a standard client library parses them unmodified:
{ "error": "invalid_grant", "error_description": "…" }
Every such error response carries Cache-Control: no-store and Pragma:
no-cache. An invalid token submitted to introspection is not a request error;
RFC 7662 returns {"active": false}. Revoking an unrecognised token remains a
successful no-op under RFC 7009.
The authorization endpoint has two reporting channels:
- If
client_idorredirect_uriis missing, repeated, unknown, or not a registered pair, JAFAAL renders the OAuth JSON error locally and sends noLocationheader. It never redirects to an unvalidated or ambiguous target. - After one client and redirect URI validate, later errors return by
302to that URI witherror,error_description, andiss. JAFAAL echoesstateonly when the request supplied it exactly once; a repeated state is ambiguous and is not selected.
| Status | OAuth error | Meaning |
|---|---|---|
400 |
invalid_request |
Missing, empty, malformed, or repeated protocol parameter |
400 |
invalid_grant |
Invalid, expired, revoked, mismatched, or already-used authorization code or refresh token |
400 |
unsupported_grant_type |
/auth/token does not implement the requested grant |
400 |
invalid_scope |
Requested scope is unknown or exceeds the registered client ceiling |
400 |
unsupported_response_type |
/auth/authorize supports only code |
401 |
invalid_client |
Client is missing where required, unknown, or not authorised |
JAFAAL extension and bearer errors¶
JAFAAL extension routes such as login, MFA, password, passkey, signup, session,
and API-key management use domain errors with a stable code:
{ "detail": "The token is invalid.", "code": "invalid_token" }
FastAPI request-schema failures on these non-OAuth routes may use its native
HTTP 422 detail array. They are distinct from JafaalError domain failures
and from the OAuth contract above.
| Status | JAFAAL code or condition | Client action |
|---|---|---|
401 |
invalid_token — expired or invalid access token |
Refresh once, retry once |
401 |
password_change_required — the password is correct but was set by an operator |
Send the user to a password-change flow; retrying is futile |
403 |
insufficient_scope |
Do not retry; the grant is too narrow |
404 |
Unknown resource | — |
429 |
Rate limited or progressive lockout | Respect Retry-After |
503 |
Auth state store unavailable | Retry with backoff |
Bearer failures carry a WWW-Authenticate challenge (RFC 6750).
Other endpoints¶
| Method | Path | Purpose |
|---|---|---|
POST |
/auth/sign-up/request · /auth/sign-up/confirm |
Enumeration-safe sign-up |
POST |
/auth/password-reset/request · /auth/password-reset/confirm |
Enumeration-safe reset |
GET/DELETE |
/auth/sessions/user/{user_id} |
List / revoke the user's sessions |
DELETE |
/auth/sessions/{session_id}/user/{user_id} |
Revoke one session |
GET/POST |
/auth/api-keys |
List / create API keys |
PATCH/DELETE |
/auth/api-keys/{api_key_id}/revoke · /{api_key_id} |
Revoke / delete |
POST |
/auth/webauthn/register/begin · /register/complete |
Passkey enrolment |
POST |
/auth/webauthn/mfa/begin · /mfa/complete |
Passkey as second factor |
POST |
/public/webauthn/authenticate/begin · /authenticate/complete |
Passwordless login |
GET |
/public/idp |
Identity providers a client may offer |
GET |
/public/idp/callback/{idp_slug} |
SSO callback |
POST |
/auth/idp/step-up/reauth/{idp_id} |
Step-up re-authentication for SSO-only accounts |