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
@@ -78,6 +78,79 @@ class CalendarrAPI {
|
||||
return (token, uname, admin)
|
||||
}
|
||||
|
||||
// MARK: - Single sign-on (OpenID Connect)
|
||||
|
||||
/// Providers configured on the server. Empty when SSO is off or the server
|
||||
/// predates it — the password form is unaffected either way.
|
||||
static func oidcProviders(baseURL: String) async throws -> [OIDCProvider] {
|
||||
guard let url = URL(string: baseURL + "/api/auth/oidc/providers") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
json["enabled"] as? Bool == true,
|
||||
let list = json["providers"] as? [[String: Any]] else { return [] }
|
||||
|
||||
return list.compactMap { entry in
|
||||
// Without a mobile client id the app cannot start a flow.
|
||||
guard let key = entry["key"] as? String,
|
||||
let clientID = entry["mobile_client_id"] as? String, !clientID.isEmpty,
|
||||
let issuer = entry["issuer"] as? String else { return nil }
|
||||
return OIDCProvider(
|
||||
key: key,
|
||||
name: (entry["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? key,
|
||||
issuer: issuer,
|
||||
// mobile_scopes is what the server wants the apps to request
|
||||
// (it knows whether the provider grants offline_access).
|
||||
scopes: (entry["mobile_scopes"] as? String)
|
||||
?? (entry["scopes"] as? String)
|
||||
?? "openid profile email",
|
||||
mobileClientID: clientID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trade a provider ID token for a Calendarr token.
|
||||
///
|
||||
/// The app is a public client: this request carries no secret, only the ID
|
||||
/// token and the nonce AppAuth bound into the flow.
|
||||
static func exchangeOIDC(baseURL: String,
|
||||
provider: String,
|
||||
clientID: String,
|
||||
idToken: String,
|
||||
accessToken: String?,
|
||||
nonce: String?) async throws -> (token: String, username: String, isAdmin: Bool) {
|
||||
guard let url = URL(string: baseURL + "/api/auth/oidc/exchange") else { throw APIError.invalidURL }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
var body: [String: Any] = ["provider": provider, "client_id": clientID, "id_token": idToken]
|
||||
if let accessToken { body["access_token"] = accessToken }
|
||||
if let nonce { body["nonce"] = nonce }
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status >= 400 {
|
||||
// The server answers with a stable slug the login screen translates.
|
||||
let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String
|
||||
throw OIDCError(slug: detail?.isEmpty == false ? detail! : "oidc_failed")
|
||||
}
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = json["access_token"] as? String,
|
||||
let user = json["user"] as? [String: Any],
|
||||
let uname = user["username"] as? String else {
|
||||
throw APIError.decodingError
|
||||
}
|
||||
let admin = user["is_admin"] as? Bool ?? false
|
||||
// Same side effect as login(): creator/owner comparisons elsewhere read
|
||||
// these back, so an SSO user must not be left with a zero userId.
|
||||
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
|
||||
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
|
||||
return (token, uname, admin)
|
||||
}
|
||||
|
||||
static func checkSetupRequired(baseURL: String) async throws -> Bool {
|
||||
guard let url = URL(string: baseURL + "/api/auth/setup-required") else { throw APIError.invalidURL }
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
|
||||
Reference in New Issue
Block a user