diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c657ed..a087aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an [Keep a Changelog](https://keepachangelog.com), Versionierung nach [Semver](https://semver.org). +## [1.1.1] — 2026-05-13 + +Patch — Wire-Konvention für authenticated Account-Calls geklärt. + +### Geändert + +- `AuthClient.changeEmail`, `changePassword`, `deleteAccount` senden + jetzt den Session-Token (`refreshToken`-Feldwert) statt des JWT als + `Authorization: Bearer`. Hintergrund: Server-seitig wurde in + `mana-auth` Better Auths `bearer`-Plugin aktiviert + (`requireSignature: false`), das Session-Tokens zu Session-Cookies + konvertiert. Damit funktionieren `auth.api.changeEmail` etc. für + Native-Apps ohne Cookie-Container. +- `AuthClient.currentSessionToken()` als public Helper hinzu. Symmetrisch + zu `currentAccessToken()`. + +### Trade-Off bewusst akzeptiert + +Session-Token wird bei jedem Account-Call versendet (vorher nur beim +`/refresh`). Mit TLS-Baseline akzeptables Risiko; Compromise-Surface +nicht relevant größer als JWT-Leak. Alternative wäre ein Custom- +Bearer-JWT-to-Cookie-Resolver im Server (40+ Zeilen Hono-Middleware, +HMAC-Cookie-Synthese) — bewusst nicht gewählt, weil der bearer-Plugin +genau für diesen Use-Case existiert. + +### Tests + +- Test `changePassword schickt Bearer-Header` umbenannt auf + `schickt Session-Token als Bearer (nicht JWT)` und geupdated. + ## [1.1.0] — 2026-05-13 Phase 1 aus dem Native-Auth-Vollausbau-Plan (Option A — alles nativ, diff --git a/Sources/ManaCore/Auth/AuthClient+Account.swift b/Sources/ManaCore/Auth/AuthClient+Account.swift index d16b017..f101f03 100644 --- a/Sources/ManaCore/Auth/AuthClient+Account.swift +++ b/Sources/ManaCore/Auth/AuthClient+Account.swift @@ -17,16 +17,19 @@ import Foundation /// - `POST /change-password` — Passwort ändern (current + new) /// - `DELETE /account` — Account löschen (App-Store-Pflicht 5.1.1(v)) /// -/// **Server-Limitation (Stand 2026-05-13):** `change-email`, -/// `change-password` und `DELETE /account` forwarden Original-Request- -/// Headers an Better Auth. Better Auth liest Session-Cookies. Der -/// `bearer`-Plugin von Better Auth ist NICHT installiert, daher -/// scheitern diese Endpoints heute mit reinem `Authorization: Bearer`. -/// → Server-Fix in Phase 3 nötig (entweder `bearerPlugin()` in -/// `better-auth.config.ts` aktivieren oder Custom-Bearer-Resolver in -/// `mana-auth/src/routes/auth.ts` ergänzen). ManaCore sendet Bearer -/// bereits korrekt — sobald der Server das akzeptiert, funktionieren -/// die Methoden ohne Swift-Änderung. +/// **Wire-Konvention für authenticated Account-Calls:** +/// `change-email`, `change-password` und `DELETE /account` forwarden +/// die Original-Request-Headers an Better Auth (`auth.api.changeEmail` +/// etc.). Better Auth's `bearer`-Plugin (seit 2026-05-13 in mana-auth +/// aktiv) konvertiert `Authorization: Bearer ` in +/// einen synthetischen Session-Cookie. **Native-Apps senden hier den +/// Session-Token (`refreshToken`-Feldwert aus /login bzw. /refresh), +/// NICHT den JWT.** Der JWT bleibt für app-eigene Backends +/// (memoro-api, cardecky-api etc.) der richtige Header. +/// +/// Trade-Off: Session-Token wird häufiger versendet als der reine +/// Refresh-Pfad. Bei TLS-only-Baseline akzeptabel; Compromise-Surface +/// nicht relevant größer als JWT-Leak. public extension AuthClient { // MARK: - Registrierung @@ -296,7 +299,9 @@ public extension AuthClient { extension AuthClient { /// Generischer JSON-POST/DELETE-Helper. Wenn `authenticated == true`, - /// wird der aktuelle Bearer-Token mitgeschickt. + /// wird der Session-Token (`refreshToken`-Feldwert, von Better Auth + /// als Session-ID interpretierbar via `bearer`-Plugin) als Bearer- + /// Header mitgeschickt — NICHT der JWT. Siehe Doc-Header dieser Datei. fileprivate func postJSON( path: String, method: String = "POST", @@ -308,7 +313,7 @@ extension AuthClient { request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Content-Type") if authenticated { - let token = try currentAccessToken() + let token = try currentSessionToken() request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } do { diff --git a/Sources/ManaCore/Auth/AuthClient.swift b/Sources/ManaCore/Auth/AuthClient.swift index c11ab2e..4b2de5c 100644 --- a/Sources/ManaCore/Auth/AuthClient.swift +++ b/Sources/ManaCore/Auth/AuthClient.swift @@ -133,6 +133,19 @@ public final class AuthClient { return token } + /// Liefert den Session-Token (das wire-protocol `refreshToken`-Feld). + /// Wird von Better-Auth-`auth.api.*`-Endpoints akzeptiert wenn der + /// `bearer`-Plugin server-seitig aktiv ist — z.B. für `changeEmail`, + /// `changePassword`, `deleteAccount`. Der App-eigene JWT (aus + /// `currentAccessToken`) gilt für app-spezifische Backends, der + /// Session-Token nur für mana-auths Account-Aktionen. + public func currentSessionToken() throws -> String { + guard let token = keychain.getString(for: .refreshToken) else { + throw AuthError.notSignedIn + } + return token + } + /// Liefert einen Access-Token, der nicht innerhalb der nächsten /// `refreshLeeway` Sekunden abläuft. Refreshed proaktiv, wenn nötig. public func freshAccessToken(refreshLeeway: TimeInterval = 300) async throws -> String { diff --git a/Tests/ManaCoreTests/AuthClientAccountTests.swift b/Tests/ManaCoreTests/AuthClientAccountTests.swift index cd743c0..8c63005 100644 --- a/Tests/ManaCoreTests/AuthClientAccountTests.swift +++ b/Tests/ManaCoreTests/AuthClientAccountTests.swift @@ -201,12 +201,16 @@ struct AuthClientAccountTests { } } - @Test("changePassword schickt Bearer-Header wenn eingeloggt") + @Test("changePassword schickt Session-Token als Bearer (nicht JWT)") func changePasswordSendsBearer() async throws { let (client, _) = Self.makeClient() - // Mock-Token im Keychain ablegen via persistSession-Helper. + // Authenticated Account-Calls senden den Session-Token (refreshToken) + // statt des JWT, weil server-seitig Better Auths bearer-Plugin + // den Session-Token zu einem Session-Cookie konvertiert. Siehe + // AuthClient+Account.swift Doc-Header. let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" - try client.persistSession(email: "u@x.de", accessToken: access, refreshToken: "r") + let session = "session-token-value" + try client.persistSession(email: "u@x.de", accessToken: access, refreshToken: session) let captured = MockURLProtocol.Capture() MockURLProtocol.handler = { request in @@ -216,7 +220,7 @@ struct AuthClientAccountTests { try await client.changePassword(currentPassword: "alt", newPassword: "neu") let request = try #require(captured.request) - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(access)") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(session)") #expect(request.url?.path == "/api/v1/auth/change-password") }