SecureTokenService.swift 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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. /** @var kReceiptKey
  16. @brief The key used to encode the receipt property for NSSecureCoding.
  17. */
  18. private let kReceiptKey = "receipt"
  19. /** @var kSecretKey
  20. @brief The key used to encode the secret property for NSSecureCoding.
  21. */
  22. private let kSecretKey = "secret"
  23. private let kFiveMinutes = 5 * 60.0
  24. /** @class FIRAuthAppCredential
  25. @brief A class represents a credential that proves the identity of the app.
  26. */
  27. @objc(FIRSecureTokenService) public class SecureTokenService: NSObject, NSSecureCoding {
  28. /** @property requestConfiguration
  29. @brief The configuration for making requests to server.
  30. */
  31. @objc public var requestConfiguration: AuthRequestConfiguration?
  32. /** @property accessToken
  33. @brief The cached access token.
  34. @remarks This method is specifically for providing the access token to internal clients during
  35. deserialization and sign-in events, and should not be used to retrieve the access token by
  36. anyone else.
  37. */
  38. @objc public var accessToken: String
  39. /** @property refreshToken
  40. @brief The refresh token for the user, or @c nil if the user has yet completed sign-in flow.
  41. @remarks This property needs to be set manually after the instance is decoded from archive.
  42. */
  43. @objc public var refreshToken: String?
  44. /** @property accessTokenExpirationDate
  45. @brief The expiration date of the cached access token.
  46. */
  47. @objc public var accessTokenExpirationDate: Date?
  48. /** @fn initWithRequestConfiguration:accessToken:accessTokenExpirationDate:refreshToken
  49. @brief Creates a @c FIRSecureTokenService with access and refresh tokens.
  50. @param requestConfiguration The configuration for making requests to server.
  51. @param accessToken The STS access token.
  52. @param accessTokenExpirationDate The approximate expiration date of the access token.
  53. @param refreshToken The STS refresh token.
  54. */
  55. @objc public init(withRequestConfiguration requestConfiguration: AuthRequestConfiguration?,
  56. accessToken: String,
  57. accessTokenExpirationDate: Date?,
  58. refreshToken: String) {
  59. self.requestConfiguration = requestConfiguration
  60. self.accessToken = accessToken
  61. self.refreshToken = refreshToken
  62. self.accessTokenExpirationDate = accessTokenExpirationDate
  63. taskQueue = AuthSerialTaskQueue()
  64. }
  65. /** @fn fetchAccessTokenForcingRefresh:callback:
  66. @brief Fetch a fresh ephemeral access token for the ID associated with this instance. The token
  67. received in the callback should be considered short lived and not cached.
  68. @param forceRefresh Forces the token to be refreshed.
  69. @param callback Callback block that will be called to return either the token or an error.
  70. Invoked asyncronously on the auth global work queue in the future.
  71. */
  72. @objc public func fetchAccessToken(forcingRefresh forceRefresh: Bool,
  73. callback: @escaping (String?, Error?, Bool) -> Void) {
  74. taskQueue.enqueueTask { complete in
  75. if !forceRefresh, self.hasValidAccessToken() {
  76. complete()
  77. callback(self.accessToken, nil, false)
  78. } else {
  79. AuthLog.logDebug(code: "I-AUT000017", message: "Fetching new token from backend.")
  80. self.requestAccessToken(retryIfExpired: true) { token, error, tokenUpdated in
  81. complete()
  82. callback(token, error, tokenUpdated)
  83. }
  84. }
  85. }
  86. }
  87. private let taskQueue: AuthSerialTaskQueue
  88. // MARK: NSSecureCoding
  89. // Secure coding keys
  90. private let kAPIKeyCodingKey = "APIKey"
  91. private static let kRefreshTokenKey = "refreshToken"
  92. private static let kAccessTokenKey = "accessToken"
  93. private static let kAccessTokenExpirationDateKey = "accessTokenExpirationDate"
  94. public static var supportsSecureCoding: Bool {
  95. true
  96. }
  97. public required convenience init?(coder: NSCoder) {
  98. guard let refreshToken = coder.decodeObject(of: [NSString.self],
  99. forKey: Self.kRefreshTokenKey) as? String,
  100. let accessToken = coder.decodeObject(of: [NSString.self],
  101. forKey: Self.kAccessTokenKey) as? String else {
  102. return nil
  103. }
  104. let accessTokenExpirationDate = coder.decodeObject(
  105. of: [NSDate.self], forKey: Self.kAccessTokenExpirationDateKey
  106. ) as? Date
  107. // TODO: the nil matches the ObjC implementation, but doesn't seem right.
  108. self.init(withRequestConfiguration: nil,
  109. accessToken: accessToken,
  110. accessTokenExpirationDate: accessTokenExpirationDate,
  111. refreshToken: refreshToken)
  112. }
  113. public func encode(with coder: NSCoder) {
  114. // The API key is encoded even it is not used in decoding to be compatible with previous versions
  115. // of the library.
  116. coder.encode(requestConfiguration?.apiKey, forKey: kAPIKeyCodingKey)
  117. // Authorization code is not encoded because it is not long-lived.
  118. coder.encode(refreshToken, forKey: SecureTokenService.kRefreshTokenKey)
  119. coder.encode(accessToken, forKey: SecureTokenService.kAccessTokenKey)
  120. coder.encode(
  121. accessTokenExpirationDate,
  122. forKey: SecureTokenService.kAccessTokenExpirationDateKey
  123. )
  124. }
  125. // MARK: Private methods
  126. /** @fn requestAccessToken:
  127. @brief Makes a request to STS for an access token.
  128. @details This handles both the case that the token has not been granted yet and that it just
  129. needs to be refreshed. The caller is responsible for making sure that this is occurring in
  130. a @c _taskQueue task.
  131. @param callback Called when the fetch is complete. Invoked asynchronously on the main thread in
  132. the future.
  133. @remarks Because this method is guaranteed to only be called from tasks enqueued in
  134. @c _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. */
  138. private func requestAccessToken(retryIfExpired: Bool,
  139. callback: @escaping (String?, Error?, Bool) -> Void) {
  140. // TODO: This was a crash in ObjC SDK, should it callback with an error?
  141. guard let refreshToken, let requestConfiguration else {
  142. fatalError("refreshToken and requestConfiguration should not be nil")
  143. }
  144. let request = SecureTokenRequest.refreshRequest(refreshToken: refreshToken,
  145. requestConfiguration: requestConfiguration)
  146. AuthBackend.post(withRequest: request) { rawResponse, error in
  147. var tokenUpdated = false
  148. if let response = rawResponse as? SecureTokenResponse {
  149. if let newAccessToken = response.accessToken,
  150. newAccessToken.count > 0,
  151. newAccessToken != self.accessToken {
  152. let tokenResult = AuthTokenResult.tokenResult(token: newAccessToken)
  153. // There is an edge case where the request for a new access token may be made right
  154. // before the app goes inactive, resulting in the callback being invoked much later
  155. // with an expired access token. This does not fully solve the issue, as if the
  156. // callback is invoked less than an hour after the request is made, a token is not
  157. // re-requested here but the approximateExpirationDate will still be off since that
  158. // is computed at the time the token is received.
  159. if retryIfExpired,
  160. let expirationDate = tokenResult?.expirationDate,
  161. expirationDate.timeIntervalSinceNow <= kFiveMinutes {
  162. // We only retry once, to avoid an infinite loop in the case that an end-user has
  163. // their local time skewed by over an hour.
  164. self.requestAccessToken(retryIfExpired: false, callback: callback)
  165. return
  166. }
  167. self.accessToken = newAccessToken
  168. self.accessTokenExpirationDate = response.approximateExpirationDate
  169. tokenUpdated = true
  170. AuthLog.logDebug(
  171. code: "I-AUT000017",
  172. message: "Updated access token. Estimated expiration date: " +
  173. "\(String(describing: self.accessTokenExpirationDate)), current date: \(Date())"
  174. )
  175. }
  176. if let newRefreshToken = response.refreshToken,
  177. newRefreshToken != self.refreshToken {
  178. self.refreshToken = newRefreshToken
  179. tokenUpdated = true
  180. }
  181. callback(response.accessToken, error, tokenUpdated)
  182. return
  183. }
  184. // Not clear this fall through case was considered in original ObjC implementation.
  185. callback(nil, error, false)
  186. }
  187. }
  188. private func hasValidAccessToken() -> Bool {
  189. if let accessTokenExpirationDate,
  190. accessTokenExpirationDate.timeIntervalSinceNow > kFiveMinutes {
  191. AuthLog.logDebug(code: "I-AUT000017",
  192. message: "Has valid access token. Estimated expiration date:" +
  193. "\(accessTokenExpirationDate), current date: \(Date())")
  194. return true
  195. }
  196. AuthLog.logDebug(code: "I-AUT000017",
  197. message: "Does not have valid access token. Estimated expiration date:" +
  198. "\(String(describing: accessTokenExpirationDate)), current date: \(Date())")
  199. return false
  200. }
  201. }