Zum Inhalt springen

Sign in with Apple — OAuth 2.1 + OIDC mit form_post-Callback

Live in Produktion

Feinkonzept-Ergänzung · §3.9a Sign in with Apple

Abschnitt betitelt „Feinkonzept-Ergänzung · §3.9a Sign in with Apple“

Einordnung. Ergänzung zu kern/09-auth-self-service. Der Haupttext listet Google Workspace + Microsoft Entra ID als OAuth-Provider auf (§4.3 / §5.2); dieses Addendum erweitert den Provider-Katalog um Sign in with Apple (SIWA) als dritten, gleichwertigen Identity Provider. Motivation: iOS-Monteure, die ihre Apple-ID ohnehin täglich nutzen, bekommen eine systemnative Login-UX (Face ID / Touch ID → direkter Session-Start), ohne dass der Betrieb eine Office-365- oder Workspace-Lizenz braucht. SIWA erfüllt damit das gleiche Ziel wie Entra ID („kein zweites Passwort“), adressiert aber den handwerklichen Kleinbetrieb ohne zentrales IdP.


feature_id: kern/09a-sign-in-with-apple
parent: kern/09-auth-self-service
title: Sign in with Apple — OAuth 2.1 + OIDC mit form_post-Callback
funktionsumfang_ref: §3.9 (Addendum)
roadmap_horizont: MVP
plattformen:
mobile: vollständig # iOS native ASAuthorizationController, Android via Web-Fallback
web: vollständig # Sign-in-with-Apple-Button (JS SDK nicht nötig — direkter OAuth-Flow)
desktop: ab V1.5 bei Bedarf
owner_rolle: Admin # schaltet SIWA pro Tenant (gleiche Policy wie Google/Microsoft)
modul_gate_flag: kern.identity
compliance_flags:
dsgvo: true # Apple ist Third-Country Transfer (USA) — DPA + Standardvertragsklauseln
bfsg: true # SIWA-Button muss WCAG-konform, Kontrast ≥ 4.5 : 1, 48 × 48 Touch
betrvg: true # login_events.oauth.* landet in derselben Chain wie Entra
weitere:
- "Apple Sign In REST API (Apple Developer Docs, 2025-Q4)"
- "RFC 7521 Assertion Framework (client_assertion statt client_secret)"
- "RFC 7523 JWT Bearer Profile"
- "OpenID Connect Form Post Response Mode 1.0"
estimate_eng_tage: 3 # Apple-Helpers + oauth.ts-Erweiterung + Tests + Terraform-Shell
abhängigkeiten:
- kern/09-auth-self-service # Haupt-Flow, Session-Management, login_events
- infra/secrets-manager # Apple-Team-ID + .p8-Private-Key + Services-ID in AWS Secrets Manager

Marktrealität. Der 45-jährige SHK-Meister hat iPhone + MacBook + iCloud — und keine Office-365-Lizenz. Seine Entra-ID heißt „gibt’s nicht“; seine Google-ID heißt „hab ich, aber nur zum Einkaufen“. Apple-ID dagegen hängt am Gerät und ist für ihn der bequemste Login der Welt: ein Tap, Face ID, drin. Ohne SIWA lassen wir diesen User-Typ im Passwort-Fallback — während Apple ihm auf jeder anderen iOS-App den Ein-Klick-Login anbietet. Das ist die schmerzhafteste UX-Lücke im MVP-Auth-Kern.

