diff --git a/Sources/ForgejoKit/Services/AuthenticationError.swift b/Sources/ForgejoKit/Services/AuthenticationError.swift new file mode 100644 index 0000000..4574f7a --- /dev/null +++ b/Sources/ForgejoKit/Services/AuthenticationError.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum AuthenticationError: LocalizedError, Sendable, Equatable { + case invalidURL + case invalidCredentials + case otpRequired + case basicAuthBlockedBySecurityKey + case serverNotFound + case invalidResponse + case certificateError + case unknownError(statusCode: Int) + + static func from(statusCode: Int, body: String = "") -> AuthenticationError { + switch statusCode { + case 401 where body.localizedCaseInsensitiveContains("security key"): + .basicAuthBlockedBySecurityKey + case 401: + body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials + case 404: + .serverNotFound + default: + .unknownError(statusCode: statusCode) + } + } + + public var httpStatusCode: Int? { + switch self { + case .invalidCredentials, .otpRequired, .basicAuthBlockedBySecurityKey: + 401 + case .serverNotFound: + 404 + case let .unknownError(statusCode): + statusCode + case .invalidURL, .invalidResponse, .certificateError: + nil + } + } + + public var httpErrorCategory: HTTPErrorCategory? { + guard let httpStatusCode else { + return nil + } + return HTTPErrorCategory(statusCode: httpStatusCode) + } + + public var errorDescription: String? { + switch self { + case .invalidURL: + "Invalid server URL" + case .invalidCredentials: + "Invalid username or password" + case .otpRequired: + "Two-factor authentication code required" + case .basicAuthBlockedBySecurityKey: + "An enrolled security key blocks Basic auth. Create a personal access token and use 'Login with Token'." + case .serverNotFound: + "Server not found. Please check the URL." + case .invalidResponse: + "Invalid response from server" + case .certificateError: + "SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one." + case let .unknownError(statusCode): + switch HTTPErrorCategory(statusCode: statusCode) { + case .permissionDenied: + "Permission denied (Status: \(statusCode))" + case .server: + "Server error occurred (Status: \(statusCode))" + case .authentication, .notFound, .other: + "Unknown error occurred (Status: \(statusCode))" + } + } + } +} diff --git a/Sources/ForgejoKit/Services/ForgejoClient.swift b/Sources/ForgejoKit/Services/ForgejoClient.swift index 7d75933..180cd3b 100644 --- a/Sources/ForgejoKit/Services/ForgejoClient.swift +++ b/Sources/ForgejoKit/Services/ForgejoClient.swift @@ -460,73 +460,6 @@ public enum HTTPErrorCategory: Equatable, Sendable { } } -public enum AuthenticationError: LocalizedError, Sendable, Equatable { - case invalidURL - case invalidCredentials - case otpRequired - case serverNotFound - case invalidResponse - case certificateError - case unknownError(statusCode: Int) - - static func from(statusCode: Int, body: String = "") -> AuthenticationError { - switch statusCode { - case 401: - body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials - case 404: - .serverNotFound - default: - .unknownError(statusCode: statusCode) - } - } - - public var httpStatusCode: Int? { - switch self { - case .invalidCredentials, .otpRequired: - 401 - case .serverNotFound: - 404 - case let .unknownError(statusCode): - statusCode - case .invalidURL, .invalidResponse, .certificateError: - nil - } - } - - public var httpErrorCategory: HTTPErrorCategory? { - guard let httpStatusCode else { - return nil - } - return HTTPErrorCategory(statusCode: httpStatusCode) - } - - public var errorDescription: String? { - switch self { - case .invalidURL: - "Invalid server URL" - case .invalidCredentials: - "Invalid username or password" - case .otpRequired: - "Two-factor authentication code required" - case .serverNotFound: - "Server not found. Please check the URL." - case .invalidResponse: - "Invalid response from server" - case .certificateError: - "SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one." - case let .unknownError(statusCode): - switch HTTPErrorCategory(statusCode: statusCode) { - case .permissionDenied: - "Permission denied (Status: \(statusCode))" - case .server: - "Server error occurred (Status: \(statusCode))" - case .authentication, .notFound, .other: - "Unknown error occurred (Status: \(statusCode))" - } - } - } -} - public enum ServiceError: LocalizedError, Sendable, Equatable { case noActiveInstance case invalidURL diff --git a/Tests/ForgejoKitTests/ErrorMappingTests.swift b/Tests/ForgejoKitTests/ErrorMappingTests.swift index f96c3ad..4a2a1a5 100644 --- a/Tests/ForgejoKitTests/ErrorMappingTests.swift +++ b/Tests/ForgejoKitTests/ErrorMappingTests.swift @@ -33,6 +33,14 @@ struct ErrorMappingTests { #expect(AuthenticationError.from(statusCode: 418) == .unknownError(statusCode: 418)) } + @Test func mapsSecurityKeyBlockedBasicAuth() { + // Forgejo's exact 401 body when a passkey/security key is enrolled on the account. + let body = "Basic authorization is not allowed while having security keys enrolled" + #expect(AuthenticationError.from(statusCode: 401, body: body) == .basicAuthBlockedBySecurityKey) + #expect(AuthenticationError.basicAuthBlockedBySecurityKey.httpStatusCode == 401) + #expect(AuthenticationError.basicAuthBlockedBySecurityKey.httpErrorCategory == .authentication) + } + @Test func exposesAuthenticationStatusAndCategory() { #expect(AuthenticationError.invalidCredentials.httpStatusCode == 401) #expect(AuthenticationError.invalidCredentials.httpErrorCategory == .authentication)