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>
This commit is contained in:
Scarriffle
2026-09-09 21:55:38 +02:00
co-authored by Claude Opus 5
parent 8d4954610b
commit 3d0ff54bf5
35 changed files with 2772 additions and 24 deletions
+85 -7
View File
@@ -37,9 +37,63 @@ actor APIClient {
}
private func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
return try JSONDecoder().decode(T.self, from: data)
let daten = try await sendeMitErneuerung(request)
return try JSONDecoder().decode(T.self, from: daten)
}
/// Fuehrt den Aufruf aus und wiederholt ihn EINMAL, wenn das Vorrania-Token
/// abgelaufen ist und sich die Anmeldung still erneuern laesst.
///
/// Betrifft nur Anmeldungen ueber einen Anmeldedienst: dort liegt ein
/// Refresh-Token auf dem Geraet. Nach einer Passwort-Anmeldung gibt es
/// nichts zu erneuern, dann bleibt es beim 401 wie bisher.
private func sendeMitErneuerung(_ request: URLRequest) async throws -> Data {
let (daten, antwort) = try await URLSession.shared.data(for: request)
do {
try check(antwort, data: daten)
return daten
} catch APIError.unauthorized {
guard await erneuereAnmeldung() else { throw APIError.unauthorized }
var wiederholung = request
if let token {
wiederholung.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (daten2, antwort2) = try await URLSession.shared.data(for: wiederholung)
try check(antwort2, data: daten2)
return daten2
}
}
/// Laufende Erneuerung, damit zehn gleichzeitig abgewiesene Aufrufe nicht
/// zehn Erneuerungen ausloesen. Der Actor serialisiert bis zum ersten await,
/// wer hier ankommt, haengt sich also an eine bereits laufende an.
private var laufendeErneuerung: Task<Bool, Never>?
private func erneuereAnmeldung() async -> Bool {
if let laufend = laufendeErneuerung { return await laufend.value }
let aufgabe = Task<Bool, Never> { await Self.erneuereJetzt() }
laufendeErneuerung = aufgabe
defer { laufendeErneuerung = nil }
return await aufgabe.value
}
private static func erneuereJetzt() async -> Bool {
let sitzung = Session.shared
guard let profil = sitzung.activeProfile,
let slug = profil.oidcSlug, !slug.isEmpty else { return false }
do {
let idToken = try await OIDCLogin.shared.frischesIdToken(fuer: profil.id)
let token = try await APIClient.shared.oidcNativeLogin(slug: slug, idToken: idToken)
await MainActor.run {
sitzung.store(token: token.accessToken, username: token.username,
isAdmin: token.role == "admin", persist: sitzung.stayLoggedIn,
oidcSlug: slug)
}
return true
} catch {
// Refresh-Token abgelaufen oder zurueckgezogen: normale Neuanmeldung.
return false
}
}
private func check(_ response: URLResponse, data: Data) throws {
@@ -77,6 +131,27 @@ actor APIClient {
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
}
/// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login.
func oidcProviders() async throws -> [OidcProviderResponse] {
let (daten, antwort) = try await URLSession.shared.data(
for: try makeRequest("/auth/oidc/providers")
)
try check(antwort, data: daten)
return try JSONDecoder().decode([OidcProviderResponse].self, from: daten)
}
/// Tauscht das ID-Token des Anmeldedienstes gegen ein Vorrania-Token.
///
/// Bewusst ohne die Erneuerungsschicht: dieser Aufruf IST die Erneuerung,
/// ein 401 hier muss zur normalen Anmeldung fuehren.
func oidcNativeLogin(slug: String, idToken: String, nonce: String? = nil) async throws -> TokenResponse {
var request = try makeRequest("/auth/oidc/\(slug)/native", method: "POST")
try jsonBody(&request, OidcNativeLoginBody(idToken: idToken, nonce: nonce))
let (daten, antwort) = try await URLSession.shared.data(for: request)
try check(antwort, data: daten)
return try JSONDecoder().decode(TokenResponse.self, from: daten)
}
// MARK: - Stammdaten
func units() async throws -> [Unit] {
@@ -142,8 +217,12 @@ actor APIClient {
let request = try makeRequest("/products/\(id)/image" + (thumb ? "?thumb=1" : ""))
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, http.statusCode == 404 { return nil }
try check(response, data: data)
return data
do {
try check(response, data: data)
return data
} catch APIError.unauthorized {
return try await sendeMitErneuerung(request)
}
}
/// Eigenes Foto (Kamera/Galerie) als multipart hochladen.
@@ -330,8 +409,7 @@ actor APIClient {
/// Fuer Antworten ohne Rumpf (204) - dort gibt es nichts zu dekodieren.
private func sendNoContent(_ request: URLRequest) async throws {
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
_ = try await sendeMitErneuerung(request)
}
// MARK: - Gegenstaende (Non-Food)
+54 -1
View File
@@ -10,6 +10,8 @@ struct LoginView: View {
@State private var busy = false
@State private var stayLoggedIn = Session.shared.stayLoggedIn
@State private var manageShown = false
/// Am eingetragenen Server eingerichtete Anmeldedienste. Leer = nur Passwort.
@State private var anbieter: [OidcProviderResponse] = []
var body: some View {
NavigationStack {
@@ -19,6 +21,12 @@ struct LoginView: View {
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
// Erst mit hinterlegter Adresse laesst sich der Server
// nach seinen Anmeldediensten fragen.
.onSubmit {
session.setServer(server)
Task { await anbieterLaden() }
}
// Mit mehreren Servern ist das hier der Weg zurueck, wenn man
// am falschen gelandet ist.
if session.profiles.count > 1 {
@@ -49,6 +57,18 @@ struct LoginView: View {
Button(busy ? "Anmelden…" : "Anmelden") { Task { await login() } }
.disabled(busy || server.isEmpty || username.isEmpty)
}
// Nur wenn der Server einen App-Zugang eingerichtet hat. Ohne
// Anmeldedienst sieht die Maske genauso aus wie bisher.
if !anbieter.isEmpty {
Section("Single Sign-on") {
ForEach(anbieter) { dienst in
Button("Mit \(dienst.displayName) anmelden") {
Task { await ssoLogin(dienst) }
}
.disabled(busy || dienst.nativeClientID == nil)
}
}
}
}
.navigationTitle(session.activeProfile?.name ?? "Vorrania")
.toolbar {
@@ -60,7 +80,10 @@ struct LoginView: View {
}
// Beim Start und bei jedem Serverwechsel die Felder auf das aktive
// Profil setzen - sonst steht die Adresse des vorigen Servers da.
.task(id: session.activeProfileID) { syncFromProfile() }
.task(id: session.activeProfileID) {
syncFromProfile()
await anbieterLaden()
}
}
private var profileSelection: Binding<UUID> {
@@ -77,6 +100,36 @@ struct LoginView: View {
error = nil
}
/// Fragt den Server, welche Anmeldedienste er kennt. Der APIClient liest die
/// Adresse aus der Sitzung – die Abfrage geht also erst, wenn ein Profil da
/// ist. Ein Fehler bleibt still: der Passwort-Login muss auch dann gehen,
/// wenn der Server alt ist und den Endpunkt gar nicht kennt.
private func anbieterLaden() async {
guard session.activeProfile != nil else { anbieter = []; return }
anbieter = (try? await APIClient.shared.oidcProviders()) ?? []
}
private func ssoLogin(_ dienst: OidcProviderResponse) async {
error = nil
busy = true
defer { busy = false }
session.setServer(server)
guard let profilID = session.activeProfileID else { return }
do {
let (idToken, nonce) = try await OIDCLogin.shared.anmelden(
provider: dienst, profileID: profilID
)
let token = try await APIClient.shared.oidcNativeLogin(
slug: dienst.slug, idToken: idToken, nonce: nonce
)
session.store(token: token.accessToken, username: token.username,
isAdmin: token.role == "admin", persist: stayLoggedIn,
oidcSlug: dienst.slug)
} catch {
self.error = error.localizedDescription
}
}
private func login() async {
error = nil
busy = true
+42
View File
@@ -13,6 +13,48 @@ struct TokenResponse: Codable {
}
}
/// Ein am Server eingerichteter Anmeldedienst (Single Sign-on).
///
/// Kommt vom jeweiligen Server, nicht aus der App: So laesst sich ein weiterer
/// Anbieter allein durch Serverkonfiguration ergaenzen, und bei mehreren
/// Serverprofilen kann jedes einen eigenen haben.
struct OidcProviderResponse: Codable, Identifiable {
let slug: String
let displayName: String
let discoveryURL: String
/// Client-ID des oeffentlichen App-Clients. Fehlt sie, ist fuer diesen
/// Anbieter nur die Anmeldung im Webbrowser eingerichtet.
let nativeClientID: String?
let nativeRedirectURI: String
let scopes: String
var id: String { slug }
var scopeListe: [String] { scopes.split(separator: " ").map(String.init) }
enum CodingKeys: String, CodingKey {
case slug
case displayName = "display_name"
case discoveryURL = "discovery_url"
case nativeClientID = "native_client_id"
case nativeRedirectURI = "native_redirect_uri"
case scopes
}
}
/// Anfragekoerper fuer POST /auth/oidc/<slug>/native.
struct OidcNativeLoginBody: Encodable {
let idToken: String
/// Die in der Anmeldeanfrage verwendete nonce. Nur beim ersten Anmelden
/// vorhanden – ein per Refresh geholtes ID-Token traegt sie nicht
/// zwangslaeufig, deshalb optional.
let nonce: String?
enum CodingKeys: String, CodingKey {
case idToken = "id_token"
case nonce
}
}
struct MeResponse: Codable {
let id: Int
let username: String
+222
View File
@@ -0,0 +1,222 @@
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."
}
}
}
+9 -1
View File
@@ -17,14 +17,22 @@ struct ServerProfile: Codable, Identifiable, Equatable {
/// Zuletzt bekannte Rolle. Entscheidet, ob der Verwaltungs-Tab erscheint,
/// bevor `me()` den Wert frisch bestaetigt hat.
var isAdmin: Bool
/// Kuerzel des Anmeldedienstes, ueber den man sich hier angemeldet hat
/// (z. B. "authentik"); leer bei Passwort-Anmeldung. Nur damit weiss die App
/// spaeter, an welchem Endpunkt sie das abgelaufene Token erneuern muss.
///
/// Optional deklariert, damit bereits gespeicherte Profile weiterhin gelesen
/// werden koennen – der Decoder liefert fuer den fehlenden Schluessel nil.
var oidcSlug: String?
init(id: UUID = UUID(), name: String, urlString: String,
username: String = "", isAdmin: Bool = false) {
username: String = "", isAdmin: Bool = false, oidcSlug: String? = nil) {
self.id = id
self.name = name
self.urlString = urlString
self.username = username
self.isAdmin = isAdmin
self.oidcSlug = oidcSlug
}
var url: URL? { Session.normalize(urlString) }
+25 -4
View File
@@ -122,6 +122,7 @@ final class Session: ObservableObject {
/// wenn der Server aus der Liste verschwindet.
func removeProfile(id: UUID) {
Keychain.delete(account: Session.keychainAccount(for: id))
OIDCLogin.abmelden(profileID: id)
NotificationStore.remove(for: id)
AppDashboardStore.remove(for: id)
profiles.removeAll { $0.id == id }
@@ -166,7 +167,11 @@ final class Session: ObservableObject {
/// `persist == false` heisst: die Anmeldung gilt nur, solange die App laeuft.
/// Auf dem Geraet bleibt dann nichts zurueck.
func store(token newToken: String, username name: String, isAdmin admin: Bool, persist: Bool) {
/// `oidcSlug` merkt, ueber welchen Anmeldedienst die Anmeldung lief – nur so
/// weiss die App spaeter, wo sie ein abgelaufenes Token erneuern kann. Bei
/// Passwort-Anmeldung bleibt es leer.
func store(token newToken: String, username name: String, isAdmin admin: Bool,
persist: Bool, oidcSlug: String? = nil) {
token = newToken
stayLoggedIn = persist
UserDefaults.standard.set(persist, forKey: stayKey)
@@ -175,6 +180,7 @@ final class Session: ObservableObject {
let index = profiles.firstIndex(where: { $0.id == id }) else { return }
profiles[index].isAdmin = admin
profiles[index].username = persist ? name : ""
profiles[index].oidcSlug = oidcSlug
ProfileStore.save(profiles)
if persist {
@@ -189,9 +195,13 @@ final class Session: ObservableObject {
token = nil
guard let id = activeProfileID else { return }
Keychain.delete(account: Session.keychainAccount(for: id))
// Der Anmeldezustand beim Dienst enthaelt das Refresh-Token. Bliebe er
// liegen, waere man nach dem Abmelden faktisch weiter angemeldet.
OIDCLogin.abmelden(profileID: id)
if let index = profiles.firstIndex(where: { $0.id == id }) {
profiles[index].username = ""
profiles[index].isAdmin = false
profiles[index].oidcSlug = nil
ProfileStore.save(profiles)
}
}
@@ -221,18 +231,29 @@ enum Keychain {
private static let service = "com.scarriffle.vorrania"
static func write(_ value: String, account: String) {
writeData(Data(value.utf8), account: account)
}
static func read(account: String) -> String? {
guard let data = readData(account: account) else { return nil }
return String(data: data, encoding: .utf8)
}
/// Der AppAuth-Anmeldezustand ist ein Archiv und kein Text – deshalb neben
/// den String-Varianten auch der direkte Weg ueber `Data`.
static func writeData(_ value: Data, account: String) {
delete(account: account)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: Data(value.utf8),
kSecValueData as String: value,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
SecItemAdd(query as CFDictionary, nil)
}
static func read(account: String) -> String? {
static func readData(account: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
@@ -243,7 +264,7 @@ enum Keychain {
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
return data
}
static func delete(account: String) {
+8 -1
View File
@@ -75,7 +75,14 @@ struct VorraniaApp: App {
.environmentObject(session)
.environmentObject(router)
.environmentObject(display)
.onOpenURL { router.handle(url: $0) }
.onOpenURL { url in
// Rueckweg einer laufenden SSO-Anmeldung zuerst anbieten.
// ASWebAuthenticationSession faengt ihn normalerweise selbst
// ab; kommt er doch hier an, wuerde ihn der Router still
// schlucken (vorrania://oauth/... passt auf keine Route).
if OIDCLogin.shared.rueckwegVerarbeiten(url) { return }
router.handle(url: url)
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { router.handle(url: url) }
}