mirror of
https://codeberg.org/secana/ForgejoKit.git
synced 2026-06-16 05:13:53 -07:00
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.
This commit is contained in:
parent
485c147e7f
commit
1ad2ee2607
3 changed files with 81 additions and 67 deletions
73
Sources/ForgejoKit/Services/AuthenticationError.swift
Normal file
73
Sources/ForgejoKit/Services/AuthenticationError.swift
Normal file
|
|
@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue