From 1ad2ee2607d33b0eb7bd97d5f5624aec44d22394 Mon Sep 17 00:00:00 2001 From: Stefan Hausotte Date: Mon, 15 Jun 2026 18:43:55 +0200 Subject: [PATCH] fix: report security-key-blocked Basic auth instead of invalid credentials Forgejo rejects username/password (Basic auth) for accounts with a security key / passkey (WebAuthn) enrolled, responding 401 with "Basic authorization is not allowed while having security keys enrolled". This was misclassified as .invalidCredentials, showing "Invalid username or password" and sending users in circles. Add a dedicated .basicAuthBlockedBySecurityKey case that tells the user to use a token. Also extract AuthenticationError into its own file. --- .../Services/AuthenticationError.swift | 73 +++++++++++++++++++ .../ForgejoKit/Services/ForgejoClient.swift | 67 ----------------- Tests/ForgejoKitTests/ErrorMappingTests.swift | 8 ++ 3 files changed, 81 insertions(+), 67 deletions(-) create mode 100644 Sources/ForgejoKit/Services/AuthenticationError.swift 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)