Archived
feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth)
Zusaetzlicher Anmeldeweg neben Benutzername/Passwort. Der Login-Screen holt die konfigurierten Provider von GET /api/auth/oidc/providers; ohne Provider oder gegen einen aelteren Server bleibt der Screen unveraendert. Die App laeuft als Public Client: Authorization Code Flow mit PKCE in einer ASWebAuthenticationSession (nie ein WKWebView), ohne Client Secret. Das ID-Token wird an POST /api/auth/oidc/exchange gegen ein Calendarr-Token getauscht; die Antwort hat dieselbe Form wie der Passwort-Login, sodass der bestehende Session-Pfad unveraendert weiterlaeuft. - OIDCAuthenticator kapselt Discovery, PKCE-Request, Code-Tausch und die stille Erneuerung. Der AuthState mit dem Refresh-Token liegt in der Keychain (kSecAttrAccessibleAfterFirstUnlock) und wird beim Logout aus beiden Zugriffsgruppen entfernt. - OIDCSessionRefresher erneuert die Sitzung beim Start, wenn das Calendarr-Token binnen 24 h ablaeuft — ohne Browser, ueber den Refresh-Token des Providers. - Die angefragten Scopes kommen vom Server (mobile_scopes); ein Scope, den der Provider nicht kennt, wuerde die ganze Anfrage scheitern lassen. - Bei aktiviertem gefuehrten Zugriff weicht AppAuth auf SFSafariViewController aus, dessen Redirect nur ueber ein registriertes URL-Schema zurueckkaeme. Da das App-Target seine Info.plist generiert und CFBundleURLTypes dort nicht setzbar ist, wird der Fall vorab sauber abgelehnt statt endlos auf einen Callback zu warten, der nicht kommen kann. - Abbrueche werden mit Fehlerdomaene geprueft (user- und programCanceled) und still behandelt; alle uebrigen Fehler tragen Domain, Code und OAuth-Fehler ins DEBUG-Log, statt spurlos zu verschwinden. project.pbxproj: AppAuth-iOS 1.7.5 als SPM-Abhaengigkeit, nur am App-Target. OTHER_LDFLAGS bekommt -ObjC, weil OIDAuthState+IOS.m ausschliesslich eine Kategorie enthaelt und ohne Symbol sonst wegoptimiert werden koennte. Achtung: mangels macOS/Xcode ist dieser Stand nicht kompiliert. Beim ersten Oeffnen loest Xcode das Paket auf und legt Package.resolved an; die bitte mit committen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d1da8de06a
commit
7b51cab693
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@@ -10,6 +11,11 @@ struct LoginView: View {
|
||||
@State private var needsTOTP = false
|
||||
@State private var error = ""
|
||||
@State private var isLoading = false
|
||||
@State private var ssoProviders: [OIDCProvider] = []
|
||||
/// Key of the provider whose flow is running — per-provider, so a second
|
||||
/// button does not also spin.
|
||||
@State private var ssoBusyKey: String?
|
||||
@State private var oidc = OIDCAuthenticator()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -95,9 +101,45 @@ struct LoginView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
||||
|
||||
// Single sign-on — hidden entirely when the server has
|
||||
// no provider configured.
|
||||
if !ssoProviders.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
Rectangle().frame(height: 1).foregroundStyle(.quaternary)
|
||||
Text(L10n.t("sso.or", appLang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Rectangle().frame(height: 1).foregroundStyle(.quaternary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
ForEach(ssoProviders) { provider in
|
||||
Button {
|
||||
Task { await signInWithSSO(provider) }
|
||||
} label: {
|
||||
HStack {
|
||||
if ssoBusyKey == provider.key {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(format: L10n.t("sso.login_with", appLang),
|
||||
provider.name))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(14)
|
||||
.background(Color.accentColor.opacity(0.12))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.disabled(isLoading || ssoBusyKey != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.animation(.easeInOut, value: needsTOTP)
|
||||
.animation(.easeInOut, value: ssoProviders)
|
||||
|
||||
Spacer().frame(height: 40)
|
||||
|
||||
@@ -109,6 +151,43 @@ struct LoginView: View {
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.task { await loadSSOProviders() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: an older server or a network hiccup simply means no button.
|
||||
private func loadSSOProviders() async {
|
||||
guard ssoProviders.isEmpty else { return }
|
||||
ssoProviders = (try? await CalendarrAPI.oidcProviders(baseURL: appState.serverURL)) ?? []
|
||||
}
|
||||
|
||||
private func signInWithSSO(_ provider: OIDCProvider) async {
|
||||
ssoBusyKey = provider.key
|
||||
error = ""
|
||||
defer { ssoBusyKey = nil }
|
||||
|
||||
do {
|
||||
let tokens = try await oidc.authorize(provider: provider)
|
||||
let result = try await CalendarrAPI.exchangeOIDC(
|
||||
baseURL: appState.serverURL,
|
||||
provider: provider.key,
|
||||
clientID: provider.mobileClientID,
|
||||
idToken: tokens.idToken,
|
||||
accessToken: tokens.accessToken,
|
||||
nonce: tokens.nonce
|
||||
)
|
||||
// Keep the provider's refresh token so the session can be renewed
|
||||
// later without sending the user through the browser again.
|
||||
OIDCAuthenticator.store(state: tokens.serialisedState, providerKey: provider.key)
|
||||
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
|
||||
} catch let e as OIDCError {
|
||||
// Cancelling the browser sheet is a normal action, not an error.
|
||||
if e.slug == "oidc_cancelled" { return }
|
||||
let key = "sso.err." + e.slug
|
||||
let msg = L10n.t(key, appLang)
|
||||
error = msg == key ? L10n.t("sso.err.generic", appLang) : msg
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ struct RootView: View {
|
||||
LoginView()
|
||||
} else {
|
||||
MainTabView()
|
||||
// Renew an SSO session before it lapses, so a user signed in
|
||||
// via a provider is not bounced back to the login screen.
|
||||
.task { await OIDCSessionRefresher.refreshIfNeeded(appState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user