SecureTokenService.swift 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. // Copyright 2023 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. import Foundation
  15. private let kFiveMinutes = 5 * 60.0
  16. /// A class represents a credential that proves the identity of the app.
  17. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  18. @objc(FIRSecureTokenService) // objc Needed for decoding old versions
  19. class SecureTokenService: NSObject, NSSecureCoding {
  20. /// The configuration for making requests to server.
  21. var requestConfiguration: AuthRequestConfiguration?
  22. /// The cached access token.
  23. ///
  24. /// This method is specifically for providing the access token to internal clients during
  25. /// deserialization and sign-in events, and should not be used to retrieve the access token by
  26. /// anyone else.
  27. var accessToken: String
  28. /// The refresh token for the user, or `nil` if the user has yet completed sign-in flow.
  29. ///
  30. /// This property needs to be set manually after the instance is decoded from archive.
  31. var refreshToken: String?
  32. /// The expiration date of the cached access token.
  33. var accessTokenExpirationDate: Date?
  34. /// Creates a `SecureTokenService` with access and refresh tokens.
  35. /// - Parameter requestConfiguration: The configuration for making requests to server.
  36. /// - Parameter accessToken: The STS access token.
  37. /// - Parameter accessTokenExpirationDate: The approximate expiration date of the access token.
  38. /// - Parameter refreshToken: The STS refresh token.
  39. init(withRequestConfiguration requestConfiguration: AuthRequestConfiguration?,
  40. accessToken: String,
  41. accessTokenExpirationDate: Date?,
  42. refreshToken: String) {
  43. self.requestConfiguration = requestConfiguration
  44. self.accessToken = accessToken
  45. self.refreshToken = refreshToken
  46. self.accessTokenExpirationDate = accessTokenExpirationDate
  47. taskQueue = AuthSerialTaskQueue()
  48. }
  49. /// Fetch a fresh ephemeral access token for the ID associated with this instance. The token
  50. /// received in the callback should be considered short lived and not cached.
  51. ///
  52. /// Invoked asynchronously on the auth global work queue in the future.
  53. /// - Parameter forceRefresh: Forces the token to be refreshed.
  54. /// - Parameter callback: Callback block that will be called to return either the token or an
  55. /// error.
  56. func fetchAccessToken(forcingRefresh forceRefresh: Bool,
  57. callback: @escaping (String?, Error?, Bool) -> Void) {
  58. taskQueue.enqueueTask { complete in
  59. if !forceRefresh, self.hasValidAccessToken() {
  60. complete()
  61. callback(self.accessToken, nil, false)
  62. } else {
  63. AuthLog.logDebug(code: "I-AUT000017", message: "Fetching new token from backend.")
  64. Task {
  65. do {
  66. let (token, tokenUpdated) = try await self.requestAccessToken(retryIfExpired: true)
  67. complete()
  68. callback(token, nil, tokenUpdated)
  69. } catch {
  70. complete()
  71. callback(nil, error, false)
  72. }
  73. }
  74. }
  75. }
  76. }
  77. /// Fetch a fresh ephemeral access token for the ID associated with this instance. The token
  78. /// received in the callback should be considered short lived and not cached.
  79. ///
  80. /// Invoked asyncronously on the auth global work queue in the future.
  81. /// - Parameter forceRefresh: Forces the token to be refreshed.
  82. /// - Parameter callback: Callback block that will be called to return either the token or an
  83. /// error.
  84. func fetchAccessToken(user: User,
  85. forcingRefresh forceRefresh: Bool) async throws -> (String?, Bool) {
  86. return try await user.auth.authWorker.fetchAccessToken(user: user, forcingRefresh: forceRefresh)
  87. }
  88. private let taskQueue: AuthSerialTaskQueue
  89. // MARK: NSSecureCoding
  90. // Secure coding keys
  91. private let kAPIKeyCodingKey = "APIKey"
  92. private static let kRefreshTokenKey = "refreshToken"
  93. private static let kAccessTokenKey = "accessToken"
  94. private static let kAccessTokenExpirationDateKey = "accessTokenExpirationDate"
  95. static var supportsSecureCoding: Bool {
  96. true
  97. }
  98. required convenience init?(coder: NSCoder) {
  99. guard let refreshToken = coder.decodeObject(of: [NSString.self],
  100. forKey: Self.kRefreshTokenKey) as? String,
  101. let accessToken = coder.decodeObject(of: [NSString.self],
  102. forKey: Self.kAccessTokenKey) as? String else {
  103. return nil
  104. }
  105. let accessTokenExpirationDate = coder.decodeObject(
  106. of: [NSDate.self], forKey: Self.kAccessTokenExpirationDateKey
  107. ) as? Date
  108. // requestConfiguration is filled in after User is set by Auth.protectedDataInitialization.
  109. self.init(withRequestConfiguration: nil,
  110. accessToken: accessToken,
  111. accessTokenExpirationDate: accessTokenExpirationDate,
  112. refreshToken: refreshToken)
  113. }
  114. func encode(with coder: NSCoder) {
  115. // The API key is encoded even it is not used in decoding to be compatible with previous
  116. // versions of the library.
  117. coder.encode(requestConfiguration?.apiKey, forKey: kAPIKeyCodingKey)
  118. // Authorization code is not encoded because it is not long-lived.
  119. coder.encode(refreshToken, forKey: SecureTokenService.kRefreshTokenKey)
  120. coder.encode(accessToken, forKey: SecureTokenService.kAccessTokenKey)
  121. coder.encode(
  122. accessTokenExpirationDate,
  123. forKey: SecureTokenService.kAccessTokenExpirationDateKey
  124. )
  125. }
  126. // MARK: Private methods
  127. /// Makes a request to STS for an access token.
  128. ///
  129. /// This handles both the case that the token has not been granted yet and that it just
  130. /// needs to be refreshed. The caller is responsible for making sure that this is occurring in
  131. /// a `_taskQueue` task.
  132. ///
  133. /// Because this method is guaranteed to only be called from tasks enqueued in
  134. /// `_taskQueue`, we do not need any @synchronized guards around access to _accessToken/etc.
  135. /// since only one of those tasks is ever running at a time, and those tasks are the only
  136. /// access to and mutation of these instance variables.
  137. /// - Returns: Token and Bool indicating if update occurred.
  138. func requestAccessToken(retryIfExpired: Bool) async throws -> (String?, Bool) {
  139. // TODO: This was a crash in ObjC SDK, should it callback with an error?
  140. guard let refreshToken, let requestConfiguration else {
  141. fatalError("refreshToken and requestConfiguration should not be nil")
  142. }
  143. let request = SecureTokenRequest.refreshRequest(refreshToken: refreshToken,
  144. requestConfiguration: requestConfiguration)
  145. let response = try await AuthBackend.call(with: request)
  146. var tokenUpdated = false
  147. if let newAccessToken = response.accessToken,
  148. newAccessToken.count > 0,
  149. newAccessToken != accessToken {
  150. if let tokenResult = try? AuthTokenResult.tokenResult(token: newAccessToken) {
  151. // There is an edge case where the request for a new access token may be made right
  152. // before the app goes inactive, resulting in the callback being invoked much later
  153. // with an expired access token. This does not fully solve the issue, as if the
  154. // callback is invoked less than an hour after the request is made, a token is not
  155. // re-requested here but the approximateExpirationDate will still be off since that
  156. // is computed at the time the token is received.
  157. if retryIfExpired {
  158. let expirationDate = tokenResult.expirationDate
  159. if expirationDate.timeIntervalSinceNow <= kFiveMinutes {
  160. // We only retry once, to avoid an infinite loop in the case that an end-user has
  161. // their local time skewed by over an hour.
  162. return try await requestAccessToken(retryIfExpired: false)
  163. }
  164. }
  165. }
  166. accessToken = newAccessToken
  167. accessTokenExpirationDate = response.approximateExpirationDate
  168. tokenUpdated = true
  169. AuthLog.logDebug(
  170. code: "I-AUT000017",
  171. message: "Updated access token. Estimated expiration date: " +
  172. "\(String(describing: accessTokenExpirationDate)), current date: \(Date())"
  173. )
  174. }
  175. if let newRefreshToken = response.refreshToken,
  176. newRefreshToken != self.refreshToken {
  177. self.refreshToken = newRefreshToken
  178. tokenUpdated = true
  179. }
  180. return (response.accessToken, tokenUpdated)
  181. }
  182. func hasValidAccessToken() -> Bool {
  183. if let accessTokenExpirationDate,
  184. accessTokenExpirationDate.timeIntervalSinceNow > kFiveMinutes {
  185. AuthLog.logDebug(code: "I-AUT000017",
  186. message: "Has valid access token. Estimated expiration date:" +
  187. "\(accessTokenExpirationDate), current date: \(Date())")
  188. return true
  189. }
  190. AuthLog.logDebug(code: "I-AUT000017",
  191. message: "Does not have valid access token. Estimated expiration date:" +
  192. "\(String(describing: accessTokenExpirationDate)), current date: \(Date())")
  193. return false
  194. }
  195. }