Repository navigation
ADR 0021 Browser Auth Flow
Accepted (2026-10-09)
ADR 0015 issues 10 minute access tokens and opaque refresh tokens that rotate on every use; presenting a replaced refresh token revokes the whole login session. Login and refresh already set the refresh token as an HttpOnly, SameSite=Strict cookie limited to /api/v1/identity/session (refreshing with the cookie requires X-Requested-With), but they also returned it in the JSON body, readable by any script on the page. A web app adds problems the API clients do not have: several tabs refreshing at the same moment would trip the reuse detection and log the user out, a response lost on a flaky mobile connection did the same, and Traefik's login rate limit (10 per minute per IP) also covered refresh and logout, which several devices of a household behind one address share. The WebSocket needs a fresh token before the old one expires (ADR 0018).
The design (Design: web app, sections 2 and 7) proposed the flow; this ADR records it as built in Task 10.
The access token lives only in memory (module state in apps/web/src/lib/auth/session.svelte.ts), never in localStorage, sessionStorage or IndexedDB, and never in the service worker. A reload costs one refresh request.
The refresh token lives only in the cookie. The app sends X-Mowdeck-Refresh: cookie-only on login and refresh; Identity then sets the cookie and returns refresh_token: null in the body. Without the header the body still carries the token, so scripts and Home Assistant are unchanged. Refresh and logout carry X-Requested-With; together with SameSite=Strict and the narrow cookie path this is the CSRF defence (a cross-origin request with a custom header needs a preflight that the API never approves). Every other call uses Authorization: Bearer.
Single-flight refresh across tabs. The app refreshes 60 s before exp. The refresh runs inside the Web Lock mowdeck-refresh; inside the lock a tab first adopts a token that another tab obtained less than 30 s ago (BroadcastChannel mowdeck-auth) instead of refreshing. Every new token is broadcast, and so is logout, which returns every tab to the login page.
One retry on 401. The fetch wrapper retries a request once after a refresh; a second 401 or a failed refresh ends the session in the app and routes to /login. 403 and 404 are shown as problems and never retried.
WebSocket re-authentication on every refresh. The Realtime client sends auth with the new token on the open socket; a close with 4401 fetches a fresh token and reconnects.
A short reuse grace in Identity (MOWDECK_IDENTITY_REFRESH_REUSE_GRACE_SECONDS, default 10, 0 disables). A rotated refresh token presented again within the grace, while its successor was never used, counts as a lost response: Identity rotates the unused successor in its place (it stores only hashes, so it cannot hand out the successor again) and audits session.refresh_grace. Only the immediate predecessor of the newest token qualifies; anything else still revokes the family (session.refresh_reuse).
Refresh and logout get their own rate limit. Traefik's identity-auth router limits only login, registration and token exchange (10 per minute, burst 20); the router identity-session gives /api/v1/identity/session/ (refresh, logout) 60 per minute, burst 30.
- Injected script can still use the session while the page is open (mitigated by the strict CSP without inline scripts other than the hashed bootstrap), but it cannot read and exfiltrate the refresh token.
- Every page load costs one refresh request; the access token is never persisted.
- Theft detection is slightly less sharp: within the grace a replayed predecessor whose successor is still unused gets a token instead of revoking the family. The window is configurable and can be turned off.
- Without the Web Locks API the in-tab single flight and the broadcast adoption remain; a lost race there ends in a new login, which is safe.
- Behind HTTPS the cookie needs the
Secureflag (IDENTITY_REFRESH_COOKIE_SECURE=true); Compose development over plain HTTP keeps it off.
Mirrored from docs/adr/0021-browser-auth-flow.md at commit 2c95bd2e71e632d0ce48646e30681257807fac8c. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 2c95bd2e71e632d0ce48646e30681257807fac8c. 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
- ADR 0045: Camera relay
- Development
- Operations
- Spikes
- Guides
- Original plan (German)