Skip to content

ADR 0052 Sign In With Apple

github-actions[bot] edited this page Oct 25, 2026 · 2 revisions

ADR 0052: Sign in with Apple

Status

Accepted (2026-10-25)

Context

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:

  1. 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.
  2. 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.
  3. The password always stays, unlinking is always allowed.
  4. 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_token the token endpoint returns, so MowDeck must validate a JWT (signature, issuer, audience, expiry, nonce) where GitHub needed one GET /user.
  • The client secret is not a static string but an ES256 JWT that MowDeck signs with the Sign in with Apple key (.p8): header kid = the Key ID, claims iss = 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 our SameSite=Lax binding cookie, which is what ties a round trip to the browser. Without a scope Apple allows response_mode=query, a plain redirect.
  • sub is stable for a developer team (the same Apple ID has the same sub for 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.

Decision

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 email scopes to show a nicer label: forces form_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.

Consequences

  • 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 localhost or 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.com outbound (HTTPS).
  • The sub is 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: none and 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 without scope and with the PKCE parameters; whether Apple enforces PKCE; the private relay case (sub is 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.

MowDeck documentation

Clone this wiki locally