Files
Vorrania/ios/Sources/OIDCLogin.swift
T
ScarriffleandClaude Opus 5 3d0ff54bf5 Anmeldung wahlweise ueber OpenID Connect (Authentik)
Neben dem Passwort-Login gibt es jetzt einen zweiten, gleichberechtigten Weg:
OpenID Connect gegen einen selbst gehosteten Anmeldedienst. Ohne Konfiguration
aendert sich nichts - keine SSO-Knoepfe, Passwort-Login wie bisher.

Der Anmeldedienst ist NUR fuer die Anmeldung zustaendig. Danach stellt Vorrania
sein eigenes Token aus wie beim Passwort-Login, deshalb bleiben deps.py, die
Rollenpruefung und die langlebigen API-Tokens unveraendert.

Backend
- Anbieter kommen aus der Umgebung (OIDC_PROVIDERS + OIDC_<KUERZEL>_*). Ein
  zweiter Anbieter ist damit reine Konfiguration, ohne Codeaenderung.
- Browser-Weg (vertraulicher Client) und App-Weg (oeffentlicher Client, PKCE)
  enden beide im selben Token-Schema.
- state/nonce/PKCE-Verifier liegen in einer kurzlebigen Tabelle statt in einem
  Cookie: Starlettes CORS-Schicht spiegelt bei allow_origins=["*"] zusammen mit
  allow_credentials die Herkunft zurueck, sobald ein Request ein Cookie traegt.
  Ohne Cookie entsteht diese Wechselwirkung erst gar nicht - und der Ablauf
  bleibt mit der db-Fixture pruefbar.
- Zuordnung ueber die sub des Anbieters; die Erstverknuepfung laeuft ueber die
  BESTAETIGTE E-Mail-Adresse. Rollen kommen nie aus dem Anbieter, und ein
  gleichlautender preferred_username uebernimmt nie ein bestehendes Konto.
- users.password_hash darf jetzt leer sein (reine SSO-Konten). verify_password
  faengt das ab, sonst antwortete der Login mit 500 statt 401.

Verknuepfen bestehender Konten
- Neue Seite "Mein Konto" (fuer alle Benutzer, nicht nur Admins): verknuepfen
  und wieder loesen. Die Berechtigung stammt aus der laufenden Sitzung, es
  braucht also weder eine hinterlegte Adresse noch Admin-Rechte.
- Der Start laeuft ueber POST, weil eine Seitennavigation das Bearer-Token
  nicht mitschicken kann.
- Die letzte Anmeldemoeglichkeit eines Kontos laesst sich nicht loesen.

iOS
- AppAuth (erste Paketabhaengigkeit) mit PKCE im System-Browser, nie in einem
  eingebetteten WebView, und ohne Client-Secret.
- offline_access nur im App-Weg: der Browser-Weg braucht kein Refresh-Token und
  bekaeme sonst einen ueberfluessigen Zustimmungsdialog.
- Anmeldezustand je Serverprofil im Keychain; abgelaufene Token erneuert die App
  still im Hintergrund. Gleichzeitige 401er teilen sich eine Erneuerung.

Deployment
- docker-compose reicht die ganze .env durch. Einzeln aufzuzaehlen ginge nur mit
  fest verdrahteten Kuerzeln - genau das soll ein weiterer Anbieter nicht
  erfordern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:55:38 +02:00

