Repository navigation
ADR 0050 Passkeys And App Login Hand Off
Accepted (2026-10-25)
ADR 0002 made login methods plug-ins behind CredentialAuthenticator and RedirectAuthenticator; ADR 0015 listed passkeys as later work. The owner wants to sign in with passkeys (Face ID, Touch ID, Windows Hello, a phone) in the browser and in the iOS and Android apps.
Owner decisions:
- Passkeys replace the password at login: passwordless, discoverable credentials, a "Mit Passkey anmelden" button without a username, plus the autofill offer (conditional UI) where the browser has it. Not a second factor.
- The password always stays as the fallback; an account is never passkey-only, so there are no recovery codes.
- The native apps (ADR 0036) sign in with a passkey through the system browser (iOS
ASWebAuthenticationSession, Android Custom Tab) and return to the app through its URL scheme; it must work for any server address, so no Associated Domains or Digital Asset Links.
Forces:
-
WebAuthn binds a credential to an RP ID (a host name) and checks the origin of every ceremony; it needs a secure context (
https, orhttp://localhost) and cannot use an IP address. A self-hosted server must therefore know the address its users open. -
Web views cannot use passkeys. A
WKWebViewgets WebAuthn only for domains in the app's Associated Domains, an Android WebView only with Digital Asset Links; both are verified through files on a public domain (the reason ADR 0036 has no universal links). The system browser has the user's passkeys for any origin. - The app's session is the refresh cookie in its web view (ADR 0021, ADR 0036). Whatever the system browser does, the app must end up with that cookie, without a token in a URL that a custom-scheme handler, a log or the history could leak.
-
More redirect logins follow. The next planned method is GitHub (a
RedirectAuthenticator); the app must be able to use it the same way without new hand-off code.
Library: py_webauthn (webauthn on PyPI, maintained by Duo Labs / Cisco, BSD-3-Clause, compatible with the AGPL-3.0 service), version 3.0.1 (2026-09-25, older than the two-week rule), pinned with hashes in uv.lock like every dependency. It builds the option sets and verifies challenge, origin, RP ID hash, user presence and verification, the attestation format and the signature. New transitive packages: cbor2 (MIT), pyasn1, pyasn1-modules (BSD-2-Clause); cryptography and pyOpenSSL were already locked. The library sits behind the port PasskeyCeremonies (adapters/webauthn.py), so the use cases do not import it.
The login is a plug-in. PasskeyAuthenticator is a CredentialAuthenticator (credentials {ceremony_id, credential}) and the first ChallengeAuthenticator, a credential authenticator that hands out a single-use challenge first (challenge()). It is registered in the AuthenticatorRegistry only when a relying party is configured; GET /login-methods reports {password, passkey} from the registry, and the web app shows the button only then. SessionService.login_passkey runs it and starts the same session as a password login (_start_session: same JWT claims, refresh token rotation, cookie, session.login audit with method: passkey).
Relying party from the public URL. MOWDECK_IDENTITY_PUBLIC_URL (Compose IDENTITY_PUBLIC_URL, Helm config.IDENTITY_PUBLIC_URL, defaulting to https://<ingress.host>) gives the only accepted origin; its host is the RP ID unless MOWDECK_IDENTITY_PASSKEY_RP_ID names a parent domain. HTTPS is required except for localhost, IP addresses are refused. An unusable value turns passkeys off and is logged; it never stops the service, so a typo cannot lock out the password login.
Ceremonies. Registration and login require user verification and discoverable credentials (residentKey: required), no attestation (none: MowDeck does not restrict authenticators), algorithms EdDSA, ES256 and RS256. The user handle is the user id (16 random bytes, no personal data). Challenges are 32 random bytes, stored in webauthn_challenges with their purpose (and user for registrations), valid for 5 minutes, consumed (deleted) by the first answer whatever its outcome, purged by the maintenance loop. A login finds the passkey by credential id, compares the returned user handle, verifies the assertion and then the signature counter: when either the stored or the new counter is non-zero, a counter that did not grow refuses the login (WebAuthn section 6.1.1, a cloned authenticator); the passkey stays and the owner decides. Synced passkeys report 0 and pass.
Credentials live in passkeys (migration 0003): credential id (unique), COSE public key, sign count, transports, AAGUID, name, created and last used, backup eligible ("synced") and backed up, and the user agent of the registration as a device hint. ON DELETE CASCADE from users.
Management. GET /me/passkeys, POST /me/passkeys/options (the current password again; the per-username failure throttle of the password login applies, failures are counted and audited as passkey.reauth_failed), POST /me/passkeys, PATCH and DELETE /me/passkeys/{id}; at most 20 per user. Admins (can_manage_users) list and remove the passkeys of the account's sub-users (/accounts/{id}/users/{id}/passkeys), the same reach as setting a sub-user's password; persons manage their own. The last passkey can always be removed. Audit: passkey.added, passkey.renamed, passkey.removed (by_admin), passkey.used, failed logins as session.login_failed with method: passkey and the reason.
Rate limits and enumeration. Answers (POST /passkeys/session, POST /app-login/session) and the password check before a registration have the login limit per client IP (in-process and Traefik identity-auth). Challenges and the app's sign-in requests (POST /app-login/start) check nothing and every sign-in page asks for one (the autofill offer), so they have the looser refresh limit (60 per minute, burst 30; Traefik identity-passkey-challenge). No username is ever involved; every failure answers the same 401. Passkey failures write no login_attempts row: a passkey has no secret to guess, and failures must not lock the user's password login.
Native app hand-off, login-method agnostic. The app does not get a passkey endpoint of its own; it takes over a fresh browser login of any kind:
- The app keeps a random PKCE verifier in its web view's memory and registers its S256 hash at
POST /app-login/start, which stores it inapp_login_requestsand answers with a request id (single use, valid 10 minutes). The app opens<origin>/login?app_request=<id>&app_state=<random>withMowDeckShell.openAuthSession(iOSASWebAuthenticationSessionwith an ephemeral session; Android a Custom Tab). The page never sees the challenge, so a link cannot bring a challenge of its own. - In the system browser the user signs in on the normal sign-in page (passkey, or password) and then confirms "Angemeldet als … In der MowDeck-App auf diesem Gerät weitermachen?" (an explicit consent, and the user gesture Android needs for the jump). The page calls
POST /app-login/codewith its access token and the request id: Identity consumes the request (unknown, used or expired: 403), accepts only a user session younger than 5 minutes (a session left in the browser is never handed over silently,app_login.denied), ends that browser session in the same transaction, drops the cookie and returns a one-time code (mda_…, 256 bits, SHA-256 at rest inapp_login_codes, valid for 60 s, bound to the request's challenge, auditedapp_login.code_issued). The page navigates to the fixedmowdeck://auth/callback?code=…&state=…, on Android asintent://auth/callback?…#Intent;scheme=mowdeck;package=app.mowdeck.mobile;end, which only the MowDeck package can receive, and also shows an "App öffnen" link. "Abbrechen" ends the browser login and returnserror=cancelled. The page never accepts a redirect target. - The app receives the callback (iOS as the result of the session, Android as a deep link), checks the state, and redeems code and verifier at
POST /app-login/session: the code is deleted on the first attempt, a wrong verifier burns it, the user must be active, and the app gets a normal session (refresh cookie in the web view,session.loginwithmethod: app_handoff).
The code in the URL is useless without the verifier, which never leaves the app; another app that registers mowdeck:// gets nothing it can redeem (RFC 7636). A phishing link to the sign-in page cannot carry an attacker's challenge: it needs a request id that the attacker registered (the attacker then holds the verifier), which expires within 10 minutes, is used once, and still needs the victim to sign in and confirm "continue in the app"; on Android the code then goes only to the MowDeck package, on iOS to whichever app iOS picks for mowdeck:// outside an ASWebAuthenticationSession. That residual iOS case needs the victim to have a malicious app claiming the scheme installed and to follow the attacker's link within 10 minutes; universal links would close it but need a public domain. The app's web view stores the session as before (the refresh cookie, ADR 0021); the Keychain is not involved.
How a redirect authenticator plugs in. A RedirectAuthenticator (GitHub next) only has to start a normal browser session from its callback, as _start_session does for passwords and passkeys. On the sign-in page opened for the app, its button keeps the app_request/app_state query through its own state (or a return URL to /login?...), and after the callback the page asks for the confirmation and calls POST /app-login/code exactly as after a passkey login. Nothing in the hand-off knows which method started the session.
Options considered:
- WebAuthn inside the web view with Associated Domains / Digital Asset Links: impossible for servers at arbitrary, often internal addresses.
- A passkey-specific app endpoint (the browser's assertion returns a code directly): smaller, but every later login method would need its own app path. Rejected for the generic hand-off after the coordinator's design note.
- Tokens in the callback URL: leak through custom-scheme hijacking and logs. Rejected.
- The challenge in the sign-in URL (the first draft): a crafted link could make the real sign-in page hand a victim's login to a challenge the attacker chose (found in the security review). Replaced by the registered, short-lived request and the explicit confirmation.
- Keeping the browser session after the hand-off: a Custom Tab shares Chrome's cookies, so a forgotten session would sit in the user's browser. The hand-off ends it.
- Attestation and an authenticator allow list: unnecessary for a household installation and would exclude synced passkeys of some providers.
- Passkeys as a second factor or passkey-only accounts: not the owner's choice.
- Passkeys work only when
MOWDECK_IDENTITY_PUBLIC_URLis set and users open MowDeck at exactly that address over HTTPS (orhttp://localhost). Passkeys made for one host do not work at another address of the same server (a LAN name and a Tailscale name are two RP IDs); changing the host later makes existing passkeys unusable (the password still works, and users add new passkeys). - The native app needs a new build for the hand-off (
MowDeckShell.openAuthSession); older builds do not show the button. Adding passkeys happens in a browser; the app then uses them through the system browser. - The login page asks for a challenge on every visit (autofill), which costs one small row each, removed after 5 minutes.
- The CSP needs no change (WebAuthn is not a fetch; the callback is a top-level navigation); the
Permissions-Policyleavespublickey-credentials-*at its defaultself. - Verified with unit and API tests (a software authenticator through the real library), PostgreSQL migrations up and down, and a Playwright test with Chromium's virtual authenticator (register, passkey login, autofill login, hand-off code redeemed, remove). The native halves (
ASWebAuthenticationSession, Custom Tab, deep-link return) are verified only by reading and need the owner's devices.
Mirrored from docs/adr/0050-passkeys-and-app-login-hand-off.md at commit 5968113c667cb4a8b25f17c329e950f2eeb6af3b. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 5968113c667cb4a8b25f17c329e950f2eeb6af3b. 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
- Development
- Operations
- Spikes
- Guides
- Original plan (German)