Archived
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>
66 lines
2.9 KiB
Swift
66 lines
2.9 KiB
Swift
import Foundation
|
|
|
|
/// Keeps an SSO session alive without sending the user back through the browser.
|
|
///
|
|
/// Calendarr's own token has no refresh mechanism — it simply expires. What the
|
|
/// `offline_access` scope buys is a refresh token at the *identity provider*, so
|
|
/// renewal means: get a fresh ID token from the provider, then trade it for a
|
|
/// fresh Calendarr token at `/api/auth/oidc/exchange`.
|
|
@MainActor
|
|
enum OIDCSessionRefresher {
|
|
|
|
/// Renew when less than this much of the Calendarr token's life is left.
|
|
private static let renewWithin: TimeInterval = 24 * 60 * 60
|
|
|
|
/// Best-effort renewal at launch. Any failure is swallowed: the existing
|
|
/// token is still valid, and a hard failure would only surface later as a
|
|
/// normal 401.
|
|
static func refreshIfNeeded(_ appState: AppState) async {
|
|
guard !appState.authToken.isEmpty,
|
|
let providerKey = OIDCAuthenticator.storedProviderKey,
|
|
expiresSoon(appState.authToken) else { return }
|
|
|
|
do {
|
|
let authenticator = OIDCAuthenticator()
|
|
guard let renewed = try await authenticator.renewIDToken() else { return }
|
|
|
|
let providers = try await CalendarrAPI.oidcProviders(baseURL: appState.serverURL)
|
|
guard let provider = providers.first(where: { $0.key == providerKey }) else { return }
|
|
|
|
let result = try await CalendarrAPI.exchangeOIDC(
|
|
baseURL: appState.serverURL,
|
|
provider: provider.key,
|
|
clientID: provider.mobileClientID,
|
|
idToken: renewed.idToken,
|
|
accessToken: renewed.accessToken,
|
|
nonce: renewed.nonce
|
|
)
|
|
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
|
|
} catch {
|
|
// Nothing to do — keep using the token we have.
|
|
}
|
|
}
|
|
|
|
/// True when the Calendarr JWT expires within [renewWithin].
|
|
///
|
|
/// The payload is read without verifying the signature: this only decides
|
|
/// *whether* to renew, and the server validates the token on every request
|
|
/// regardless. A token we cannot parse is left alone.
|
|
private static func expiresSoon(_ jwt: String) -> Bool {
|
|
let parts = jwt.split(separator: ".")
|
|
guard parts.count == 3 else { return false }
|
|
|
|
var payload = String(parts[1])
|
|
.replacingOccurrences(of: "-", with: "+")
|
|
.replacingOccurrences(of: "_", with: "/")
|
|
// Base64url drops the padding that Data(base64Encoded:) insists on.
|
|
while payload.count % 4 != 0 { payload += "=" }
|
|
|
|
guard let data = Data(base64Encoded: payload),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let exp = json["exp"] as? Double else { return false }
|
|
|
|
return Date(timeIntervalSince1970: exp).timeIntervalSinceNow < renewWithin
|
|
}
|
|
}
|