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:
Scarriffle
2026-09-09 18:33:59 +02:00
co-authored by Claude Opus 5
parent d1da8de06a
commit 7b51cab693
8 changed files with 548 additions and 0 deletions
+3
View File
@@ -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")
+38
View File
@@ -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",
+73
View File
@@ -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
}
}
+79
View File
@@ -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
}
}
+3
View File
@@ -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) }
}
}
}