Forji/Forji/Forji/Services/AuthenticationService.swift
Stefan Hausotte 3289fc9eb0 fix: surface SwiftData save failures instead of swallowing them
assertionFailure is compiled out under -O, so every catch around
modelContext.save() that only asserted discarded the error entirely in
Release builds. InstanceFormView.handleSave then set currentInstance and
dismissed regardless, reporting a connected account that was never
persisted and would be gone on the next launch.

Add ModelContext.saveOrRollback(), which returns a user-facing message on
failure and rolls the context back so in-memory state matches disk. Views
now show a "Save Failed" alert and skip the follow-on state changes.

Two behaviour fixes beyond error reporting:

- InstanceListView.deleteInstance saves before disconnecting and deleting
  Keychain credentials. A failed save previously left the rolled-back
  instance in the list with its credentials already gone, making it
  unauthenticatable. AuthenticationService.logout already did this.
- Both default toggles revert their @AppStorage flag when the save fails,
  so it cannot disagree with the stored isDefault flags.

ContentView's saves only carry lastUsed, so a failure costs list ordering
rather than account data and stays unsurfaced during automatic restore.

Move Mode, AuthMode and accountAlreadyExists out of InstanceFormView to
keep the file within the 400-line lint limit.

Fixes https://codeberg.org/secana/Forji/issues/92
2026-08-06 18:39:58 +02:00

329 lines
12 KiB
Swift

