Authentication¶
The JSON API at /api/v1/* uses bearer authentication. Tokens are
issued by the /api/v1/auth/login endpoint and are JWT-signed with
SAEBOOKS_SECRET_KEY.
Bearer types¶
JWT bearer (standard)¶
Issued by POST /api/v1/auth/login (email + password) or by
POST /api/v1/auth/refresh. Claims:
{
"sub": "uuid-of-user",
"tenant_id": "uuid-of-tenant",
"role": "owner | admin | accountant | bookkeeper | viewer",
"pwv": 0,
"iat": 1714400000,
"exp": 1714428800
}
pwv is the user's password_version counter. A password reset increments
it, which invalidates every previously-issued token globally on the next
request. Tokens issued before migration 0077 carry no pwv claim; the
missing claim is treated as 0, matching the column default.
Default TTL is 8 hours (expires_in: 28800). The POST /auth/refresh
endpoint accepts an unexpired access token (via Authorization: Bearer header
or {"refresh_token": "<token>"} body) and issues a fresh one — there is no
separate long-lived refresh token.
The middleware loads the User row for sub and stamps
request.state.user and request.state.role. Server-side role gates
key off request.state.role, not the role claim directly — so a
user whose role was downgraded between token issue and use is bound by
the current DB role, not the stale claim.
Machine API token¶
Issued via POST /api/v1/api-tokens (requires an active JWT session — you cannot
bootstrap a token from a token). The cleartext token is shown once and
takes the format:
The saebk_ prefix lets require_bearer route to the API-token branch
without attempting JWT decode. Tokens are bcrypt-hashed server-side and
verified per-request. Optional scopes restrict access ("read" for safe
methods, "write" for mutating methods; empty/null means full access).
Optional ttl_days sets an expiry; omit for non-expiring.
Manage tokens at GET /api-tokens, DELETE /api-tokens/{id}.
Static dev token (scripts / testing only)¶
SAEBOOKS_DEV_API_TOKEN in the environment. When this token is
presented as Authorization: Bearer <token>, the request is treated as
authenticated without a real User row. Admin gates fall back to checking
X-Admin: true in this mode.
Production
Do not set SAEBOOKS_DEV_API_TOKEN in production.
Admin gate¶
Endpoints that require admin role check:
request.state.roleisowneroradmin(preferred path)X-Admin: true— only honoured on the static-token path
A bookkeeper-role JWT presenting X-Admin: true is rejected with 403.
Session flow (saebooks-web)¶
The browser UI (saebooks-web) does not hold a JWT in localStorage.
Instead:
POST /loginon the web process validates credentials via the API, receives a JWT, and stores it in a signed server-side session cookie.- Every subsequent page request reads the JWT from the session and
presents it as
Authorization: Bearer …in the proxied API call. GET /auth/discourse/login(DiscourseConnect) also reads the JWT from the session to fetch the user profile and sign the identity payload for Discourse.
Session cookies are SameSite=strict, HttpOnly, signed with
SAEBOOKS_WEB_SECRET_KEY using itsdangerous. They are not JWTs.
Password reset / magic link¶
| Flow | Steps |
|---|---|
| Password reset | POST /auth/password-reset/request → email → POST /auth/password-reset/confirm → JWT |
| Magic link | POST /auth/magic-link/request → email → POST /auth/magic-link/consume → JWT |
| Email verification | After signup → POST /auth/verify-email → JWT |
Examples¶
# Login
TOKEN=$(curl -sX POST https://app.saebooks.com.au/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"…"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
# Authenticated request
curl https://app.saebooks.com.au/api/v1/auth/me \
-H "Authorization: Bearer $TOKEN"
# Hard-delete (admin only)
curl -X DELETE "https://app.saebooks.com.au/api/v1/invoices/$ID?hard=true" \
-H "Authorization: Bearer $ADMIN_TOKEN"