Skip to content

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:

saebk_<64 lowercase hex chars>

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:

  1. request.state.role is owner or admin (preferred path)
  2. 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:

  1. POST /login on the web process validates credentials via the API, receives a JWT, and stores it in a signed server-side session cookie.
  2. Every subsequent page request reads the JWT from the session and presents it as Authorization: Bearer … in the proxied API call.
  3. 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.

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"