import ForgejoKit
import Foundation
import SwiftData
@Observable
class AuthenticationService {
var isAuthenticated = false
var currentUser: User?
var currentInstance: ForgejoInstance?
private(set) var client: ForgejoClient?
func login(serverURL: String, username: String, password: String, allowSelfSigned: Bool = false) async throws {
let result = try await ForgejoClient.login(
serverURL: serverURL,
username: username,
password: password,
allowSelfSignedCertificates: allowSelfSigned,
)
try await storeCredentials(result: result, password: password)
}
func loginWithToken(serverURL: String, token: String, allowSelfSigned: Bool = false) async throws {
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
let tokenClient = ForgejoClient(
serverURL: normalizedURL,
username: "",
token: token,
allowSelfSignedCertificates: allowSelfSigned,
)
let user = try await tokenClient.fetchCurrentUser()
let tokenClientWithUsername = ForgejoClient(
serverURL: normalizedURL,
username: user.login,
token: token,
allowSelfSignedCertificates: allowSelfSigned,
)
try await KeychainManager.shared.saveToken(
token, for: normalizedURL, username: user.login,
)
client = tokenClientWithUsername
currentUser = user
isAuthenticated = true
}
func loginWithOTP(
serverURL: String, username: String, password: String, otp: String,
allowSelfSigned: Bool = false,
) async throws {
let result = try await ForgejoClient.loginWithOTP(
serverURL: serverURL,
username: username,
password: password,
otp: otp,
allowSelfSignedCertificates: allowSelfSigned,
)
try await storeCredentials(result: result, password: password)
}
private func storeCredentials(result: LoginResult, password: String) async throws {
try await KeychainManager.shared.savePassword(
password, for: result.client.serverURL, username: result.client.username,
)
try await KeychainManager.shared.saveToken(
result.token, for: result.client.serverURL, username: result.client.username,
)
client = result.client
currentUser = result.user
isAuthenticated = true
}
func disconnect() {
isAuthenticated = false
currentUser = nil
currentInstance = nil
client = nil
}
/// Synchronous session restore from keychain so the home screen
/// with cached data can appear on the very first frame.
@discardableResult
func bootstrapSession(instance: ForgejoInstance) -> Bool {
let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL)
guard let token = KeychainManager.getTokenSync(
for: normalizedURL, username: instance.username,
) else { return false }
client = ForgejoClient(
serverURL: normalizedURL,
username: instance.username,
token: token,
allowSelfSignedCertificates: instance.allowSelfSignedCertificates,
)
currentInstance = instance
isAuthenticated = true
return true
}
func logout(modelContext: ModelContext) async {
if let instance = currentInstance {
let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL)
// Remove the instance from SwiftData first. If the save fails, keep the
// credentials so the account stays usable instead of becoming an
// unauthenticatable orphan, and bail out without clearing the session.
instance.isDefault = false
modelContext.delete(instance)
if modelContext.saveOrRollback() != nil {
return
}
// Delete credentials only after the instance is gone from SwiftData.
do {
try await KeychainManager.shared.deleteCredentials(
for: normalizedURL,
username: instance.username,
)
} catch {
// Keychain delete failed, log in debug builds
#if DEBUG
print("Keychain delete failed during logout: \(error)")
#endif
}
}
// Clears cache for all instances since keys are hashed and cannot be scoped per-instance
DiskCache.removeAll()
isAuthenticated = false
currentUser = nil
currentInstance = nil
client = nil
}
func restoreSession(instance: ForgejoInstance) async throws {
let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL)
// Migrate keychain items to AfterFirstUnlock accessibility for background access
await KeychainManager.shared.migrateAccessibility(for: normalizedURL, username: instance.username)
try await restoreWithCredentials(
serverURL: normalizedURL,
username: instance.username,
allowSelfSigned: instance.allowSelfSignedCertificates,
useTokenAuth: instance.useTokenAuth,
)
currentInstance = instance
}
func restoreFromSnapshot(_ snapshot: InstanceSnapshot) async throws {
let normalizedURL = ForgejoClient.normalizeServerURL(snapshot.serverURL)
try await restoreWithCredentials(
serverURL: normalizedURL,
username: snapshot.username,
allowSelfSigned: snapshot.allowSelfSigned,
useTokenAuth: snapshot.useTokenAuth,
)
}
private func restoreWithCredentials(
serverURL: String, username: String, allowSelfSigned: Bool, useTokenAuth: Bool,
) async throws {
// Try restoring from stored API token first (avoids 2FA prompt)
if let token = try? await KeychainManager.shared.getToken(for: serverURL, username: username) {
let tokenClient = ForgejoClient(
serverURL: serverURL,
username: username,
token: token,
allowSelfSignedCertificates: allowSelfSigned,
)
do {
let user = try await tokenClient.fetchCurrentUser()
client = tokenClient
currentUser = user
isAuthenticated = true
return
} catch {
// Token is invalid/expired, only fall through to password if this is not a token-only instance
if useTokenAuth {
throw SessionRestoreError.fromTokenValidationError(error)
}
// Otherwise fall through to password-based login below
}
}
// Fall back to password-based login (creates a new token)
guard !useTokenAuth else {
throw SessionRestoreError.tokenExpired
}
let password = try await KeychainManager.shared.getPassword(for: serverURL, username: username)
do {
try await login(
serverURL: serverURL, username: username,
password: password, allowSelfSigned: allowSelfSigned,
)
} catch {
throw SessionRestoreError.fromTokenValidationError(error)
}
}
// Stub factories for SwiftUI previews
#if DEBUG
static func preview(user: User, instance: ForgejoInstance? = nil) -> AuthenticationService {
let service = AuthenticationService()
service.isAuthenticated = true
service.currentUser = user
service.currentInstance = instance
service.client = ForgejoClient(
serverURL: instance?.serverURL ?? "https://forgejo.example.com",
username: user.login,
token: "preview-token",
)
return service
}
#endif
}
enum SessionRestoreError: LocalizedError, Equatable {
case tokenExpired
case tokenPermissionDenied
case serverUnavailable
case serverNotFound
case networkUnavailable
case certificateError
case invalidServerResponse
case tokenValidationFailedHTTPStatus(Int)
case tokenValidationFailed
var errorDescription: String? {
switch self {
case .tokenExpired:
"Your API token is expired or has been revoked. Please edit this instance and enter a new token."
case .tokenPermissionDenied:
"Your API token does not have permission to access this account. "
+ "Please edit this instance and enter a token with the required scopes."
case .serverUnavailable:
"The Forgejo server returned an error while validating your token. Please try again later."
case .serverNotFound:
"The Forgejo server could not be found. Please check the instance URL."
case .networkUnavailable:
"Forji could not reach the Forgejo server while validating your token. "
+ "Please check your connection and try again."
case .certificateError:
"Forji could not validate the server certificate. "
+ "Enable self-signed certificates if this instance uses one."
case .invalidServerResponse:
"The Forgejo server returned an invalid response while validating your token."
case let .tokenValidationFailedHTTPStatus(statusCode):
"Forji could not validate your API token (HTTP \(statusCode)). "
+ "Please edit this instance or try again later."
case .tokenValidationFailed:
"Forji could not validate your API token. Please edit this instance or try again later."
}
}
/// Whether the server has definitively rejected the stored credential, as opposed to a
/// transient/network/server-side failure that may resolve on its own. Only this case
/// justifies deleting the stored instance and credentials.
var isDefiniteCredentialFailure: Bool {
switch self {
case .tokenExpired, .tokenPermissionDenied:
true
case .serverUnavailable, .serverNotFound, .networkUnavailable, .certificateError,
.invalidServerResponse, .tokenValidationFailedHTTPStatus, .tokenValidationFailed:
false
}
}
static func fromTokenValidationError(_ error: Error) -> SessionRestoreError {
if let sessionError = error as? SessionRestoreError {
return sessionError
}
if let authError = error as? AuthenticationError {
return fromAuthenticationError(authError)
}
if let serviceError = error as? ServiceError {
return fromServiceError(serviceError)
}
if error is URLError {
return .networkUnavailable
}
return .tokenValidationFailed
}
private static func fromAuthenticationError(_ error: AuthenticationError) -> SessionRestoreError {
switch error {
case .certificateError:
.certificateError
case .invalidResponse:
.invalidServerResponse
case .invalidURL:
.serverNotFound
case .invalidCredentials, .otpRequired, .basicAuthBlockedBySecurityKey, .serverNotFound, .unknownError:
fromHTTPErrorCategory(error.httpErrorCategory, statusCode: error.httpStatusCode)
}
}
private static func fromServiceError(_ error: ServiceError) -> SessionRestoreError {
switch error {
case .invalidURL:
.serverNotFound
case .invalidResponse, .decodingFailed:
.invalidServerResponse
case .httpError:
fromHTTPErrorCategory(error.httpErrorCategory, statusCode: error.httpStatusCode)
case .noActiveInstance:
.tokenValidationFailed
case .notMergeable, .mergeConflict:
.tokenValidationFailed
}
}
private static func fromHTTPErrorCategory(
_ category: HTTPErrorCategory?,
statusCode: Int?,
) -> SessionRestoreError {
switch category {
case .authentication:
.tokenExpired
case .permissionDenied:
.tokenPermissionDenied
case .notFound:
.serverNotFound
case .server:
.serverUnavailable
case .other:
if let statusCode {
.tokenValidationFailedHTTPStatus(statusCode)
} else {
.tokenValidationFailed
}
case nil:
.tokenValidationFailed
}
}
}