Repository navigation
ADR 0052 Sign In With Apple
Accepted (2026-10-25)
ADR 0051 built the first RedirectAuthenticator: the generic OAuthAuthenticator on the OAuthClient port, the single-use round trip in oauth_states bound to the browser by the mowdeck_oauth cookie, the external_identities links and the shared Reauthenticator. It said that a second provider is a second OAuthClient behind the same authenticator. The owner asked for Sign in with Apple as that second provider, with exactly the same decisions:
- Only explicitly linked accounts. A signed-in user links their Apple ID in their settings (the current password again); an Apple identity that is not linked is refused with the same generic denial, nothing is ever matched by e-mail address, nobody registers through Apple.
- Server-side configuration only, environment variables and Helm values, the key as a secret file. No Apple configuration in the web app, not even for administrators; only "Mit Apple verknüpfen" / "Verknüpfung lösen" in the user's own settings and "Mit Apple anmelden" on the sign-in page when Apple is configured. Status goes to the Identity startup log and the operations docs.
- The password always stays, unlinking is always allowed.
- Native apps use the existing system-browser hand-off of ADR 0050 unchanged; there is no app build for this.
Forces specific to Apple:
- Apple is an OpenID Connect provider without a user-info endpoint: the account is read from the signed
id_tokenthe token endpoint returns, so MowDeck must validate a JWT (signature, issuer, audience, expiry, nonce) where GitHub needed oneGET /user. - The client secret is not a static string but an ES256 JWT that MowDeck signs with the Sign in with Apple key (
.p8): headerkid= the Key ID, claimsiss= Team ID,iat,exp(at most 180 days),aud=https://appleid.apple.com,sub= the Services ID. - Apple answers with a POST to the return URL (
response_mode=form_post) when the request asks for a name or e-mail scope. A cross-site POST does not carry ourSameSite=Laxbinding cookie, which is what ties a round trip to the browser. Without a scope Apple allowsresponse_mode=query, a plain redirect. -
subis stable for a developer team (the same Apple ID has the samesubfor every app and Services ID of the team) and is the only identifier that never changes. The e-mail may be Apple's private relay address and can be changed by the user. - PKCE (
code_challenge,code_verifier) is not part of the Sign in with Apple parameters we know of, and the developer documentation is rendered by JavaScript and could not be read when this was written (2026-10-25). A recent change of a widely used auth library, which now sends the S256 challenge to Apple, suggests that Apple accepts the parameters, but it cites no Apple source and no test against Apple. Whether Apple supports or enforces PKCE is therefore unconfirmed.
Plug-in. AppleOAuthClient (adapters/apple.py, httpx and PyJWT) implements OAuthClient; AppleAuthenticator is OAuthAuthenticator with that client, registered next to GitHub (authenticators.py, services.py). State, PKCE bookkeeping, the browser binding cookie, single use, expiry, the link and login logic, the audit entries, the redirects back to the web app, the rate limits and the app hand-off are all the code of ADR 0051, unchanged. The callback is <public URL>/api/v1/identity/oauth/apple/callback; the provider key apple is the path segment everywhere (/oauth/apple/start, /me/external-identities/apple/link). GET /login-methods gains the boolean apple. external_identities needs no migration (provider is a free string).
Authorization request. response_type=code, response_mode=query, state, a nonce, and no scope: MowDeck neither wants the name nor the e-mail address (and could not use them), and without scope the callback is a GET redirect that carries the Lax binding cookie. The PKCE S256 challenge is sent as well (code_challenge, code_challenge_method=S256), and the verifier with the token request. Whether Apple checks them is unconfirmed (see above); an OIDC server ignores parameters it does not know and enforces the ones it does, so sending them is harmless in the first case and a gain in the second. State, the browser binding and the nonce carry the security of the flow either way. MOWDECK_IDENTITY_APPLE_PKCE=false leaves PKCE out should Apple ever refuse the request because of it.
Nonce. The nonce of a round trip is its PKCE S256 challenge. The browser's cookie holds the verifier, from which the callback recomputes the challenge, so the client needs no extra state and the port stays unchanged; the ID token must carry exactly this nonce.
Client secret. Generated by the client from the .p8 key: ES256, kid = Key ID, iss = Team ID, sub = Services ID, aud = https://appleid.apple.com, lifetime one hour, cached and replaced five minutes before it ends. The key is read once at startup (MOWDECK_IDENTITY_APPLE_PRIVATE_KEY_FILE) and must be an unencrypted EC P-256 PEM; it is never written to the database, logged or sent anywhere but used as a signing key in memory.
ID token validation (AppleOAuthClient._validate): the header must name a kid and RS256 or ES256; the signature is checked against Apple's key set (https://appleid.apple.com/auth/keys, cached, read again after 24 hours or for an unknown kid, at most once a minute so a forged token cannot make MowDeck hammer Apple); then iss = https://appleid.apple.com, aud = the Services ID, exp, iat (30 seconds of leeway), the presence of sub, and the nonce in constant time. alg: none and symmetric algorithms are rejected by the allow-list. The account is the sub (at most 255 characters); the stored display_name is the neutral label "Apple ID". Every other claim, the e-mail ones included, is ignored.
Errors. invalid_client, unauthorized_client and unsupported_grant_type from the token endpoint, a 5xx, a network error and an unreadable key set are unavailable (the installation's settings or Apple are at fault, logged); everything else the token endpoint refuses and every invalid ID token is denied. Apple's error user_cancelled_authorize ends as cancelled like OAuth's access_denied.
Tokens. Apple's access and refresh tokens are dropped unread: not stored, not logged, not revoked (as for GitHub since the amendment of 2026-10-25; Apple's revocation endpoint is not used). Unlinking in MowDeck removes only MowDeck's link; the user can remove the authorization in the Apple ID settings, which the unlink dialog says ("Anmeldung und Sicherheit → Mit Apple anmelden").
Configuration (server side only): MOWDECK_IDENTITY_APPLE_SERVICES_ID, _TEAM_ID, _KEY_ID, _PRIVATE_KEY_FILE and MOWDECK_IDENTITY_PUBLIC_URL (the return URL). IDs are validated by shape (Team ID and Key ID: ten upper-case letters or digits). Apple is on only when all are set and the key file is a readable P-256 PEM; otherwise Identity logs Apple login is off: … with the reason (a wrong value never stops the service, in particular not the password login) and when on Apple login is on with the return URL to register. The endpoints (_AUTHORIZE_URL, _TOKEN_URL, _KEYS_URL, _ISSUER) are settings with Apple's defaults, used by the test stack's fake. Compose mounts infra/compose/secrets/apple/ (git-ignored) at /run/secrets/apple; Helm takes services.identity.apple.{enabled, servicesId, teamId, keyId, existingSecret, secretKey}. Traefik and the Ingress rate limits cover the callback and the link start (login limit) and the start (looser limit) as for GitHub.
Dependency. PyJWT, already in uv.lock through mowdeck-auth (with cryptography, already an Identity dependency), is declared by Identity too (pyjwt[crypto]>=2.10.1, the same specifier as mowdeck-auth). No new package enters the lock file; jwt.encode signs the client secret, jwt.decode validates the ID token with an algorithm allow-list.
Web. The GitHub panel becomes ExternalLinkPanel (provider key, texts and logo as parameters) with GitHubPanel and ApplePanel as thin wrappers; auth/oauth.ts holds the provider-independent round trip with github.ts and apple.ts binding it. The linked account shows "Verknüpft mit Apple" (there is no e-mail to show). The sign-in page offers one button per configured provider. Later the two panels became one "Linked accounts" section (LoginLinksPanel, the providers listed in linked-providers.ts): linked providers as a list, offered but unlinked providers as buttons side by side; a further provider is one more list entry.
Options considered:
-
Request the
name emailscopes to show a nicer label: forcesform_post, which breaks the cookie binding (a looser binding would need a new mechanism), and Apple returns the name only the first time. Rejected: nothing in MowDeck needs them. -
Make the nonce independent of the PKCE challenge (store it in
oauth_states): needs a migration and a port change for no gain, since the browser-bound verifier already ties the nonce to the browser. - A new OIDC library (Authlib, joserfc): a new dependency for a JWT check PyJWT already does; PyJWT is in the lock file.
- A static client secret generated by the operator: Apple allows up to six months; expiry would silently turn the login off. Generating it in process removes that operations burden.
- Apple's native "Sign in with Apple" in the iOS app (ASAuthorization, bundle identifier as audience): a later app build; the server could accept a second audience then. Out of scope by decision 4.
- Other self-hosters need an Apple Developer Program membership of their own (paid) to register a Services ID and a key; without it the login stays off and nothing else changes. The return URL domain must be the installation's public HTTPS address; Apple does not accept
localhostor IP addresses, so there is no local development against real Apple. - A Services ID has exactly the registered return URLs; a second address needs its own entry (up to Apple's limit) or Services ID.
- Apple never calls the server; the browser carries the redirect. Identity must reach
appleid.apple.comoutbound (HTTPS). - The
subis the same for every Services ID and App ID of the team, so a later native Sign in with Apple in the app can link to the same accounts without migration. - Apple shows its consent screen the first time a user signs in to a Services ID, and again if the user removed MowDeck in their Apple ID settings.
- Verified with unit and API tests against a strict fake Apple (httpx mock transport: ES256 client secret checked with the registered key, code single use, PKCE verifier, RS256 ID token from a published key set; wrong audience, issuer, nonce, expiry, issue time, missing or odd
sub, bad signature, unknown key id,alg: noneand HS256 all refused; key rotation and the refresh limits), the unchanged round trip tests of ADR 0051, web unit tests and a Playwright test against the test stack's fake Apple container. Not verified: the real appleid.apple.com (the owner registers the Services ID); that Apple accepts a request withoutscopeand with the PKCE parameters; whether Apple enforces PKCE; the private relay case (subis used, the e-mail ignored); the native apps on a device.
Mirrored from docs/adr/0052-sign-in-with-apple.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)