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] = [:] 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 { [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." } } }