Technischer Kontext. Sign in with Apple unterscheidet sich von Google/Microsoft in drei relevanten Punkten, die das Abstraktions-Design von kern/09 §5 nicht out-of-the-box abbildet:

  1. Kein client_secret. Apple verlangt pro Token-Request eine JWT-Client-Assertion, signiert mit einem ES256-Private-Key (.p8-Format, von Apple Developer Portal bezogen). Der Key lebt in AWS Secrets Manager; die Assertion wird pro Request für 10 min signiert und enthält iss=teamId, sub=servicesId, aud=https://appleid.apple.com, exp=now+600.
  2. response_mode=form_post statt Query-Redirect. Apple POSTet den Callback an unseren Endpunkt als application/x-www-form-urlencoded (mit CSRF-Nonce via state), nicht als GET ?code=.... Grund: Apple will nicht, dass sensible Payloads (ID-Token mit email) in Browser-History oder Referrer-Logs landen.
  3. Email nur beim ersten Callback. Apple liefert email + name genau einmal — beim allerersten Login. Spätere Logins liefern nur die subject-ID (der opake sub). Wir müssen beim ersten Callback email in oauth_identities.email persistieren — sonst kann der User nie wieder per Apple-Login zugeordnet werden, wenn wir die Session-DB leeren.

Zusätzlich: Apple erlaubt dem User „Meine E-Mail verbergen“ → dann bekommen wir eine generierte Private-Relay-Adresse ([email protected]). Für MVP akzeptieren wir das — Mail an diese Adresse zu versenden funktioniert transparent, Apple leitet weiter. Später kann der Admin die Relay-Adresse in den Audit-Logs als solche markieren.

Schmerzpunkt der Alt-App. Die Alt-App hatte keinen SIWA und verlor laut Vertriebs-Feedback drei von zehn iOS-Handwerker-Demos am „Noch ein Passwort anlegen“-Schritt.

Erwarteter Outcome (SMART).

  • Spezifisch: POST /v1/auth/login/oauth/start akzeptiert provider: 'apple' und liefert eine gültige Authorization-URL; POST /v1/auth/login/oauth/callback akzeptiert sowohl Query (Google/Microsoft) als auch form_post (Apple) und legt eine Session an, wenn die Subject-ID einem existierenden User zugeordnet werden kann.
  • Messbar: 100 % der iOS-Testgeräte schließen den SIWA-Flow in ≤ 3 s vom Button-Tap bis zum authentisierten /v1/auth/me; 0 % akzeptieren eine ID-Token-Signatur, die nicht gegen Apples JWKS verifizierbar ist (getestet durch einen Negativ-Fall mit manipuliertem Token).
  • Achievable: Apples OIDC-Endpunkt ist seit 2019 stabil; JWKS liegt unter https://appleid.apple.com/auth/keys und rotiert selten (alle 6–12 Monate); Caching mit 12-h-TTL reicht.
  • Relevant: Ohne SIWA entgeht uns die wertvollste iOS-Zielgruppe (handwerkliche Einzelmeister mit iPhone und ohne IT-Abteilung).
  • Time-bound: 3 Eng-Tage — Sprint-Slot zwischen MFA-Completion (gerade gelandet) und dem Passkey/WebAuthn-Block.

Apple-Sign-In zielt auf die User-Typen aus §3.2 des Haupt-Feinkonzepts, die bisher „Passwort-only“ wählen:

Rolle Typisches Setup Warum SIWA statt Google/Microsoft
SHK-Meister iPhone + iCloud, kein Büro-365 Apple-ID ist einziger Account, den er täglich nutzt
Maler-Bauleiter iPad auf der Baustelle iPadOS kennt SIWA system-nativ — ein Tap
Azubi-iOS Privates iPhone Hat keine Firmen-Google-Domain
Freier SubU Privat-iPhone, wechselnde Kunden Will nicht in jeden Kundentenant eine eigene Identität anlegen

4. Funktionale Anforderungen (Delta zum Haupt-Feinkonzept)

Abschnitt betitelt „4. Funktionale Anforderungen (Delta zum Haupt-Feinkonzept)“

