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
@@ -10,6 +10,7 @@
|
|||||||
D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
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 */; };
|
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; };
|
||||||
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
|
CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; };
|
||||||
|
AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */,
|
CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */,
|
||||||
|
AA9701000001000000000001 /* AppAuth in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -180,6 +182,7 @@
|
|||||||
name = "Calendarr iOS";
|
name = "Calendarr iOS";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
CA1E4D0A0001000000000001 /* CalendarrCore */,
|
CA1E4D0A0001000000000001 /* CalendarrCore */,
|
||||||
|
AA9701000002000000000001 /* AppAuth */,
|
||||||
);
|
);
|
||||||
productName = "Calendarr iOS";
|
productName = "Calendarr iOS";
|
||||||
productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */;
|
productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */;
|
||||||
@@ -243,6 +246,7 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */,
|
39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */,
|
||||||
|
AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = C0000C01FB4E10100AB5001 /* Products */;
|
productRefGroup = C0000C01FB4E10100AB5001 /* Products */;
|
||||||
@@ -521,6 +525,10 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-ObjC",
|
||||||
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -566,6 +574,10 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"-ObjC",
|
||||||
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -702,6 +714,11 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = CalendarrCore;
|
productName = CalendarrCore;
|
||||||
};
|
};
|
||||||
|
AA9701000002000000000001 /* AppAuth */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
|
||||||
|
productName = AppAuth;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
@@ -710,6 +727,17 @@
|
|||||||
relativePath = ../CalendarrKit;
|
relativePath = ../CalendarrKit;
|
||||||
};
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* 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 */;
|
rootObject = C0000301FB4E10100AB5001 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class AppState {
|
|||||||
isAdmin = false
|
isAdmin = false
|
||||||
try? KeychainStore.set(nil, for: "authToken")
|
try? KeychainStore.set(nil, for: "authToken")
|
||||||
try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy
|
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: "authToken") // pre-Keychain copy
|
||||||
UserDefaults.standard.removeObject(forKey: "username")
|
UserDefaults.standard.removeObject(forKey: "username")
|
||||||
UserDefaults.standard.removeObject(forKey: "isAdmin")
|
UserDefaults.standard.removeObject(forKey: "isAdmin")
|
||||||
|
|||||||
@@ -294,6 +294,25 @@ private let strings: [String: [String: String]] = [
|
|||||||
"login.remember": "Angemeldet bleiben",
|
"login.remember": "Angemeldet bleiben",
|
||||||
"login.signin": "Anmelden",
|
"login.signin": "Anmelden",
|
||||||
"login.choose_server": "Anderen Server wählen",
|
"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.connect_title": "Server verbinden",
|
||||||
"server.url": "Server-URL",
|
"server.url": "Server-URL",
|
||||||
"server.connect": "Verbinden",
|
"server.connect": "Verbinden",
|
||||||
@@ -668,6 +687,25 @@ private let strings: [String: [String: String]] = [
|
|||||||
"login.remember": "Stay signed in",
|
"login.remember": "Stay signed in",
|
||||||
"login.signin": "Sign in",
|
"login.signin": "Sign in",
|
||||||
"login.choose_server": "Choose another server",
|
"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.connect_title": "Connect server",
|
||||||
"server.url": "Server URL",
|
"server.url": "Server URL",
|
||||||
"server.connect": "Connect",
|
"server.connect": "Connect",
|
||||||
|
|||||||
@@ -78,6 +78,79 @@ class CalendarrAPI {
|
|||||||
return (token, uname, admin)
|
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 {
|
static func checkSetupRequired(baseURL: String) async throws -> Bool {
|
||||||
guard let url = URL(string: baseURL + "/api/auth/setup-required") else { throw APIError.invalidURL }
|
guard let url = URL(string: baseURL + "/api/auth/setup-required") else { throw APIError.invalidURL }
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@@ -10,6 +11,11 @@ struct LoginView: View {
|
|||||||
@State private var needsTOTP = false
|
@State private var needsTOTP = false
|
||||||
@State private var error = ""
|
@State private var error = ""
|
||||||
@State private var isLoading = false
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -95,9 +101,45 @@ struct LoginView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
.disabled(username.isEmpty || password.isEmpty || isLoading)
|
.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)
|
.padding(.horizontal, 32)
|
||||||
.animation(.easeInOut, value: needsTOTP)
|
.animation(.easeInOut, value: needsTOTP)
|
||||||
|
.animation(.easeInOut, value: ssoProviders)
|
||||||
|
|
||||||
Spacer().frame(height: 40)
|
Spacer().frame(height: 40)
|
||||||
|
|
||||||
@@ -109,6 +151,43 @@ struct LoginView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
.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()
|
LoginView()
|
||||||
} else {
|
} else {
|
||||||
MainTabView()
|
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