Files
Calendarr/ios/Calendarr iOS/Services/OIDCSessionRefresher.swift
T
ScarriffleandClaude Opus 5 6e981e7aea chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen
Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis
ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem
gemeinsamen Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 09:20:12 +02:00

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
}
}