Alle Anforderungen aus §4 gelten unverändert. Zusätzlich:

  1. provider = 'apple' wird als dritter Wert in den Provider-Enum aufgenommen (DB-Check, Valibot, OpenAPI-Spec).
  2. response_mode=form_post — Apples /start-Response enthält zwingend response_mode=form_post und response_type=code id_token. Ohne beides wirft Apple invalid_request.
  3. Separate Callback-Variante: zusätzlich zum existierenden JSON-POST / Query-GET akzeptiert /callback auch application/x-www-form-urlencoded-POSTs von Apple. Die Felder code, state, id_token, optional user (JSON-Escape, nur bei Erstlogin) werden dem bestehenden Handler wie ein JSON-Body zugeführt.
  4. Client-Assertion signieren: pro Token-Request wird ein ES256-JWT erzeugt (TTL 600 s), als client_assertion mitgeschickt (client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer). Der Private-Key wird aus AWS Secrets Manager gelesen und im Prozess-RAM gecached (Lambda-Cold-Start-Budget: ≤ 100 ms für Key-Fetch beim ersten Request).
  5. ID-Token-Verifikation gegen https://appleid.apple.com/auth/keys (JWKS):
    • Issuer muss https://appleid.apple.com sein.
    • Audience muss der Werkszeit-Services-ID entsprechen (z. B. de.werkszeit.auth).
    • exp > now, iat ≤ now + 60 s (Clock-Drift-Toleranz).
    • nonce im Token muss mit dem in Redis/State-Store hinterlegten Nonce aus /start übereinstimmen.
  6. Private-Relay-Email-Handling: Wenn email.endsWith('@privaterelay.appleid.com'), wird die Adresse in oauth_identities.email gespeichert und in oauth_identities.domain als privaterelay.appleid.com geführt — Admin-Reports können diese User später als „Relay-Identitäten“ filtern. Kein Warn-Popup im Flow (UX-Entscheidung: dem User ist nicht zu erklären, was Relay ist).
  7. JIT-Provisioning-Policy: Apple ist nicht domain-gebunden — anders als hd=musterbetrieb.de bei Google Workspace. In MVP ist die SIWA-JIT-Policy: kein JIT. Der User muss bereits als Werkszeit-User in einem Tenant existieren (Match via email), sonst 403 AUTH-SIWA-UNKNOWN-USER. Grund: ohne Domain-Mapping wissen wir nicht, in welchem Tenant wir den User anlegen sollten; ein einzelner SHK-Meister, der „auch mal“ mit Apple einloggt, kann vom Admin-Portal (kern/09 §9) manuell per oauth_identities-Insert verknüpft werden. Volle JIT (Tenant-Selection-Flow nach erstem Apple-Login) ist Phase-2.
  8. Rate-Limit: Apple-Callback nutzt denselben Rate-Limiter wie Google/Microsoft (oauthCallbackIpLimiter — 30/min pro IP). Die zusätzliche signing-Operation ist günstig (< 2 ms), kein separater Limiter.
  • Revocation via /auth/revoke — Apple bietet einen REST-Endpunkt, um Sessions serverseitig zu invalidieren. Phase 2.
  • Refresh-Token-Rotation — Apple-Refresh-Tokens sind langlebig (180 Tage); wir speichern sie nicht und fordern bei Session-Ablauf einen frischen Login. Phase 2 ergänzt Refresh wenn Demand da.
  • Private-Relay-Adressbuch — keine UI-Hinweise für Admin, welche User Relay nutzen. Logs reichen für MVP.

