v1.1.1 — Session-Token statt JWT für Account-Calls

Wire-Konvention für authenticated Account-Endpoints (changeEmail,
changePassword, deleteAccount) geklärt. Server-seitig wurde in
mana-auth Better Auths bearer-Plugin aktiviert (requireSignature:
false), das Session-Tokens zu Session-Cookies konvertiert. Native-
Apps senden daher jetzt den Session-Token (refreshToken-Feldwert)
statt des JWT als Authorization: Bearer für diese drei Endpoints.

Der JWT bleibt für app-eigene Backends (memoro-api, cardecky-api,
manaspur-api) der richtige Authorization-Header — die Trennung ist
nur für mana-auth interne Endpoints.

currentSessionToken() als public Helper hinzu (symmetrisch zu
currentAccessToken).

38/38 Tests grün.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 19:35:57 +02:00
parent 716509e10e
commit 3459c78731
4 changed files with 68 additions and 16 deletions

View file

@ -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,

View file

@ -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 <session-token>` 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<Body: Encodable>(
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 {

View file

@ -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 {

View file

@ -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")
}