mirror of
https://codeberg.org/secana/Forji.git
synced 2026-08-15 14:43:29 -07:00
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
329 lines
12 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|