From 7b51cab6937ec9c37556d294a5dd11f3c076aab1 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 9 Sep 2026 18:33:59 +0200 Subject: [PATCH] feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Calendarr iOS.xcodeproj/project.pbxproj | 28 ++ Calendarr iOS/CalendarrApp.swift | 3 + Calendarr iOS/Models/Localization.swift | 38 +++ Calendarr iOS/Services/CalendarrAPI.swift | 73 +++++ .../Services/OIDCAuthenticator.swift | 259 ++++++++++++++++++ .../Services/OIDCSessionRefresher.swift | 65 +++++ Calendarr iOS/Views/LoginView.swift | 79 ++++++ Calendarr iOS/Views/RootView.swift | 3 + 8 files changed, 548 insertions(+) create mode 100644 Calendarr iOS/Services/OIDCAuthenticator.swift create mode 100644 Calendarr iOS/Services/OIDCSessionRefresher.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index dc282ad..bdaaaad 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; }; CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; }; + AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -98,6 +99,7 @@ buildActionMask = 2147483647; files = ( CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */, + AA9701000001000000000001 /* AppAuth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -180,6 +182,7 @@ name = "Calendarr iOS"; packageProductDependencies = ( CA1E4D0A0001000000000001 /* CalendarrCore */, + AA9701000002000000000001 /* AppAuth */, ); productName = "Calendarr iOS"; productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */; @@ -243,6 +246,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */, + AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */, ); preferredProjectObjectVersion = 77; productRefGroup = C0000C01FB4E10100AB5001 /* Products */; @@ -521,6 +525,10 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -566,6 +574,10 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -702,6 +714,11 @@ isa = XCSwiftPackageProductDependency; productName = CalendarrCore; }; + AA9701000002000000000001 /* AppAuth */ = { + isa = XCSwiftPackageProductDependency; + package = AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */; + productName = AppAuth; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCLocalSwiftPackageReference section */ @@ -710,6 +727,17 @@ relativePath = ../CalendarrKit; }; /* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openid/AppAuth-iOS.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.7.5; + }; + }; +/* End XCRemoteSwiftPackageReference section */ }; rootObject = C0000301FB4E10100AB5001 /* Project object */; } diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index 904c13b..a12c147 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -95,6 +95,9 @@ class AppState { isAdmin = false try? KeychainStore.set(nil, for: "authToken") try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy + // Drop the SSO session too, otherwise a stale provider refresh token + // outlives the account it belonged to. + OIDCAuthenticator.clearStoredSession() UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy UserDefaults.standard.removeObject(forKey: "username") UserDefaults.standard.removeObject(forKey: "isAdmin") diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index f577319..c35e1d6 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -294,6 +294,25 @@ private let strings: [String: [String: String]] = [ "login.remember": "Angemeldet bleiben", "login.signin": "Anmelden", "login.choose_server": "Anderen Server wählen", + // Single sign-on (OpenID Connect) + "sso.or": "oder", + "sso.login_with": "Mit %@ anmelden", + "sso.err.generic": "Anmeldung fehlgeschlagen", + "sso.err.oidc_failed": "Anmeldung fehlgeschlagen", + "sso.err.oidc_provider_unreachable": "Anmeldedienst nicht erreichbar", + "sso.err.oidc_guided_access": "Bei aktiviertem geführten Zugriff ist die SSO-Anmeldung nicht möglich", + "sso.err.oidc_refresh_failed": "Sitzung konnte nicht erneuert werden", + "sso.err.oidc_no_presenter": "Anmeldefenster konnte nicht geöffnet werden", + "sso.err.oidc_bad_issuer": "Der Anbieter ist falsch konfiguriert", + "sso.err.oidc_account_not_linked": "Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.", + "sso.err.oidc_signup_disabled": "Automatische Kontoerstellung ist deaktiviert", + "sso.err.oidc_domain_not_allowed": "Diese E-Mail-Domain ist nicht zugelassen", + "sso.err.oidc_email_conflict": "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto", + "sso.err.oidc_email_not_verified": "E-Mail-Adresse beim Anbieter nicht bestätigt", + "sso.err.oidc_no_email": "Der Anbieter hat keine E-Mail-Adresse übermittelt", + "sso.err.oidc_unknown_client": "Diese App ist beim Anbieter nicht registriert", + "sso.err.oidc_invalid_token": "Das Anmelde-Token ist ungültig", + "sso.err.oidc_no_id_token": "Der Anbieter hat kein ID-Token geliefert", "server.connect_title": "Server verbinden", "server.url": "Server-URL", "server.connect": "Verbinden", @@ -668,6 +687,25 @@ private let strings: [String: [String: String]] = [ "login.remember": "Stay signed in", "login.signin": "Sign in", "login.choose_server": "Choose another server", + // Single sign-on (OpenID Connect) + "sso.or": "or", + "sso.login_with": "Sign in with %@", + "sso.err.generic": "Sign-in failed", + "sso.err.oidc_failed": "Sign-in failed", + "sso.err.oidc_provider_unreachable": "Sign-in service unreachable", + "sso.err.oidc_guided_access": "SSO sign-in is not available while Guided Access is on", + "sso.err.oidc_refresh_failed": "Could not renew the session", + "sso.err.oidc_no_presenter": "Could not open the sign-in window", + "sso.err.oidc_bad_issuer": "The provider is misconfigured", + "sso.err.oidc_account_not_linked": "No linked Calendarr account. Sign in locally and link it in Settings.", + "sso.err.oidc_signup_disabled": "Automatic account creation is disabled", + "sso.err.oidc_domain_not_allowed": "This email domain is not allowed", + "sso.err.oidc_email_conflict": "That email address already belongs to another account", + "sso.err.oidc_email_not_verified": "Email address not verified at the provider", + "sso.err.oidc_no_email": "The provider did not supply an email address", + "sso.err.oidc_unknown_client": "This app is not registered with the provider", + "sso.err.oidc_invalid_token": "The sign-in token is invalid", + "sso.err.oidc_no_id_token": "The provider did not return an ID token", "server.connect_title": "Connect server", "server.url": "Server URL", "server.connect": "Connect", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index 3d4dd03..1407333 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -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) diff --git a/Calendarr iOS/Services/OIDCAuthenticator.swift b/Calendarr iOS/Services/OIDCAuthenticator.swift new file mode 100644 index 0000000..cb319f6 --- /dev/null +++ b/Calendarr iOS/Services/OIDCAuthenticator.swift @@ -0,0 +1,259 @@ +import AppAuth +import Foundation +import UIKit + +/// Redirect target for the SSO round-trip. Reverse-DNS on the bundle id, per +/// RFC 8252; the same value has to be registered in the identity provider. +/// +/// It is deliberately not server-configurable: the app can only ever receive a +/// scheme it owns, so the provider has to be registered with this exact value. +/// +/// No `CFBundleURLTypes` entry is needed on the normal path — AppAuth's iOS +/// user agent uses `ASWebAuthenticationSession`, which intercepts the callback +/// itself. See `guidedAccessBlocksLogin` for the one exception. +private let redirectURI = "com.scarriffleservices.calendarr.ios:/oauth2redirect" + +/// Keychain key for the serialised `OIDAuthState` (contains the refresh token). +private let authStateKey = "oidcAuthState" +/// UserDefaults key for the provider that SSO session belongs to. +let oidcProviderDefaultsKey = "oidcProvider" + +/// A provider offered by the Calendarr server (`GET /api/auth/oidc/providers`). +struct OIDCProvider: Identifiable, Equatable { + let key: String + let name: String + let issuer: String + /// Exactly what the server told us to request. The app must not add scopes + /// of its own — one the provider does not know makes it reject the whole + /// authorization request. + let scopes: String + let mobileClientID: String + + var id: String { key } +} + +/// An SSO failure carrying the server's stable slug, which `LoginView` maps to +/// a translated message. `underlying` is for the log, never for the user. +struct OIDCError: LocalizedError { + let slug: String + var underlying: String? + var errorDescription: String? { slug } +} + +/// Result of a completed provider flow, ready to be exchanged with Calendarr. +struct OIDCTokens { + let idToken: String + let accessToken: String? + let nonce: String? + /// Serialised `OIDAuthState`, including the refresh token. + let serialisedState: String? +} + +/// Drives the OpenID Connect Authorization Code Flow with PKCE. +/// +/// The login page always opens in `ASWebAuthenticationSession` (AppAuth's +/// default external user agent on iOS) — never in an embedded web view, so the +/// credentials stay in the browser's security context and the provider's own +/// session cookies keep working. +@MainActor +final class OIDCAuthenticator { + + /// AppAuth requires the in-flight session to stay alive for the round-trip. + private var session: OIDExternalUserAgentSession? + + /// Under Guided Access, AppAuth falls back to `SFSafariViewController`, + /// whose redirect can only come back through a registered URL scheme — and + /// the app target generates its Info.plist, which has no way to declare + /// `CFBundleURLTypes`. Rather than hang forever waiting for a callback that + /// cannot arrive, refuse up front. + var guidedAccessBlocksLogin: Bool { UIAccessibilityIsGuidedAccessEnabled() } + + func discover(issuer: String) async throws -> OIDServiceConfiguration { + guard let url = URL(string: issuer) else { throw OIDCError(slug: "oidc_bad_issuer") } + return try await withCheckedThrowingContinuation { cont in + OIDAuthorizationService.discoverConfiguration(forIssuer: url) { config, error in + if let config { + cont.resume(returning: config) + } else { + cont.resume(throwing: OIDCError(slug: "oidc_provider_unreachable", + underlying: Self.describe(error))) + } + } + } + } + + /// Run the full flow and return the provider's tokens. + /// + /// `OIDAuthorizationRequest` generates state, nonce and the PKCE verifier + /// itself. The nonce has to travel to our server with the ID token so it + /// can verify the binding — this is the app's replay protection. + func authorize(provider: OIDCProvider) async throws -> OIDCTokens { + if guidedAccessBlocksLogin { throw OIDCError(slug: "oidc_guided_access") } + + let config = try await discover(issuer: provider.issuer) + + var scopes = provider.scopes + .split(separator: " ") + .map(String.init) + .filter { !$0.isEmpty } + if !scopes.contains(OIDScopeOpenID) { scopes.append(OIDScopeOpenID) } + + guard let redirectURL = URL(string: redirectURI) else { + throw OIDCError(slug: "oidc_bad_redirect") + } + + let request = OIDAuthorizationRequest( + configuration: config, + clientId: provider.mobileClientID, + clientSecret: nil, // public client — never send a secret + scopes: scopes, + redirectURL: redirectURL, + responseType: OIDResponseTypeCode, + additionalParameters: nil + ) + + guard let presenter = Self.topViewController() else { + throw OIDCError(slug: "oidc_no_presenter") + } + + // Clear the retained session on every exit, not just the happy one. + defer { session = nil } + + let authState: OIDAuthState = try await withCheckedThrowingContinuation { cont in + session = OIDAuthState.authState(byPresenting: request, + presenting: presenter) { state, error in + if let state { + cont.resume(returning: state) + } else if Self.isCancellation(error) { + cont.resume(throwing: OIDCError(slug: "oidc_cancelled")) + } else { + cont.resume(throwing: OIDCError(slug: "oidc_failed", + underlying: Self.describe(error))) + } + } + } + + guard let idToken = authState.lastTokenResponse?.idToken else { + throw OIDCError(slug: "oidc_no_id_token") + } + + return OIDCTokens( + idToken: idToken, + accessToken: authState.lastTokenResponse?.accessToken, + nonce: request.nonce, + serialisedState: Self.serialise(authState) + ) + } + + // MARK: - Silent renewal + + /// Ask the provider for a fresh ID token using the stored refresh token. + /// + /// Returns nil when there is no SSO session to renew. The refreshed state + /// is written back, because the provider may have rotated the refresh + /// token. + func renewIDToken() async throws -> (idToken: String, accessToken: String?, nonce: String?)? { + guard let stored = try? KeychainStore.get(authStateKey), + let state = Self.deserialise(stored) else { return nil } + + let tokens: (String?, String?) = try await withCheckedThrowingContinuation { cont in + state.performAction { accessToken, idToken, error in + if let error { + cont.resume(throwing: OIDCError(slug: "oidc_refresh_failed", + underlying: Self.describe(error))) + } else { + cont.resume(returning: (idToken, accessToken)) + } + } + } + + guard let idToken = tokens.0 else { return nil } + if let refreshed = Self.serialise(state) { + try? KeychainStore.set(refreshed, for: authStateKey) + } + // The original request travels inside the stored state, so the nonce + // is still available. A refreshed token often drops the claim, which + // the server tolerates — but when it keeps it, it has to match. + let nonce = state.lastAuthorizationResponse?.request.nonce + return (idToken, tokens.1, nonce) + } + + /// Persist the SSO session so it can be renewed without another browser trip. + static func store(state: String?, providerKey: String) { + guard let state else { return } + try? KeychainStore.set(state, for: authStateKey) + UserDefaults.standard.set(providerKey, forKey: oidcProviderDefaultsKey) + } + + /// The provider key of the active SSO session, if any. + static var storedProviderKey: String? { + UserDefaults.standard.string(forKey: oidcProviderDefaultsKey) + } + + static func clearStoredSession() { + try? KeychainStore.set(nil, for: authStateKey) + // Mirror the authToken handling: a pre-entitlement copy may exist in + // the app's own keychain group. + try? KeychainStore.set(nil, for: authStateKey, accessGroup: nil) + UserDefaults.standard.removeObject(forKey: oidcProviderDefaultsKey) + } + + // MARK: - Helpers + + private static func serialise(_ state: OIDAuthState) -> String? { + do { + let data = try NSKeyedArchiver.archivedData(withRootObject: state, + requiringSecureCoding: true) + return data.base64EncodedString() + } catch { + log("archiving OIDAuthState failed: \(error)") + return nil + } + } + + private static func deserialise(_ raw: String) -> OIDAuthState? { + guard let data = Data(base64Encoded: raw) else { return nil } + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) + } + + /// AppAuth reports both a user-initiated and a programmatic cancel; neither + /// is a failure worth showing. Checking the domain as well as the code + /// avoids matching an unrelated error that happens to share the number. + private static func isCancellation(_ error: Error?) -> Bool { + guard let ns = error as NSError? else { return false } + guard ns.domain == OIDGeneralErrorDomain else { return false } + return ns.code == OIDErrorCode.userCanceledAuthorizationFlow.rawValue + || ns.code == OIDErrorCode.programCanceledAuthorizationFlow.rawValue + } + + /// Detail for the log only — never shown to the user. + private static func describe(_ error: Error?) -> String? { + guard let ns = error as NSError? else { return nil } + let oauth = ns.userInfo[OIDOAuthErrorResponseErrorKey] as? [String: Any] + let detail = (oauth?["error"] as? String).map { " oauth=\($0)" } ?? "" + let text = "\(ns.domain)/\(ns.code): \(ns.localizedDescription)\(detail)" + log(text) + return text + } + + private static func log(_ message: String) { + #if DEBUG + print("[OIDC] \(message)") + #endif + } + + /// Topmost view controller to present the browser sheet from. Works under + /// Mac Catalyst too, where a presentation anchor is likewise required. + private static func topViewController() -> UIViewController? { + let scene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + + var top = scene?.keyWindow?.rootViewController + while let presented = top?.presentedViewController { + top = presented + } + return top + } +} diff --git a/Calendarr iOS/Services/OIDCSessionRefresher.swift b/Calendarr iOS/Services/OIDCSessionRefresher.swift new file mode 100644 index 0000000..894ad77 --- /dev/null +++ b/Calendarr iOS/Services/OIDCSessionRefresher.swift @@ -0,0 +1,65 @@ +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 + } +} diff --git a/Calendarr iOS/Views/LoginView.swift b/Calendarr iOS/Views/LoginView.swift index a5b9ff8..8eef38b 100644 --- a/Calendarr iOS/Views/LoginView.swift +++ b/Calendarr iOS/Views/LoginView.swift @@ -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 } } diff --git a/Calendarr iOS/Views/RootView.swift b/Calendarr iOS/Views/RootView.swift index e3649c7..8e42edf 100644 --- a/Calendarr iOS/Views/RootView.swift +++ b/Calendarr iOS/Views/RootView.swift @@ -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) } } } }