223 lines
8.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppAuth
import Foundation
import UIKit
/// Anmeldung ueber einen OpenID-Connect-Dienst (z. B. Authentik).
///
/// Der gesamte Vorgang laeuft in AppAuth und damit im **System-Browser**
/// (`ASWebAuthenticationSession`) – nie in einem eingebetteten WebView. Das ist
/// keine Geschmacksfrage: nur so sieht der Nutzer die echte Adresszeile, und die
/// App bekommt sein Passwort nie zu Gesicht.
///
/// Die App ist beim Anmeldedienst ein **oeffentlicher** Client: kein
/// Client-Secret, dafuer PKCE (das setzt AppAuth bei diesem Aufbau von selbst).
///
/// Der Anmeldezustand (inkl. Refresh-Token) liegt je Serverprofil im Keychain.
@MainActor
final class OIDCLogin {
static let shared = OIDCLogin()
/// Muss gehalten werden, solange der Browser offen ist – sonst kann der
/// Rueckweg nicht mehr zugestellt werden.
private var laufenderFlow: OIDExternalUserAgentSession?
/// Laufende Erneuerungen je Profil. Ohne das loesen mehrere gleichzeitige
/// 401er ebenso viele parallele Erneuerungen aus – und alle bis auf eine
/// scheitern, weil Authentik ein Refresh-Token nur einmal einloest.
private var laufendeErneuerung: [UUID: Task<String, Error>] = [:]
private init() {}
nonisolated static func keychainAccount(for id: UUID) -> String {
"vorrania-oidc-\(id.uuidString)"
}
// MARK: - Anmelden
/// Fuehrt den Anmeldevorgang durch und liefert das ID-Token, das der Server
/// gegen ein Vorrania-Token tauscht.
func anmelden(
provider: OidcProviderResponse, profileID: UUID
) async throws -> (idToken: String, nonce: String?) {
guard let clientID = provider.nativeClientID, !clientID.isEmpty else {
throw OIDCFehler.keinAppClient
}
guard let discoveryURL = URL(string: provider.discoveryURL),
let redirectURL = URL(string: provider.nativeRedirectURI) else {
throw OIDCFehler.konfigurationUnbrauchbar
}
guard let presenting = Self.oberstesFenster() else {
throw OIDCFehler.keinFenster
}
let konfiguration = try await Self.konfiguration(von: discoveryURL)
let anfrage = OIDAuthorizationRequest(
configuration: konfiguration,
clientId: clientID,
// Ausdruecklich nil: ein oeffentlicher Client hat kein Secret und darf
// auch nie eines mitschicken.
clientSecret: nil,
// offline_access ist Pflicht – ohne diesen Scope liefert Authentik
// seit 2024.2 kein Refresh-Token, und die App muesste sich staendig
// neu anmelden.
scopes: provider.scopeListe,
redirectURL: redirectURL,
responseType: OIDResponseTypeCode,
additionalParameters: nil
)
let zustand: OIDAuthState = try await withCheckedThrowingContinuation { fortsetzung in
laufenderFlow = OIDAuthState.authState(
byPresenting: anfrage,
presenting: presenting
) { zustand, fehler in
if let zustand {
fortsetzung.resume(returning: zustand)
} else {
fortsetzung.resume(throwing: fehler ?? OIDCFehler.abgebrochen)
}
}
}
laufenderFlow = nil
sichern(zustand, profileID: profileID)
guard let idToken = zustand.lastTokenResponse?.idToken else {
throw OIDCFehler.keinIdToken
}
// AppAuth erzeugt die nonce selbst; der Server prueft sie damit erneut.
return (idToken, zustand.lastAuthorizationResponse.request.nonce)
}
/// Fuehrt den Rueckweg aus dem Browser dem laufenden Vorgang zu.
/// Bei `ASWebAuthenticationSession` faengt die Sitzung den Aufruf selbst ab;
/// dies ist der Rueckfall, falls die Adresse doch ueber `onOpenURL` ankommt.
@discardableResult
func rueckwegVerarbeiten(_ url: URL) -> Bool {
guard let flow = laufenderFlow else { return false }
if flow.resumeExternalUserAgentFlow(with: url) {
laufenderFlow = nil
return true
}
return false
}
// MARK: - Erneuern
/// Holt ein frisches ID-Token, notfalls ueber das Refresh-Token.
///
/// Ohne nonce: ein per Refresh ausgestelltes ID-Token muss sie laut
/// OIDC-Spezifikation nur uebernehmen, *wenn* es sie mitfuehrt – viele
/// Anbieter lassen sie weg. Signatur, iss, aud und exp prueft der Server
/// unveraendert.
///
/// Mehrere gleichzeitige Aufrufe teilen sich denselben Vorgang: das
/// Refresh-Token laesst sich nur einmal einloesen.
func frischesIdToken(fuer profileID: UUID) async throws -> String {
if let laufend = laufendeErneuerung[profileID] {
return try await laufend.value
}
let aufgabe = Task<String, Error> { [weak self] in
guard let self, let zustand = self.geladenerZustand(profileID: profileID) else {
throw OIDCFehler.nichtAngemeldet
}
let idToken: String = try await withCheckedThrowingContinuation { fortsetzung in
zustand.performAction { _, idToken, fehler in
if let idToken {
fortsetzung.resume(returning: idToken)
} else {
fortsetzung.resume(throwing: fehler ?? OIDCFehler.erneuerungGescheitert)
}
}
}
// performAction kann den Zustand veraendert haben (neues Refresh-Token).
self.sichern(zustand, profileID: profileID)
return idToken
}
laufendeErneuerung[profileID] = aufgabe
defer { laufendeErneuerung[profileID] = nil }
return try await aufgabe.value
}
func istAngemeldet(profileID: UUID) -> Bool {
geladenerZustand(profileID: profileID) != nil
}
// MARK: - Ablage
private func sichern(_ zustand: OIDAuthState, profileID: UUID) {
guard let daten = try? NSKeyedArchiver.archivedData(
withRootObject: zustand, requiringSecureCoding: true
) else { return }
// Enthaelt das Refresh-Token – gehoert deshalb in den Keychain und nicht
// in die UserDefaults.
Keychain.writeData(daten, account: Self.keychainAccount(for: profileID))
}
private func geladenerZustand(profileID: UUID) -> OIDAuthState? {
guard let daten = Keychain.readData(account: Self.keychainAccount(for: profileID)) else {
return nil
}
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: daten)
}
nonisolated static func abmelden(profileID: UUID) {
Keychain.delete(account: keychainAccount(for: profileID))
}
// MARK: - Hilfen
private static func konfiguration(von url: URL) async throws -> OIDServiceConfiguration {
try await withCheckedThrowingContinuation { fortsetzung in
OIDAuthorizationService.discoverConfiguration(forDiscoveryURL: url) { konfig, fehler in
if let konfig {
fortsetzung.resume(returning: konfig)
} else {
fortsetzung.resume(throwing: fehler ?? OIDCFehler.discoveryGescheitert)
}
}
}
}
private static func oberstesFenster() -> UIViewController? {
let szene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
guard var oben = szene?.keyWindow?.rootViewController else { return nil }
while let naechst = oben.presentedViewController { oben = naechst }
return oben
}
}
enum OIDCFehler: LocalizedError {
case keinAppClient
case konfigurationUnbrauchbar
case keinFenster
case discoveryGescheitert
case keinIdToken
case abgebrochen
case nichtAngemeldet
case erneuerungGescheitert
var errorDescription: String? {
switch self {
case .keinAppClient:
return "Für diesen Anmeldedienst ist kein App-Zugang eingerichtet."
case .konfigurationUnbrauchbar:
return "Der Server hat eine unbrauchbare Anmelde-Konfiguration geliefert."
case .keinFenster:
return "Die Anmeldung konnte nicht angezeigt werden."
case .discoveryGescheitert:
return "Der Anmeldedienst ist nicht erreichbar."
case .keinIdToken:
return "Der Anmeldedienst hat kein ID-Token geliefert."
case .abgebrochen:
return "Die Anmeldung wurde abgebrochen."
case .nichtAngemeldet:
return "Für diesen Server liegt keine Anmeldung beim Anmeldedienst vor."
case .erneuerungGescheitert:
return "Die Anmeldung konnte nicht erneuert werden. Bitte neu anmelden."
}
}
}