Authentication
Patrimoine supports a dual authentication system: OIDC (RS256 via JWKS) for federated identity and local auth (HS256 JWT) for self-hosted accounts. The iss claim in the JWT determines which validation path is used. OIDC is only active when OIDC_ENABLED=true and OIDC_CLIENT_ID is set; otherwise only local auth is offered (GET /auth/config reports oidc_enabled).
get_current_user Priority Chain
Section titled “get_current_user Priority Chain”The get_current_user dependency (backend/app/dependencies.py) resolves the authenticated user in the following order:
-
API token — If the
Authorizationheader starts withBearer ptr_, the token is passed tovalidate_api_tokenwhich SHA-256 hashes it and looks up the hash in theapi_tokenstable. API tokens are CSRF-exempt (no cookie involved). Updateslast_used_aton success. -
Access token cookie (JWT) — If no API token, the
access_tokencookie is read.peek_issdecodes the JWT without signature verification to read theissclaim:iss == "patrimoine"→ Local HS256 path:validate_local_tokenverifies signature withJWT_SECRET, checksiss/exp/sub/typeclaims. OnExpiredSignatureError, attempts refresh via therefresh_tokencookie usingvalidate_refresh_token+create_access_token.- Otherwise → OIDC RS256 path:
validate_token(async) fetches the signing key from JWKS ({oidc_issuer}/jwks/) via an asynchttpxcall (non-blocking), verifies signature (RS256),iss,aud(=oidc_client_id), andexp.- On
ExpiredSignatureError, attempts refresh via the OIDCtoken_endpointusing therefresh_tokencookie. If the provider returns a newrefresh_token(token rotation), therefresh_tokencookie is updated accordingly. - If the OIDC provider itself is unreachable or erroring (e.g. the JWKS endpoint returns
503),httpx.HTTPErroris caught and the request is rejected with401 Not authenticatedrather than an unhandled 500.
- On
-
Access token cookie absent, refresh token cookie present — The
access_tokencookie’s ownmax_agematches its short JWT lifetime (see cookies-and-csrf.md), so the browser deletes it entirely once it expires — this is the ordinary case for a returning user, not an edge case.- There is no access token to
peek_isson here, so the same peek runs on therefresh_tokencookie instead:- A local refresh token is a JWT with
iss == "patrimoine", so it routes to the local refresh flow. - A non-matching or non-JWT value (an OIDC refresh token is Authentik’s opaque, non-JWT string) routes to the OIDC refresh flow.
- A local refresh token is a JWT with
- Both flows are otherwise identical to the
ExpiredSignatureErrorrefresh branches above — same validation, same rotation, same cookies set. - Only when neither cookie is present does the request fall through to the E2E bypass check and then
401 Not authenticated.
- There is no access token to
-
E2E bypass — If
e2e_test_modeistrueand the token matchese2e_bypass_token, a test user is upserted. The bypass token is generated at startup viasecrets.token_urlsafe(32)if not set — never a hardcoded string.
Once a user is resolved on any of the four paths above, two additional checks run before the request proceeds:
_reject_if_inactiverejects the request (403) ifis_activeisfalse._maybe_promote_adminpromotes the user to admin if theirsub/email matchesADMIN_EMAILS/ADMIN_SUBSand they aren’t already admin (one-way — never demotes). Local accounts never match:_matches_configured_adminreturnsFalseforauth_provider == "local".
Rejection runs first, so a disabled account listed in ADMIN_EMAILS is never promoted on a request that gets rejected anyway. See Admin Role below.
jti Revocation
Section titled “jti Revocation”Every JWT includes a jti (JWT ID) claim. auth_service.revoke_token decodes the token (without signature verification — it was already validated) and inserts the jti into the revoked_tokens table. It’s called on:
- logout (
routers/auth.py) - local-auth refresh-token rotation (
dependencies.py— see Token Refresh below)
It lives in the service layer rather than the router specifically so dependencies.py can call it without a circular import.
_check_revocation is called on every JWT validation path to reject revoked tokens.
The insert uses a savepoint (begin_nested) with IntegrityError catch. This ensures that a duplicate jti (concurrent logout) only rolls back the failed INSERT, not the entire session. This is critical because logout revokes both access and refresh tokens sequentially — a full rollback() would undo the first revocation if the second fails.
Expired revocation rows are purged once a day by the revoked-token-purge background task (the expires_at column is indexed).
Sessions
Section titled “Sessions”Every local cookie sign-in (POST /auth/login, registration, invite redemption) opens a tracked browser session: start_local_session (routers/auth.py) inserts a user_sessions row and mints the access and refresh tokens with its sid claim. The sid is a random secrets.token_urlsafe(16) value. Sign-ins that carry no browser cookie have no row and no sid: API tokens (ptr_..., including the one POST /auth/login/token returns to the mobile app), OIDC sessions (the identity provider owns them) and tokens minted before sessions were tracked.
- One query per validation —
_check_revocationruns a singleSELECTthat answers both questions: anEXISTSon thejtiinrevoked_tokens, and scalar subqueries on the token’suser_sessionsrow. A token whosesidhas no row, whose row belongs to another user than the token’suid, or whose row is revoked or pastexpires_at, is rejected with401 session_invalid. A token withoutsidis only checked against itsjti. The check runs for access tokens and for the refresh token on rotation, so an ended session cannot refresh. last_seen_atis written only at rotation —touch_sessionruns inside the refresh-and-rotate flow, which is already exempt from the GET-write guard. An ordinary request never writes, so the “last used” time can be up to one access-token lifetime (30 minutes by default) behind.- Rows are committed before the response —
start_local_sessioncallscommit()itself instead of leaving it to theget_dbexit code, which FastAPI runs after the response has been sent. Without it the browser’s next request could carry asidwhose row is not visible yet and be refused. - What is stored —
expires_atis the sign-in time plusMAX_SESSION_DAYS.user_agentholds a coarse “Browser on System” label built byua_label, andip_networkthe client address truncated to its/24(IPv4) or/48(IPv6) network. The rawUser-Agentheader and the full address never reach the database, a log line or an audit entry. - Sessions from before tracking — a token without
sidcannot be tied to a row. “Sign out everywhere else” stampsusers.legacy_sessions_revoked_at, and_reject_if_token_not_boundthen refuses anysid-less local token whoseauth_timeis at or before that moment (access and refresh alike, with no extra query: the user row is already loaded).GET /api/v1/auth/sessionsshows these tokens as one synthetic entry withid0 andis_legacy: true, for local users only and only while such tokens can still be alive. That entry cannot be revoked on its own. - Revoking never touches API tokens —
GET /api/v1/auth/sessions(paginated,X-Total-Count),DELETE /api/v1/auth/sessions/{id}andPOST /api/v1/auth/sessions/revoke-others(routers/sessions.py,services/session_service.py) only setrevoked_at(and, for revoke-others, the legacy cutoff). They neither delete API tokens nor bumptoken_version. Unknown, foreign, revoked or expired ids answer404 session_not_found, the caller’s own session400 session_is_current. Each action is audited (user.session_revoked,user.sessions_revoked_others). A caller on a pre-tracking cookie is retired with the rest, so revoke-others re-issues it a tracked session in the same response. Aptr_token may call these endpoints: it has full account scope. - Credential changes still end everything —
invalidate_user_sessions(password change and reset, TOTP enable and disable, an admin MFA reset) bumpstoken_version, deletes every API token and revokes every session row. A caller authenticated by aptr_token keeps that one token for password change and TOTP enable and disable (keep_api_token; API tokens are validated by hash, independently oftoken_version), while every other token and session still ends; password reset and admin actions end everything. An email change (user or admin) changessub, which ends the cookie sessions without atoken_versionbump, so it revokes every row explicitly and leaves API tokens alone. Logout revokes the row of the session that signed out. Deleting the account deletes its rows. - Cleanup — a daily background task (
run_session_purgeinapp/main.py) callspurge_expired_sessions, which deletes rows whoseexpires_atis more than 30 days in the past, revoked or not.
OIDC Flow
Section titled “OIDC Flow”The OIDC Authorization Code + PKCE flow is implemented in backend/app/routers/auth.py and backend/app/utils/oidc_client.py:
-
GET /auth/oidc/start— Generates a PKCEcode_verifier(32 random bytes → base64url) andcode_challenge(S256:BASE64URL(SHA256(code_verifier))). A randomstateis generated viasecrets.token_urlsafe(32). Both are set as cookies (pkce_verifier,oauth_state) with 5-minute TTL,HttpOnly,SameSite=Lax. The user is redirected to the OIDC provider’sauthorization_endpoint. -
GET /auth/callback- Verifies
statematches theoauth_statecookie. - Exchanges the authorization code for tokens using the
code_verifiercookie. - Fetches userinfo.
- Validates that the
subclaim is non-empty (400 if missing). - Upserts the user via
upsert_from_claims(only updates email/name if the OIDC claims are non-None, preserving user-edited profile fields). - Stores the provider’s
id_tokenin an HttpOnlyid_tokencookie, only to send it asid_token_hinton logout; it is deleted at logout. - Sets the
access_tokencookie withmax_agecapped to the token’s ownexp(oidc_access_cookie_max_age) and therefresh_tokencookie withmax_agematchingrefresh_token_expire_days— the access token cookie deliberately cannot outlive the refresh token cookie (see cookies-and-csrf.md). - If the OIDC provider does not return a
refresh_token, a warning is logged — sessions will expire when the access token expires. - When the OIDC provider returns an empty
refresh_token, the cookie is not set (preventing an empty/""""cookie value). A warning is logged. - Enable
DEBUG=trueinbackend/container/.envto see diagnostic logging of the token response keys and refresh_token presence/length. - Deletes the OIDC flow cookies.
- Verifies
-
Discovery — The discovery document is fetched on demand (
fetch_discovery_document) with the shared client and a bounded retry with exponential backoff; it is not cached. Only the JWKS is cached (1 hour). -
Shared HTTP client — All OIDC HTTP calls (discovery, token exchange, userinfo, refresh, JWKS) use a shared
httpx.AsyncClientwith connection pooling, created lazily viaget_oidc_client()and closed on application shutdown viaclose_oidc_client(). -
JWKS validation — JWKS keys are fetched asynchronously (non-blocking) via
get_signing_key(), cached in memory with a 1-hour TTL, and protected by anasyncio.Lockto prevent duplicate fetches on concurrent requests. -
Timeouts — All OIDC HTTP calls use
httpx.Timeout(10.0, connect=5.0). -
Cookie attributes — OIDC flow cookies use
oidc_cookie_kwargs:{secure: is_https(), samesite: "lax", path: "/"}.- Session cookies use
auth_cookie_kwargsfor their shared attributes.max_agediffers per cookie:refresh_tokengetsauth_cookie_max_age()(refresh_token_expire_days * 86400, 7 days default).access_tokengets its own shorterlocal_access_cookie_max_age()/oidc_access_cookie_max_age()so it can never outlive the refresh cookie.
- See cookies-and-csrf.md for details.
- Session cookies use
Token Refresh
Section titled “Token Refresh”Both local and OIDC auth paths support transparent token refresh. The backend uses the refresh_token cookie to obtain a new access token without user interaction whenever the current access_token isn’t usable:
- It’s still present but its
expclaim has passed (ExpiredSignatureError). - The cookie is gone entirely.
The second case is the everyday “remember me” scenario, not a fallback for a rare timing window: the access_token cookie’s own max_age matches its short JWT lifetime (see cookies-and-csrf.md), so the browser deletes it well before the refresh_token cookie expires.
Both triggers run the identical refresh-and-rotate flow described below — get_current_user just reaches it by a different route (peek_iss on the refresh_token itself instead of on an access token that no longer exists) when the access_token cookie is absent, per the priority chain above.
Local auth refresh
Section titled “Local auth refresh”validate_local_tokenraisesExpiredSignatureError(access token present, expired) — or theaccess_tokencookie is absent entirely andpeek_iss(refresh_token) == "patrimoine".- The
refresh_tokencookie is validated viavalidate_refresh_token(checks signature,iss,exp,type == "refresh"), then checked againstrevoked_tokens.- Local tokens also carry
tv(compared withusers.token_version, bumped by password reset/change and TOTP enable/disable to end every session),uid(must equalusers.id, so a re-registered email cannot reuse an old token) andauth_time(original sign-in time, carried unchanged through rotation) and, for cookie sign-ins,sid(the id of the session row, carried unchanged through rotation; see Sessions). A refresh older thanMAX_SESSION_DAYSafterauth_timeis rejected. Tokens issued before these claims existed are treated astv0 and unbound, and keep working until they expire.
- Local tokens also carry
- A new access token is issued via
create_access_token(sub)and set as a cookie. - Refresh token rotation — a new refresh token is minted via
create_refresh_token(sub), the old one is revoked viaauth_service.revoke_token(added torevoked_tokens), and the new one replaces therefresh_tokencookie.- This mirrors the OIDC path below: without rotation, a stolen local refresh cookie stayed valid for its full 7-day lifetime even after a legitimate refresh consumed it.
- This write happens on the first request of any route once the access token cookie is expired or absent, not a specific path, so it’s exempted from the GET-write guard the same way
_maybe_promote_admin’s write is (seeapp/database.py’sget_write_exemptsession flag).
OIDC refresh
Section titled “OIDC refresh”validate_tokenraisesExpiredSignatureError(access token present, expired) — or theaccess_tokencookie is absent entirely andpeek_iss(refresh_token)doesn’t match"patrimoine"(Authentik’s refresh tokens are opaque, non-JWT strings, so this is the normal case for OIDC).- The
refresh_tokencookie is sent to the OIDC provider’stoken_endpointviarefresh_access_token. - The new
access_tokenfrom the response is validated and set as a cookie. - Refresh token rotation — If the OIDC provider returns a new
refresh_tokenin the refresh response (as Authentik does by default), therefresh_tokencookie is updated with the new value. This is critical because many OIDC providers invalidate the old refresh token after use. Without rotation, the second refresh attempt would fail and the user would be logged out.
Missing refresh token
Section titled “Missing refresh token”If the OIDC provider does not return a refresh_token during the initial token exchange (e.g., because the offline_access scope is not granted), a warning is logged in the callback handler. The user can still authenticate, but sessions will expire when the access token expires (typically 5–15 minutes for OIDC providers). To fix this, ensure the OIDC provider grants the offline_access scope to the application. See Debugging for details.
Local Auth
Section titled “Local Auth”Local authentication uses HS256 JWTs signed with JWT_SECRET:
- Registration (
POST /auth/register) — Only available whenREGISTRATION_ENABLED=true(defaults tofalse). Passwords hashed with argon2 viapwdlib(PasswordHash.recommended()). Creates access + refresh tokens immediately. - Login (
POST /auth/login)- Checks
locked_untilfirst (see Login Lockout below), then verifies email/password againstpassword_hash, then checksis_active(disabled accounts get403 account_disabled— checked after password verification, not before, so a wrong-password guess against a disabled account’s email still gets the genericinvalid_credentialsresponse rather than leaking account status). - Sets access + refresh cookies.
POST /auth/login/token(mobile) andPOST /auth/mobile/exchange(OIDC PKCE mobile) apply the sameis_activecheck before issuing a token.
- Checks
- Login lockout
authenticate_local_usertracksfailed_login_attemptsandlocked_untilonUser.- A wrong password or a wrong TOTP code both count as a guess;
totp_required(the normal first half of a two-step login) does not. - Failed guesses are counted per (account, client IP) in memory (
app/utils/login_throttle.py): afterLOGIN_MAX_ATTEMPTS(default 5) from one IP, that IP is locked out of the account forLOGIN_LOCKOUT_MINUTES(default 15), doubling with each further failure up toLOGIN_LOCKOUT_MAX_MINUTES(default 240). A success from that IP clears its counter. failed_login_attemptssums failures from every IP; reachingLOGIN_ACCOUNT_MAX_ATTEMPTS(default 25) setslocked_untilfor everyone, with the same doubling backoff. A success resets it.- A locked account gets the exact same
invalid_credentialsresponse as a wrong password — lockout state is never distinguishable from an ordinary failed login, matching the no-enumeration design used for password-reset requests below. - This supplements the IP-based
/auth/*rate limit (10 req/60s) with a per-account backoff that an IP-rotating attacker can’t route around.
- Password change (
POST /api/v1/auth/change-password) — Requires current password confirmation. Available only forauth_provider == "local"accounts. - Token structure —
iss: "patrimoine",sub: "local:{email}",type: "access"or"refresh",jti: secrets.token_urlsafe(16),expbased onaccess_token_expire_minutes(30min) orrefresh_token_expire_days(7days).
TOTP Replay Protection
Section titled “TOTP Replay Protection”Every code-accepting path — login (POST /auth/login, POST /auth/login/token, which share authenticate_local_user) and TOTP setup verification (POST /api/v1/auth/totp/verify) — records the RFC 6238 time step (users.totp_last_step) of the code it just accepted:
- A code from the same or an earlier time step is rejected with the same
totp_invalid401 as a wrong code, so a replayed or intercepted code is never distinguishable from a guess, and it counts toward the same per-account lockout. - The +/-1 time-step drift window is unchanged — the next step’s code still succeeds; only a non-increasing step is rejected.
- The check-and-set is a single conditional
UPDATE ... WHERE totp_last_step IS NULL OR totp_last_step < :step, so two concurrent requests with the same code cannot both succeed (no read-then-write race window). - The recorded step is reset to
NULLwhenever the secret itself changes: starting a new enrolment (POST /api/v1/auth/totp/setup), a self-service disable (POST /api/v1/auth/totp/disable), and an admin-initiated MFA disable (POST /api/v1/admin/users/{user_id}/mfa/disable) all clear it, so a re-enrolled user’s first code is never blocked by a stale step from a previous secret.
Recovery codes
Section titled “Recovery codes”A local user who enables TOTP gets ten one-time recovery codes (app/services/recovery_code_service.py), so losing the authenticator does not lock them out.
- Format —
XXXXX-XXXXX-XXXXX: fifteen symbols from the Crockford base32 alphabet (0-9andA-ZwithoutI,L,OandU), drawn withsecrets.choice. The first group is a lookup id, stored in plain and unique per user (uq_totp_recovery_codes_user_lookup). The other ten symbols are the secret (50 bits), stored only as an Argon2 hash made byapp/utils/password.py. No code is ever stored in plain, logged, audited or emailed, and none is included in a backup. - Normalisation — whitespace and hyphens are dropped, letters are upper-cased, and
O,IandLare read as0,1and1, so a hand-copied code still matches. - Routing —
_verify_totp_code_or_raisesends exactly six ASCII digits to the TOTP path and any other value to the recovery path, which never decrypts the TOTP secret and never touchestotp_last_step. - One verify per attempt —
consume_recovery_codedoes one indexed lookup on (user, lookup id, unused) and exactly one Argon2 verify. When the code is malformed or no unused row matches, it verifies a dummy hash with the real parameters instead, so a wrong, unknown, spent or malformed code costs the same. Every failure is the same401 totp_invalidas a wrong TOTP code and counts toward the shared per-account lockout (the failed attempt is audited with the reasoninvalid_recovery_code). - Atomic single use — a conditional
UPDATE ... WHERE id = :id AND used_at IS NULLsetsused_at, so of two racing sign-ins with the same code only one succeeds. - Lifecycle
POST /api/v1/auth/totp/verifyenables TOTP and returns the first set in the200response (Cache-Control: no-store).POST /api/v1/auth/totp/recovery-codesreplaces the whole set after a password check (and nothing else, so a user who has just signed in with a recovery code and has no authenticator can still renew the set). It takes aFOR UPDATElock on the user row, so two concurrent calls leave exactly one set.GET /api/v1/auth/totp/recovery-statusreturns{remaining, total}.- Starting a new enrolment, disabling TOTP (user or admin) and deleting the account delete the codes: codes never outlive the secret they back.
- Audit and notices —
user.recovery_codes_generated(withvia:totp_enableorregenerate) anduser.recovery_code_used(with theremainingcount). When SMTP is configured, each use and each regeneration also sends the user an email in their language, with a count and a UTC time but never a code.
Rotating JWT_SECRET
Section titled “Rotating JWT_SECRET”JWT_SECRET must be at least 32 characters (enforced at startup — app/main.py’s lifespan raises RuntimeError otherwise). Generate one with:
python -c "import secrets; print(secrets.token_urlsafe(48))"When to rotate — the secret is compromised or suspected leaked, or as part of a routine credential-rotation policy.
What the secret protects — three things, all tied to local auth:
- Signs and verifies every local-auth access/refresh JWT through a key derived from
JWT_SECRETwith HKDF (app/utils/local_jwt.py; new tokens carrykid: v2). - Derives, through HKDF with a TOTP-specific context, the Fernet key that encrypts every stored TOTP secret at rest (
app/utils/totp_crypto.py). - Derives, through HKDF with a webhook-specific context, the Fernet key that encrypts every stored webhook signing secret at rest (
encrypt_webhook_secretinapp/utils/totp_crypto.py).
What rotation breaks:
- All local-auth sessions end immediately. Every existing access/refresh JWT was signed with the old secret and fails signature verification against the new one — every local user is logged out and must sign in again.
- Every TOTP-enabled local user is stranded at the TOTP step.
- Their stored
totp_secretwas encrypted with a Fernet key derived from the oldJWT_SECRET; decrypting it with the new secret fails. - Signing in with a six-digit code fails with
503 totp_unavailable(“Two-factor verification is temporarily unavailable”):_verify_totp_code_or_raisecatches thecryptography.fernet.InvalidTokenraised by the decryption and logs a warning naming the user. - A user who holds an unused recovery code is not stranded: recovery codes are verified against their own Argon2 hashes and never need
JWT_SECRET, so the code is accepted after a rotation (see the recovery below). - The admin recovery below works regardless: it never needs to decrypt the old secret.
- Their stored
- Every webhook stops delivering. Stored webhook secrets were encrypted under a key derived from the old
JWT_SECRETand can no longer be decrypted; each delivery fails withsecret_unavailable. Delete and recreate the webhooks (a new secret is shown once at creation).
What rotation does not break:
- OIDC sessions — signed RS256 via the identity provider’s own keys (JWKS), entirely independent of
JWT_SECRET. - API tokens (
ptr_...) — stored as a plain SHA-256 hash (api_token_service._hash_token), independent ofJWT_SECRET. They keep working across a rotation.
Post-rotation recovery (per stranded user):
A user who still has a recovery code can recover alone:
- The user signs in with email, password and, on the verification step, a recovery code.
- The user turns two-factor authentication off with their password (Settings → Security → Disable MFA, as in the pre-rotation option above), then enrols again (
POST /api/v1/auth/totp/setup→POST /api/v1/auth/totp/verify) under the new secret. Enrolling returns a fresh set of recovery codes.
A user without a usable recovery code needs an admin:
- An admin disables MFA for the affected user: Administration → Users → select the user → Disable MFA (or
POST /api/v1/admin/users/{user_id}/mfa/disabledirectly —admin_user_service.disable_user_mfa, idempotent, clearstotp_secret/totp_enabledand the user’s recovery codes). This does not require decrypting the old secret. - The user signs in with email and password alone (no TOTP code required once MFA is disabled).
- The user re-enrols TOTP (
POST /api/v1/auth/totp/setup→POST /api/v1/auth/totp/verify) under the new secret if they want MFA again.
API Tokens
Section titled “API Tokens”API tokens provide long-lived, header-based authentication for scripts and integrations:
- Format —
ptr_prefix + 64 hex chars (secrets.token_hex(32)). - Storage — SHA-256 hash stored in
api_tokens.token_hash. Plaintext is never persisted. - Creation —
POST /api/v1/tokensreturns the plaintext token once. Auto-clears from the frontend DOM after 60 seconds. - Limits — Max 10 tokens per user. Optional expiry via
expires_in_days(1 to 3650 days; omit it for a token that never expires). The token minted by the OIDC mobile exchange (POST /auth/mobile/exchange) expires after 90 days; password-login tokens and tokens issued earlier keep their existing lifetime. - Validation —
validate_api_tokenhashes the incoming token and looks up the hash. Updateslast_used_at. - CSRF — Exempt from CSRF middleware because authentication is via
Authorizationheader, not cookies.
Cookie Security
Section titled “Cookie Security”Cookie attributes are centralized in backend/app/utils/cookies.py:
is_https(request)— WhenTRUSTED_PROXY=true(default) and the direct peer is a trusted proxy (TRUSTED_PROXIES, default private networks), readsX-Forwarded-Proto; otherwise falls back torequest.url.scheme.Secureflag — Protocol-aware: set totruewhen the request is HTTPS,falseover HTTP. This allows local dev over HTTP withoutDEBUG=true.SameSite—Laxfor all cookies (session and OIDC flow). See cookies-and-csrf.md for the rationale.HttpOnly— Alwaystruefor all auth cookies.
Session Cookies
Section titled “Session Cookies”| Cookie | Max-Age | Purpose |
|---|---|---|
access_token |
30min (access_token_expire_minutes) for local auth; the token’s own remaining exp for OIDC |
JWT for API authentication. Cookie max_age deliberately matches the JWT’s own (short) lifetime, not the refresh window, so this cookie can never outlive the refresh_token cookie in the browser — it gets deleted by the browser once it expires. When that happens, get_current_user transparently refreshes via the refresh_token cookie (see Token Refresh) exactly as it would for a present-but-expired access token. |
refresh_token |
7 days (refresh_token_expire_days) |
Used to obtain new access tokens; its max_age is what actually determines how long a session survives across browser restarts. Rotated on every use for both auth paths: for OIDC, when the provider returns a new refresh token during the refresh flow; for local auth, unconditionally (see Token Refresh). |
OIDC Flow Cookies
Section titled “OIDC Flow Cookies”| Cookie | Max-Age | Purpose |
|---|---|---|
pkce_verifier |
300 seconds (5 min) | PKCE code verifier for token exchange |
oauth_state |
300 seconds (5 min) | CSRF state verification for OIDC callback |
All OIDC flow cookies use SameSite=Lax (not None, which causes Firefox TCP partitioning).
Admin Role
Section titled “Admin Role”An optional is_admin flag on User, scoped strictly to account management (list/enable/disable/promote/demote users, issue invites) — an admin can never query another user’s financial data (accounts, snapshots, transactions).
- OIDC bootstrap —
ADMIN_EMAILS/ADMIN_SUBS(comma-separated) promote a matching OIDC user on their next authenticated request (see the priority-chain note above), andbootstrap_configured_adminsre-promotes every matching live OIDC user at backend startup. Emails match case-insensitively,subvalues exactly. One-way: removing an entry from the env var never demotes an already-admin user. - Local bootstrap — local accounts are never matched against those variables, because a self-registered email is not proof of ownership. Instead
register_usermakes the first account registered admin when theuserstable holds no live (non-deleted) user. If an install ends up with accounts but no admin, seeADMIN_EMAILShas no effect for the database fallback. - In-app grants — Beyond bootstrap, an existing admin can promote/demote any other user via
PATCH /api/v1/admin/users/{id}. - Authorization — All
/api/v1/admin/*endpoints depend onget_current_admin_user, which wrapsget_current_userand raisesAPIError(code="admin_required", status_code=403)for non-admins. - Last-admin guard — Demoting, disabling, or deleting the sole remaining admin (
is_admin AND is_active) is rejected with409 last_admin. Self-disable is allowed as long as another admin remains. - Invites — Admin-issued, single-use, 7-day-expiry tokens (SHA-256 hashed at rest, plaintext never persisted — same pattern as
PasswordResetToken). Redemption (POST /auth/invites/redeem, public/unauthenticated) always creates a regular (non-admin) user and bypassesREGISTRATION_ENABLED(which only gates the public self-service/auth/registerform). - Audit — Promote/demote/enable/disable/invite actions are logged via
log_entity_eventand exposed throughGET /api/v1/admin/users/audit, a cross-user audit view scoped strictly toentity_type="user"(account-status metadata only, never financial data).
Related Documentation
Section titled “Related Documentation”- Cookies & CSRF — Cookie helper module, SameSite rationale, CSRF middleware
- Backend Architecture — Application structure and request flow
- Environment Variables — Configuration reference
- Security — What users see: two-factor authentication, recovery codes and active sessions