Repository navigation
ADR 0015 Tokens Sessions And Key Rotation
Accepted (2026-10-09)
ADR 0002 chose a self-built Identity service. The plan asks for short-lived signed tokens (JWT, 5 to 15 minutes) that every service verifies itself with a public key, without a call to Identity per request; for Argon2 passwords, a login rate limit, short sessions and API tokens per client (one for Home Assistant). Services already turn a token into a Principal (mowdeck_contracts.v1.principal) and check object rights in OpenFGA (ADR 0014).
Open points for the first implementation:
- Which algorithm and lifetime, and what goes into the token?
- How are sessions kept alive without long-lived access tokens, and how is a stolen refresh token noticed?
- How do API tokens (long-lived, chosen by a user) fit the "services only verify JWTs" rule?
- How do signing keys rotate without breaking verification in other services?
The full design is in Design: identity and authorization, sections 3 and 4.
EdDSA access tokens, 10 minutes. Identity signs JWTs with Ed25519 (alg: EdDSA, RFC 8037), header typ: at+jwt (RFC 9068) and a kid. Claims: sub, acc (account), kind (user, subuser, api_token), name, roles, perms, plan, sid, jti, iss, aud, iat, nbf, exp. perms is the union of permission keys the subject holds anywhere in the account: an upper bound that services may use as a pre-filter (deny without asking OpenFGA), never as a grant for an object. Object rights always come from OpenFGA. Tokens stay well under 1 KB. PyJWT 2.15.1 does not implement the fully specified Ed25519 algorithm name of RFC 9864, so EdDSA is the only accepted value for now; switching later only changes the signer and the allow list.
Services verify locally. The library mowdeck-auth fetches Identity's JWKS (http://identity:8000/.well-known/jwks.json inside Compose), caches it for 5 minutes, refreshes once when a token names an unknown kid (rate limited to one fetch per 30 s), serves the last good set for at most an hour while Identity is unreachable, and accepts only EdDSA with OKP keys (no none, no HMAC). iss, aud, exp, nbf and iat are checked with a 30 s leeway. Every failure answers 401 with one generic message; the reason is logged.
Opaque, rotated refresh tokens with reuse detection. A login returns a 256-bit random refresh token (stored as SHA-256 only) with a 7 day idle and a 30 day absolute lifetime. Every refresh replaces it and issues a new access token with claims read fresh from the database. Presenting a replaced refresh token again revokes the whole login session (family_id, which is also the JWT sid) and is audited. The web app gets the refresh token additionally as an HttpOnly, SameSite=Strict cookie limited to /api/v1/identity/session; refreshing with the cookie requires an X-Requested-With header.
API tokens are exchanged for JWTs. An API token (mdk_<id>_<secret>, SHA-256 at rest, compared in constant time) is its own OpenFGA subject with explicit grants capped at its creator's rights. A client exchanges it at POST /api/v1/identity/token for a 10 minute access token with kind=api_token and no refresh token. So services only ever verify JWTs; a revoked or expired API token stops working with the next exchange, and its tuples are removed at once.
Signing keys rotate with an announced next key. Keys are stored in Identity's database, private halves encrypted with AES-256-GCM under a key-encryption key from MOWDECK_IDENTITY_KEY_ENCRYPTION_KEY (or _FILE). Statuses: next (published, not signing), active (signing), retiring (published until its tokens expired), retired. A maintenance loop announces a next key when the active one is older than MOWDECK_IDENTITY_KEY_ROTATION_DAYS (default 30), promotes it after an hour (well beyond the 5 minute JWKS cache) and retires the old key an hour later (token lifetime plus skew). The JWKS therefore shows at most the active key, one retiring key and the announced next key, with Cache-Control: max-age=300. The CLI python -m identity.cli rotate-keys --now activates a new key immediately and retire-key drops one at once (emergency); tokens signed with a retired key fail as soon as the services refresh their JWKS (immediately for an unknown kid, at most 5 minutes otherwise).
Passwords and throttling. Argon2id with the RFC 9106 low-memory profile (64 MiB, t=3, p=4; owasp_min with 19 MiB, t=2, p=1 for small hosts), at most four hashes at a time, hashes upgraded after login when the parameters change, a dummy verification for unknown users. Logins and token exchanges are limited per client IP (in-process token bucket, plus Traefik's rateLimit in Compose) and per username (failures within 15 minutes in the database, 429 after 10).
- No service calls Identity per request; Identity being down blocks logins and refreshes, not API calls with valid tokens.
- A disabled user or a removed grant loses object rights at once (tuples are removed, and
identity.grants.changedclears the services' decision caches), but a token-level check such asrequire_permissioncan accept the old access token until it expires (at most 10 minutes). Revocation lists are deliberately not used. - New rights reach the token-level pre-filter with the next access token; the web app refreshes after changing roles.
- Operators must keep the key-encryption key safe and backed up: without it Identity cannot sign, and all sessions must start over after a new key is created.
- Multi-account switching (
/session/switch-account), passkeys, OIDC and GitHub login (theRedirectAuthenticatorinterface exists, the GitHub stub is behind a disabled flag) are later work.
Mirrored from docs/adr/0015-tokens-sessions-and-key-rotation.md at commit a117684672263ba4f0b7e2e3538233456122d24e. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit a117684672263ba4f0b7e2e3538233456122d24e. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- Development
- Operations
- Spikes
- Guides
- Original plan (German)