Skip to content

Commit 5bc16a3

Browse files
authored
Fix authentication delay when WebView forces a token refresh (home-assistant#691)
Refs home-assistant#592. I believe the issue is that the referenced module in the frontend is refreshing the token itself, and that's causing our expiration logic to be incorrect -- we think we have a valid token, but it's actually been replaced. We do eventually recover when one of our own network requests yields an invalid token response, but that's _after_ we've already told the WebView about the token we believed was valid. The frontend will then retry in 10 seconds, but not before considering the token we provided as an invalid login attempt and throwing a log. This also resolves some threading issues with the cache wrapping refresh token, whose goal was to avoid issuing more than one request at once. Since the WebView callbacks were happening on the on the main queue, and the Alamofire responses on the utility queue, it was possible for both to mutate and kick off refreshes at once, which would also log login failures. This also adds some logs to try and understand the issues that may be happening in that ticket if this doesn't resolve them.
1 parent 418ec87 commit 5bc16a3

File tree

2 files changed

+91
-45
lines changed

2 files changed

+91
-45
lines changed

HomeAssistant/Views/WebViewController.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -711,9 +711,10 @@ extension WebViewController: WKScriptMessageHandler {
711711
} else if message.name == "updateThemeColors" {
712712
self.handleThemeUpdate(messageBody)
713713
} else if message.name == "getExternalAuth", let callbackName = messageBody["callback"] {
714+
let force = messageBody["force"] as? Bool ?? false
714715
if let tokenManager = Current.tokenManager {
715-
Current.Log.verbose("getExternalAuth called")
716-
tokenManager.authDictionaryForWebView.done { dictionary in
716+
Current.Log.verbose("getExternalAuth called, forced: \(force)")
717+
tokenManager.authDictionaryForWebView(forceRefresh: force).done { dictionary in
717718
let jsonData = try? JSONSerialization.data(withJSONObject: dictionary, options: [])
718719
if let jsonString = String(data: jsonData!, encoding: .utf8) {
719720
// swiftlint:disable:next line_length

Shared/Authentication/TokenManager.swift

Lines changed: 88 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,32 @@ public class TokenManager: RequestAdapter, RequestRetrier {
1919

2020
private var tokenInfo: TokenInfo?
2121
private var authenticationAPI: AuthenticationAPI
22-
private var refreshPromiseCache: Promise<String>?
22+
23+
private class RefreshPromiseCache {
24+
// we can be asked to refresh from any queue - alamofire's utility queue, webview's main queue, so guard
25+
// accessing the underlying promise here without being on the queue is programmer error
26+
let queue: DispatchQueue
27+
private let queueSpecific = DispatchSpecificKey<Bool>()
28+
29+
init() {
30+
queue = DispatchQueue(label: "refresh-promise-cache-mutex", qos: .userInitiated)
31+
queue.setSpecific(key: queueSpecific, value: true)
32+
}
33+
34+
private var underlyingPromise: Promise<String>?
35+
36+
var promise: Promise<String>? {
37+
get {
38+
assert(DispatchQueue.getSpecific(key: queueSpecific) == true)
39+
return underlyingPromise
40+
}
41+
set {
42+
assert(DispatchQueue.getSpecific(key: queueSpecific) == true)
43+
underlyingPromise = newValue
44+
}
45+
}
46+
}
47+
private let refreshPromiseCache = RefreshPromiseCache()
2348
private let connectionInfo: ConnectionInfo
2449

2550
public var isAuthenticated: Bool {
@@ -66,19 +91,25 @@ public class TokenManager: RequestAdapter, RequestRetrier {
6691
}
6792
}
6893

69-
public var authDictionaryForWebView: Promise<[String: Any]> {
70-
return firstly {
71-
self.bearerToken
72-
}.map { _ -> [String: Any] in
73-
// TokenInfo is refreshed at this point.
74-
guard let info = self.tokenInfo else {
75-
throw TokenError.tokenUnavailable
76-
}
94+
public func authDictionaryForWebView(forceRefresh: Bool) -> Promise<[String: Any]> {
95+
return firstly { () -> Promise<String> in
96+
if forceRefresh {
97+
Current.Log.info("forcing a refresh of token")
98+
return refreshToken
99+
} else {
100+
Current.Log.info("using existing token")
101+
return bearerToken
102+
}
103+
}.map { _ -> [String: Any] in
104+
// TokenInfo is refreshed at this point.
105+
guard let info = self.tokenInfo else {
106+
throw TokenError.tokenUnavailable
107+
}
77108

78-
var dictionary: [String: Any] = [:]
79-
dictionary["access_token"] = info.accessToken
80-
dictionary["expires_in"] = Int(info.expiration.timeIntervalSince(Current.date()))
81-
return dictionary
109+
var dictionary: [String: Any] = [:]
110+
dictionary["access_token"] = info.accessToken
111+
dictionary["expires_in"] = Int(info.expiration.timeIntervalSince(Current.date()))
112+
return dictionary
82113
}
83114
}
84115

@@ -220,41 +251,55 @@ public class TokenManager: RequestAdapter, RequestRetrier {
220251
return connectionInfo.checkURLMatches(url)
221252
}
222253

223-
private var newCodePromise: Promise<Void>?
224254
private var refreshToken: Promise<String> {
225-
guard let tokenInfo = self.tokenInfo else {
226-
return Promise(error: TokenError.tokenUnavailable)
227-
}
228-
229-
if let refreshPromise = self.refreshPromiseCache {
230-
return refreshPromise
231-
}
232-
233-
let promise: Promise<String> =
234-
self.authenticationAPI.refreshTokenWith(tokenInfo: tokenInfo).map { tokenInfo in
235-
self.refreshPromiseCache = nil
236-
Current.settingsStore.tokenInfo = tokenInfo
237-
self.tokenInfo = tokenInfo
238-
return tokenInfo.accessToken
239-
}.ensure {
240-
self.refreshPromiseCache = nil
241-
}
255+
refreshPromiseCache.queue.sync {
256+
guard let tokenInfo = self.tokenInfo else {
257+
Current.Log.error("no token info, can't refresh")
258+
return Promise(error: TokenError.tokenUnavailable)
259+
}
242260

243-
promise.catch { error in
244-
if let networkError = error as? AFError, let statusCode = networkError.responseCode,
245-
statusCode == 400 {
246-
/// Server rejected the refresh token. All is lost.
247-
let event = ClientEvent(text: "Refresh token is invalid, showing onboarding", type: .networkRequest)
248-
Current.clientEventStore.addEvent(event)
261+
if let refreshPromise = self.refreshPromiseCache.promise {
262+
Current.Log.info("using cached refreshToken promise")
263+
return refreshPromise
264+
}
249265

250-
self.tokenInfo = nil
251-
Current.settingsStore.tokenInfo = nil
252-
Current.signInRequiredCallback?(.error)
266+
let promise: Promise<String> = firstly {
267+
self.authenticationAPI.refreshTokenWith(tokenInfo: tokenInfo)
268+
}.map { tokenInfo in
269+
Current.Log.info("storing refresh token")
270+
Current.settingsStore.tokenInfo = tokenInfo
271+
self.tokenInfo = tokenInfo
272+
return tokenInfo.accessToken
273+
}.ensure(on: refreshPromiseCache.queue) {
274+
Current.Log.info("reset cached refreshToken promise")
275+
self.refreshPromiseCache.promise = nil
276+
}.tap { result in
277+
switch result {
278+
case .rejected(let error):
279+
Current.Log.error("refresh token got error: \(error)")
280+
281+
if let networkError = error as? AFError, let statusCode = networkError.responseCode,
282+
statusCode == 400 {
283+
/// Server rejected the refresh token. All is lost.
284+
let event = ClientEvent(
285+
text: "Refresh token is invalid, showing onboarding",
286+
type: .networkRequest
287+
)
288+
Current.clientEventStore.addEvent(event)
289+
290+
self.tokenInfo = nil
291+
Current.settingsStore.tokenInfo = nil
292+
Current.signInRequiredCallback?(.error)
293+
}
294+
case .fulfilled:
295+
Current.Log.info("refresh token got success")
296+
}
253297
}
254-
}
255298

256-
self.refreshPromiseCache = promise
257-
return promise
299+
Current.Log.info("starting refreshToken cache")
300+
self.refreshPromiseCache.promise = promise
301+
return promise
302+
}
258303
}
259304
}
260305

0 commit comments

Comments
 (0)