apps/api/src/db/schemas/auth.ts
export const oauthProviderValues = ['google', 'microsoft', 'apple'] as const;
-- Add 'apple' to the provider check constraint + a nullable email column
-- (Apple reveals email once, on first callback).
ALTER TABLE oauth_identities
DROP CONSTRAINT IF EXISTS oauth_identities_provider_check;
ALTER TABLE oauth_identities
ADD CONSTRAINT oauth_identities_provider_check
CHECK (provider IN ('google','microsoft','apple'));
ALTER TABLE oauth_identities
ADD COLUMN IF NOT EXISTS email text;
-- Index for the Apple JIT-match by email.
CREATE INDEX IF NOT EXISTS oauth_identities_email_idx
ON oauth_identities(email)
WHERE email IS NOT NULL;
// apps/api/src/auth/oauth-pkce.ts — erweitert PROVIDERS
apple: {
authEndpoint: 'https://appleid.apple.com/auth/authorize',
tokenEndpoint: 'https://appleid.apple.com/auth/token',
userInfoEndpoint: '', // Apple liefert keine userinfo — alles aus ID-Token
scope: 'name email',
extraAuthParams: {
response_mode: 'form_post',
response_type: 'code id_token',
},
},
APPLE_SERVICES_ID # z.B. de.werkszeit.auth (Services ID im Apple Developer Portal)
APPLE_TEAM_ID # 10-char Team ID
APPLE_KEY_ID # 10-char Key ID der .p8-Datei
APPLE_PRIVATE_KEY # PEM-formatierter ES256-Private-Key (mehrzeilig)
APPLE_REDIRECT_URI # https://api.werkszeit.de/v1/auth/login/oauth/callback

Alle fünf landen in AWS Secrets Manager (apple/siwa-oauth als JSON-Blob) und werden via App-Runner-Env-Mapping in den Container injiziert — gleiches Pattern wie die bereits hinterlegten apple/key-*-Shells (siehe infra/terraform/envs/prod/main.tf).

Zusätzlich zum bestehenden POST /callback mit JSON-Body akzeptiert der Handler:

POST /v1/auth/login/oauth/callback
Content-Type: application/x-www-form-urlencoded
code=ABC123&state=...&id_token=eyJ...&user={"name":{"firstName":"Ron","lastName":"..."}}

Der Handler erkennt den Content-Type und parst application/x-www-form-urlencoded → delegiert an die gemeinsame Exchange-Logic.


Bedrohung Vektor Mitigation
Gestohlenes .p8-Private-Key-Material S3-Dump, Leaked-Env, Repo-Push Key nur in Secrets Manager, nie im Repo; Prozess-RAM-Cache wird beim App-Runner-Rolling-Update invalidiert; Rotation-Scripts in scripts/rotate-apple-key.sh (Phase 2)
Replay eines abgefangenen id_token MitM, Referrer-Leak nonce-Claim im id_token muss mit State-Store-Nonce aus /start übereinstimmen; state-Store löscht den Eintrag nach Match (one-shot)
iss/aud-Forgery Gefälschtes id_token von fremder Apple-App JWKS-Verify gegen https://appleid.apple.com/auth/keys; Audience-Check gegen APPLE_SERVICES_ID
Private-Relay-Adresse als JIT-Vektor Angreifer mit beliebiger Apple-ID JIT ist deaktiviert — User muss vorher existieren; Admin-Portal ist einziger Weg, SIWA zu einem User zu verknüpfen
Clock-Drift > 60 s Falsch eingestellte Server-Uhr iat-Toleranz ≤ 60 s; Server-Uhr via chrony auf ntp.ubuntu.com (App Runner nutzt Amazon Time Sync)

Unit.

  • apple-client-assertion.ts: ES256-Signature ist mit einem Test-Keypair gegen den Public-Key verifizierbar; Header enthält kid; Claims enthalten iss, sub, aud, exp.
  • apple-jwks.ts: JWKS-Cache hält 12 h, invalidiert bei kid-Miss; abgelehnt werden Tokens ohne kid, mit falschem aud, mit exp < now.

Route-Tests (tests/auth/oauth-apple.test.ts):

  • /start?provider=apple liefert Auth-URL mit response_mode=form_post und response_type=code id_token.
  • /callback mit gültigem form-post (mocked ID-Token + gemocktem Token-Exchange) → 200 mit Session-Cookie + login.succeeded event.
  • /callback mit manipuliertem ID-Token (falsche Signatur) → 401 AUTH-SIWA-INVALID-TOKEN.
  • /callback mit Apple-Subject, der noch keinem User zugeordnet ist → 403 AUTH-SIWA-UNKNOWN-USER.
  • /callback mit Private-Relay-Email → 200 + oauth_identities.domain='privaterelay.appleid.com'.

