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
+7
View File
@@ -43,6 +43,13 @@ Beim Start nach der Server-Adresse fragen lassen, z. B. `http://192.168.1.50:808
(dieselbe Adresse wie im Browser), dann mit deinem Benutzer anmelden. Adresse und
Token bleiben gespeichert – das Token liegt im Keychain.
Hat der Server einen Anmeldedienst eingerichtet (Single Sign-on, siehe
[../README.md](../README.md#single-sign-on-optional)), erscheint darunter ein
Knopf „Mit … anmelden". Die Anmeldung läuft dann im System-Browser (AppAuth mit
PKCE, kein eingebettetes WebView); der Anmeldezustand samt Refresh-Token liegt
je Serverprofil getrennt im Keychain, und die App erneuert ein abgelaufenes
Token still im Hintergrund.
## Home-Screen-Shortcuts
Es gibt zwei Wege direkt in den Scan-Bildschirm:
+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) }
}
+40
View File
@@ -20,6 +20,7 @@
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */; };
56C15BE80330E00D82AF3BD1 /* OIDCLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */; };
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */; };
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; };
@@ -40,6 +41,7 @@
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; };
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; };
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
E216EFADBC6D6AC39C0547DF /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 31DDA98DEF7F8937A8719C80 /* AppAuth */; };
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; };
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; };
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */; };
@@ -76,6 +78,7 @@
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDCLogin.swift; sourceTree = "<group>"; };
A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; };
A75926511854BB8EE316ED3A /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; };
@@ -90,6 +93,17 @@
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
6ABD1700B66B20DDD542B777 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E216EFADBC6D6AC39C0547DF /* AppAuth in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
5216EA3D3DA41052FD0202A7 /* Sources */ = {
isa = PBXGroup;
@@ -121,6 +135,7 @@
3731608B8D48960DC98912BC /* NotificationSettings.swift */,
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */,
9C5F87AE1C7EA366862DB22E /* OIDCLogin.swift */,
6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */,
369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
@@ -162,6 +177,7 @@
buildPhases = (
08E03A34841D4B2D2C7E2E81 /* Sources */,
049B3FB4A4D974E54814E40C /* Resources */,
6ABD1700B66B20DDD542B777 /* Frameworks */,
);
buildRules = (
);
@@ -169,6 +185,7 @@
);
name = Vorrania;
packageProductDependencies = (
31DDA98DEF7F8937A8719C80 /* AppAuth */,
);
productName = Vorrania;
productReference = 4BF4E4F5524B15728CEEE116 /* Vorrania.app */;
@@ -198,6 +215,9 @@
);
mainGroup = 5612AB3DD4CD09397A8F1BAB;
minimizedProjectReferenceProxies = 1;
packageReferences = (
AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 9BD53E938DBCB7F0CF657753 /* Products */;
projectDirPath = "";
@@ -250,6 +270,7 @@
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */,
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */,
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
56C15BE80330E00D82AF3BD1 /* OIDCLogin.swift in Sources */,
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */,
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */,
@@ -452,6 +473,25 @@
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/openid/AppAuth-iOS";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.7.6;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
31DDA98DEF7F8937A8719C80 /* AppAuth */ = {
isa = XCSwiftPackageProductDependency;
package = AA6EA5DEB9627D245AB25D7E /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
productName = AppAuth;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = B35524A6F4D28034E6687EE2 /* Project object */;
}
@@ -0,0 +1,15 @@
{
"originHash" : "c2c3123823fbf9ecb5ff108c887e3a41cb72f13d86620f12b66cac13738096c1",
"pins" : [
{
"identity" : "appauth-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/openid/AppAuth-iOS",
"state" : {
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
"version" : "1.7.6"
}
}
],
"version" : 3
}
+11
View File
@@ -12,6 +12,14 @@ options:
iOS: "16.0"
createIntermediateGroups: true
# Erste (und einzige) Fremdabhaengigkeit: AppAuth uebernimmt den
# OpenID-Connect-Anmeldevorgang. Selbst gebaut waere das ein Sicherheitsrisiko –
# PKCE, State-Pruefung und der Ablauf im System-Browser stecken hier drin.
packages:
AppAuth:
url: https://github.com/openid/AppAuth-iOS
from: 1.7.6
targets:
Vorrania:
type: application
@@ -26,6 +34,9 @@ targets:
- Info.plist
# App-Icon im Icon-Composer-Format (Xcode 26).
- path: AppIcon.icon
dependencies:
- package: AppAuth
product: AppAuth
settings:
base:
INFOPLIST_FILE: Sources/Info.plist