Repository navigation
ADR 0057 Step Up Reauthentication
Accepted (2026-10-27)
A MowDeck session lives for days (7 days idle, 30 absolute, ADR 0015): the refresh cookie keeps the browser or the app signed in. Whoever gets hold of an open browser, a phone left unlocked or a stolen session can therefore delete mowers, unlink the cloud account, mint API tokens, hand out admin roles or remove the owner's passkeys. Only some paths asked for the password again (adding a passkey, linking GitHub or Apple, changing the password, deleting the account), each with its own password field.
Wanted: a fresh-authentication requirement ("step-up") for the critical actions: the user must have confirmed with a passkey (preferred, user verification required) or the password within the last N minutes (default 5, configurable). Every service that owns a critical action must be able to check it without calling Identity, the web app must handle it generically, and API tokens (Home Assistant) are not interactive.
Claims. The access token carries auth_time (epoch seconds of the user's last interactive authentication) and amr (how: pwd, passkey, github, apple), the OpenID Connect claims of the same names. Principal has auth_time and auth_methods; API tokens and services have neither.
Where the time lives. On the login session: sessions.auth_time and sessions.auth_method (migration 0005). A login sets them to now (password, passkey, GitHub, Apple alike: a provider login is a real, interactive authentication). A refresh copies them to the successor, so a 10 minute token that is renewed keeps the time of the last real authentication and the window really ends. A native app hand-off carries over the time and method of the browser login that is handed over. Sessions from before the migration have no time: they count as stale, the first critical action asks once.
Step-up endpoints (Identity, signed-in users with a login session; API tokens get 403):
-
POST /me/step-up/password{password}: the password check of the existingReauthenticator(login limit per address, the per-user failure throttle, a wrong password counts as a failed login and is audited asstep_up.reauth_failed). -
POST /me/step-up/passkey/optionsandPOST /me/step-up/passkey: the passkey ceremony of the login (user verification required, single-use challenge, looser limit for the options and the login limit for the answer). The assertion must come from a passkey of the caller; another user's passkey fails (step_up.failed). - Success stamps the live session of the login session with now and the method, issues a new access token (same session, current claims,
auth_time= now, 10 minute lifetime) and writesstep_up.succeeded. No refresh token and no cookie are involved; the response isTokenOut.
Check in the services. mowdeck_auth.require_recent_auth(max_age=None) is a FastAPI dependency (and ensure_recent_auth(principal, ...) a function for use cases) in mowdeck-auth. Declared after the permission dependency, so a caller without the right still gets 403/404 first. Fresh: the request goes on. Missing or too old (or a future time more than a minute ahead): 401 urn:mowdeck:problem:reauth-required with the RFC 9470 challenge WWW-Authenticate: Bearer error="insufficient_user_authentication", max_age="300". Window: MOWDECK_AUTH_STEP_UP_MAX_AGE_SECONDS, default 300, clamped to 30..3600, read by every service from its own environment (one value in Compose or Helm).
API tokens and services can never confirm: require_recent_auth answers 403 urn:mowdeck:problem:step-up-unavailable ("this action needs an interactive sign-in"). Deliberately conservative; there is no scope that waives it. A token that needs one of these actions is the wrong tool: do it in the web app. The Home Assistant integration uses none of the protected actions (checked: commands, firmware check, camera, telemetry, settings reads and the like only).
Protected actions (what was verified to exist, and where):
| Action | Endpoint | Service |
|---|---|---|
| Delete a mower | DELETE /fleet/mowers/{id} |
Fleet |
| Remove a linked Mammotion cloud account | DELETE /fleet/linked-accounts/{id} |
Fleet |
| Create an API token (its grants are fixed at creation: changing scopes means a new token) | POST /identity/accounts/{id}/api-tokens |
Identity |
| Delete the account | DELETE /identity/accounts/{id} |
Identity |
| Create a sub-user, remove a user or sub-user, set a sub-user's password |
POST .../users, DELETE .../users/{id}, POST .../users/{id}/password
|
Identity |
| Change roles and admin rights |
POST/DELETE .../role-assignments, PATCH .../roles/{id}
|
Identity |
| Remove a passkey (own, or a sub-user's as admin) |
DELETE /me/passkeys/{id}, DELETE .../users/{id}/passkeys/{id}
|
Identity |
| Unlink GitHub or Apple | DELETE /me/external-identities/{provider} |
Identity |
| Add a passkey, link GitHub or Apple |
POST /me/passkeys/options, POST /me/external-identities/{p}/link
|
Identity |
| Delete the account (owner) | (above) | Identity |
Unified with the older password re-checks: adding a passkey, linking a provider and deleting the account keep the optional password field (existing clients and tests); without it the request needs a recent step-up. The web app uses the one sheet instead of three password fields.
Not protected, with reasons. Change password already needs the current password in the request: that is a confirmation at least as strong as a step-up. Firmware update (firmware_update is a mower command): it is vendor-signed, the mower recovers from a failed update, and it is an ordinary command that automations (Home Assistant) may legitimately send; stopping it would break those and the generic command channel, for little gain. Theft alarm settings (notification preferences and geofences): personal, reversible, and turning off an alert deletes no data and grants no access; a later task can protect geofence removal if wanted. Disabling a user, revoking or purging an API token, logging out everywhere: they only reduce access, and in an incident they must stay quick. Mower settings, commands, schedules, camera: operational, not account security.
Web app. The API client recognises the 401 reauth-required before its refresh logic (the session is fine, a refresh would not help and a second 401 must not end the session). It asks the StepUp controller, which opens one re-authentication sheet (passkey button when the browser and the installation support it, password field always), calls the step-up endpoint with a plain fetch (a wrong password is a 401 too and must not run through the retrying client), hands the new token to the session (and to the other tabs through the existing BroadcastChannel), and the client repeats the request once. Dismissing the sheet returns the original problem to the caller.
Native apps. Passkeys cannot run inside the Capacitor web view (ADR 0050). The sheet therefore offers only the password there, with a short note; every user has a password (it always stays). A step-up confirmation through the system browser (a hand-off like the login) would give passkey users the same convenience in the app; it needs a new short-lived server-side request and one-time code and is left as a follow-up, since the password path is complete and secure. The session of an app that signed in through the hand-off carries the browser login's auth_time, so the first critical action right after signing in does not ask again.
Audit and limits. step_up.succeeded (method, session, token id, passkey id), step_up.failed (passkey), step_up.reauth_failed (password; also counted in login_attempts, so the password login throttle applies to guessing here too). The address limit of the login applies to the password and passkey answers; the options use the looser challenge limit.
- One mechanism for every service: a new critical endpoint adds
Annotated[Principal, Depends(require_recent_auth())]after its permission check. Services need no call to Identity and no new secret. - Clock: services compare
auth_timewith their own clock. Anauth_timemore than a minute in the future counts as stale, so clock skew shows as an extra prompt, never as a hole. - A token stays valid for 10 minutes, but a stolen access token alone never carries a fresh
auth_timebeyond the 5 minute window of the real user's last confirmation; a stolen session (refresh cookie) must prove a passkey or the password. - Roles and permissions in the step-up token are re-read from the database like on a refresh.
- Deleting the account keeps working with a password in the body; a client that sends none gets the sheet.
- A rejected idea: a separate short-lived "step-up token" sent in a second header. It would need its own verification, its own header handling in every client and would not survive the page reload as simply; the claim in the normal token is checked by the same verifier everywhere.
Mirrored from docs/adr/0057-step-up-reauthentication.md at commit f656a253cc0cf2f0e69bf4bbd29ef05a6b512e74. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit f656a253cc0cf2f0e69bf4bbd29ef05a6b512e74. 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
- ADR 0046: Device settings as capabilities
- ADR 0047: The RTK base station as a Fleet device
- ADR 0048: Maintenance commands
- ADR 0049: Manual driving over the Mammotion cloud
- ADR 0050: Passkeys and the app login hand-off
- ADR 0051: Sign in with GitHub
- ADR 0052: Sign in with Apple
- ADR 0053: Retention and downsampling of the position track
- ADR 0054: Aerial photo under the mower map
- ADR 0055: Deploy maintenance window
- ADR 0056: Camera photo in push notifications for errors and theft alarms
- ADR 0057: Step-up re-authentication for critical actions
- ADR 0058: Theft tracking mode
- ADR 0059: iOS widgets and the mowing Live Activity
- ADR 0060: Apple Watch app
- ADR 0061: Mowing angle rotation
- ADR 0062: Setup tour for new users
- ADR 0063: Matter server (mowers in Apple Home and Google Home)
- ADR 0064: Personal access tokens and the public API documentation
- Development
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Building the Bluetooth bridge (ESPHome on a XIAO ESP32-S3)
- Releases
- Billing operations
- Telemetry: retention and position downsampling
- iPhone and Android apps
- Passkeys
- Sign in with GitHub
- Aerial photo under the map
- Sign in with Apple
- Theft tracking mode
- Matter server
- Source code offer (AGPL-3.0 section 13)
- Spikes
- Guides
- API reference
- Original plan (German)