Integration (tests/integration/oauth-apple.test.ts):

  • Echter Testcontainers-Postgres; mocked HTTP für Apple-Token-Endpunkt und JWKS. Erster Callback schreibt oauth_identities mit email; zweiter Callback desselben Subject findet den Eintrag und legt Session an, ohne email zu re-schreiben.

Compliance.

  • Nach erfolgreichem SIWA-Login muss ein login.succeeded mit method='oauth' im BetrVG-Chain-Log stehen. Shape identisch zu Google/Microsoft — Provider wird nicht in der Event-Payload genannt (Payload-Schlankheit), der Provider-Nachweis lebt im oauth_identities-Insert.

Phase A (T+0 bis T+2). Code (Migration, Helpers, Handler, Tests) hinter dem bestehenden Env-Flag-Pattern. Wenn APPLE_SERVICES_ID leer → /start?provider=apple antwortet 503 AUTH-OAUTH-UNCONFIGURED (gleicher Code-Pfad wie Google/Microsoft).

Phase B (T+3). Apple-Developer-Konfiguration: Services ID anlegen, Return-URL https://api.werkszeit.de/v1/auth/login/oauth/callback, SIWA-Capability aktivieren. Eine der bestehenden apple/key-*-Secrets wird als SIWA-Key designiert (metadata.json updated). Dann terraform apply + App-Runner-Redeploy.

Phase C (T+5). Marketing-Seite (apps/marketing /login) ergänzt den „Mit Apple anmelden“-Button neben „Mit Google“ und „Mit Microsoft“.

Feature-Flags.

  • auth.oauth.apple.allow (per Tenant, default false für neue Tenants — Opt-in durch Admin). Bestehendes auth.oauth.*.allow-Schema aus kern/09 §14.

  1. Apple-Services-ID-Namingde.werkszeit.auth vs. com.werkszeit.auth? Annahme: de.werkszeit.auth, weil Werkszeit eine .de-Marke ist. Falls Apple das ablehnt (Services IDs müssen nicht zwingend Reverse-DNS sein, werden aber so empfohlen), Fallback auf com.werkszeit.auth.
  2. Key-Rotation — Apple-Keys rotieren wir manuell jährlich (Phase 2 automatisiert via scripts/rotate-apple-key.sh). Solange nur eine aktive Services-ID existiert, ist Key-Rotation einfaches Secrets-Manager-Versionieren + App-Runner-Redeploy.
  3. Android ohne Apple-ID — Android-User sehen den Button, können ihn aber nicht nativ nutzen (Apple zeigt Web-Flow). UX-Entscheidung: Button-Text „Mit Apple anmelden“ ohne Android-Hinweis — Apple handhabt den Web-Fallback.
  4. Private-Relay + DSGVO-Auskunft — Art. 15 Auskunftsanspruch funktioniert über die relay-Adresse (Apple leitet weiter). Keine Aktion nötig, aber im DSGVO-Export muss klar sein, dass die Adresse eine Relay ist.

  • Migration 0041 angewendet auf Dev + CI; oauth_identities.provider akzeptiert 'apple'.
  • apple-client-assertion.ts + apple-jwks.ts existieren mit Test-Coverage ≥ 90 %.
  • /v1/auth/login/oauth/start?provider=apple liefert gültige Auth-URL mit response_mode=form_post.
  • /v1/auth/login/oauth/callback akzeptiert Apples form-post-Payload und legt Session an (mit oauth_identities-Insert und login.succeeded-Event).
  • Negative Tests (manipuliertes ID-Token, unbekannter Subject, abgelaufener State) liefern die definierten Fehlercodes.
  • Terraform-Shell apple/siwa-oauth existiert + upload-apple-keys.sh ergänzt um SIWA-Payload.
  • Login-Page rendert Mit Apple anmelden-Button (BFSG-konform: 48 × 48 px, Kontrast ≥ 4.5 : 1).

Stand 2026-04-24