Improve handling around refresh token errors (#1661)

Resolves #1542.

## Summary
Logs the error code we get when refreshing a token and log out if we encounter a 403 during token refresh.

## Any other notes
- Fixes an auth handling issue where the WebSocket connection doesn't connect initially.
- Logs out when we encounter a 403 during token refresh. Only a few auth providers do this; specifically, the `trusted_networks` one will reject token refreshes for auth tokens it vends when not on the trusted network. Otherwise, we'll keep retrying and end up getting banned (if enabled).
- Includes the error that we get into the event log so we can trace back the failure easily.
This commit is contained in:
Zac West
2021-06-10 22:46:17 -07:00
committed by GitHub
parent 175adfaaf4
commit 0bb4317e7d
3 changed files with 56 additions and 33 deletions

View File

@@ -165,6 +165,8 @@ class ConnectInstanceViewController: UIViewController {
)
self.setAnimationStatus(self.sensorsConfigured, state: .success)
}.get { _ in
Current.apiConnection.connect()
}
}
}

View File

@@ -6,11 +6,17 @@ import PromiseKit
typealias URLRequestConvertible = Alamofire.URLRequestConvertible
public class AuthenticationAPI {
public enum AuthenticationError: Error {
case unexepectedType
case unexpectedResponse
case invalidCode
public enum AuthenticationError: LocalizedError {
case noConnectionInfo
case serverError(statusCode: Int, errorCode: String?, error: String?)
public var errorDescription: String? {
switch self {
case .noConnectionInfo: return L10n.HaApi.ApiError.notConfigured
case let .serverError(statusCode: statusCode, errorCode: errorCode, error: error):
return [String(describing: statusCode), errorCode, error].compactMap { $0 }.joined(separator: ", ")
}
}
}
private let forcedConnectionInfo: ConnectionInfo?
@@ -39,8 +45,8 @@ public class AuthenticationAPI {
let request = Session.default.request(routeInfo)
let context = TokenInfo.TokenInfoContext(oldTokenInfo: tokenInfo)
request.validate().responseObject(context: context) { (dataresponse: DataResponse<TokenInfo, AFError>) in
switch dataresponse.result {
request.validateAuth().responseObject(context: context) { (response: DataResponse<TokenInfo, AFError>) in
switch response.result {
case let .failure(error):
seal.reject(error)
case let .success(value):
@@ -59,7 +65,7 @@ public class AuthenticationAPI {
)
let request = Session.default.request(routeInfo)
request.validate().response { _ in
request.validateAuth().response { _ in
// https://developers.home-assistant.io/docs/en/auth_api.html#revoking-a-refresh-token says:
//
// The request will always respond with an empty body and HTTP status 200,
@@ -77,30 +83,10 @@ public class AuthenticationAPI {
)
let request = Session.default.request(routeInfo)
request.validate().responseObject { (dataresponse: DataResponse<TokenInfo, AFError>) in
request.validateAuth().responseObject { (dataresponse: DataResponse<TokenInfo, AFError>) in
switch dataresponse.result {
case let .failure(networkError):
guard case let AFError.responseValidationFailed(reason: reason) = networkError,
case let AFError.ResponseValidationFailureReason.unacceptableStatusCode(code: code)
= reason, code == 400, let errorData = dataresponse.data else {
seal.reject(networkError)
return
}
do {
let jsonObject = try JSONSerialization.jsonObject(
with: errorData,
options: .allowFragments
)
if let errorDictionary = jsonObject as? [String: AnyObject],
let errorString = errorDictionary["error_description"] as? String,
errorString == "Invalid code" {
seal.reject(AuthenticationError.invalidCode)
return
}
} catch {
Current.Log.error("Error deserializing failure json response: \(error)")
}
case let .failure(error):
seal.reject(error)
case let .success(value):
seal.fulfill(value)
}
@@ -108,3 +94,35 @@ public class AuthenticationAPI {
}
}
}
extension DataRequest {
@discardableResult
func validateAuth() -> Self {
validate { _, response, data in
if case 200 ..< 300 = response.statusCode {
return .success(())
} else if let data = data {
let errorCode: String?
let error: String?
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
errorCode = json["error"] as? String
error = json["error_description"] as? String
} else {
errorCode = nil
error = String(data: data, encoding: .utf8)
}
return .failure(AuthenticationAPI.AuthenticationError.serverError(
statusCode: response.statusCode,
errorCode: errorCode,
error: error
))
} else {
return .failure(AFError.responseValidationFailed(
reason: .unacceptableStatusCode(code: response.statusCode)
))
}
}
}
}

View File

@@ -168,12 +168,15 @@ public class TokenManager {
case let .rejected(error):
Current.Log.error("refresh token got error: \(error)")
if let networkError = error as? AFError, let statusCode = networkError.responseCode,
statusCode == 400 {
if let underlying = (error as? AFError)?.underlyingError as? AuthenticationAPI.AuthenticationError,
case .serverError(400 ... 403, _, _) = underlying {
/// Server rejected the refresh token. All is lost.
let event = ClientEvent(
text: "Refresh token is invalid, showing onboarding",
type: .networkRequest
type: .networkRequest,
payload: [
"error": String(describing: underlying),
]
)
Current.clientEventStore.addEvent(event)