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
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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