SecureTokenService.swift 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  28. @objc(FIRSecureTokenService) public class SecureTokenService: NSObject, NSSecureCoding {
  29. /** @property requestConfiguration
  30. @brief The configuration for making requests to server.
  31. */
  32. @objc public var requestConfiguration: AuthRequestConfiguration?
  33. /** @property accessToken
  34. @brief The cached access token.
  35. @remarks This method is specifically for providing the access token to internal clients during
  36. deserialization and sign-in events, and should not be used to retrieve the access token by
  37. anyone else.
  38. */
  39. @objc public var accessToken: String
  40. /** @property refreshToken
  41. @brief The refresh token for the user, or @c nil if the user has yet completed sign-in flow.
  42. @remarks This property needs to be set manually after the instance is decoded from archive.
  43. */
  44. @objc public var refreshToken: String?
  45. /** @property accessTokenExpirationDate
  46. @brief The expiration date of the cached access token.
  47. */
  48. @objc public var accessTokenExpirationDate: Date?
  49. /** @fn initWithRequestConfiguration:accessToken:accessTokenExpirationDate:refreshToken
  50. @brief Creates a @c FIRSecureTokenService with access and refresh tokens.
  51. @param requestConfiguration The configuration for making requests to server.
  52. @param accessToken The STS access token.
  53. @param accessTokenExpirationDate The approximate expiration date of the access token.
  54. @param refreshToken The STS refresh token.
  55. */
  56. @objc public init(withRequestConfiguration requestConfiguration: AuthRequestConfiguration?,
  57. accessToken: String,
  58. accessTokenExpirationDate: Date?,
  59. refreshToken: String) {
  60. self.requestConfiguration = requestConfiguration
  61. self.accessToken = accessToken
  62. self.refreshToken = refreshToken
  63. self.accessTokenExpirationDate = accessTokenExpirationDate
  64. taskQueue = AuthSerialTaskQueue()
  65. }
  66. /** @fn fetchAccessTokenForcingRefresh:callback:
  67. @brief Fetch a fresh ephemeral access token for the ID associated with this instance. The token
  68. received in the callback should be considered short lived and not cached.
  69. @param forceRefresh Forces the token to be refreshed.
  70. @param callback Callback block that will be called to return either the token or an error.
  71. Invoked asyncronously on the auth global work queue in the future.
  72. */
  73. @objc public func fetchAccessToken(forcingRefresh forceRefresh: Bool,
  74. callback: @escaping (String?, Error?, Bool) -> Void) {
  75. taskQueue.enqueueTask { complete in
  76. if !forceRefresh, self.hasValidAccessToken() {
  77. complete()
  78. callback(self.accessToken, nil, false)
  79. } else {
  80. AuthLog.logDebug(code: "I-AUT000017", message: "Fetching new token from backend.")
  81. self.requestAccessToken(retryIfExpired: true) { token, error, tokenUpdated in
  82. complete()
  83. callback(token, error, tokenUpdated)
  84. }
  85. }
  86. }
  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. public static var supportsSecureCoding: Bool {
  96. true
  97. }
  98. public 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. // TODO: the nil matches the ObjC implementation, but doesn't seem right.
  109. self.init(withRequestConfiguration: nil,
  110. accessToken: accessToken,
  111. accessTokenExpirationDate: accessTokenExpirationDate,
  112. refreshToken: refreshToken)
  113. }
  114. public 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. /** @fn requestAccessToken:
  128. @brief Makes a request to STS for an access token.
  129. @details 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 @c _taskQueue task.
  132. @param callback Called when the fetch is complete. Invoked asynchronously on the main thread in
  133. the future.
  134. @remarks Because this method is guaranteed to only be called from tasks enqueued in
  135. @c _taskQueue, we do not need any @synchronized guards around access to _accessToken/etc.
  136. since only one of those tasks is ever running at a time, and those tasks are the only
  137. access to and mutation of these instance variables.
  138. */
  139. private func requestAccessToken(retryIfExpired: Bool,
  140. callback: @escaping (String?, Error?, Bool) -> Void) {
  141. // TODO: This was a crash in ObjC SDK, should it callback with an error?
  142. guard let refreshToken, let requestConfiguration else {
  143. fatalError("refreshToken and requestConfiguration should not be nil")
  144. }
  145. let request = SecureTokenRequest.refreshRequest(refreshToken: refreshToken,
  146. requestConfiguration: requestConfiguration)
  147. AuthBackend.post(with: request) { rawResponse, error in
  148. var tokenUpdated = false
  149. if let response = rawResponse {
  150. if let newAccessToken = response.accessToken,
  151. newAccessToken.count > 0,
  152. newAccessToken != self.accessToken {
  153. let tokenResult = AuthTokenResult.tokenResult(token: newAccessToken)
  154. // There is an edge case where the request for a new access token may be made right
  155. // before the app goes inactive, resulting in the callback being invoked much later
  156. // with an expired access token. This does not fully solve the issue, as if the
  157. // callback is invoked less than an hour after the request is made, a token is not
  158. // re-requested here but the approximateExpirationDate will still be off since that
  159. // is computed at the time the token is received.
  160. if retryIfExpired,
  161. let expirationDate = tokenResult?.expirationDate,
  162. expirationDate.timeIntervalSinceNow <= kFiveMinutes {
  163. // We only retry once, to avoid an infinite loop in the case that an end-user has
  164. // their local time skewed by over an hour.
  165. self.requestAccessToken(retryIfExpired: false, callback: callback)
  166. return
  167. }
  168. self.accessToken = newAccessToken
  169. self.accessTokenExpirationDate = response.approximateExpirationDate
  170. tokenUpdated = true
  171. AuthLog.logDebug(
  172. code: "I-AUT000017",
  173. message: "Updated access token. Estimated expiration date: " +
  174. "\(String(describing: self.accessTokenExpirationDate)), current date: \(Date())"
  175. )
  176. }
  177. if let newRefreshToken = response.refreshToken,
  178. newRefreshToken != self.refreshToken {
  179. self.refreshToken = newRefreshToken
  180. tokenUpdated = true
  181. }
  182. callback(response.accessToken, error, tokenUpdated)
  183. return
  184. }
  185. // Not clear this fall through case was considered in original ObjC implementation.
  186. callback(nil, error, false)
  187. }
  188. }
  189. private func hasValidAccessToken() -> Bool {
  190. if let accessTokenExpirationDate,
  191. accessTokenExpirationDate.timeIntervalSinceNow > kFiveMinutes {
  192. AuthLog.logDebug(code: "I-AUT000017",
  193. message: "Has valid access token. Estimated expiration date:" +
  194. "\(accessTokenExpirationDate), current date: \(Date())")
  195. return true
  196. }
  197. AuthLog.logDebug(code: "I-AUT000017",
  198. message: "Does not have valid access token. Estimated expiration date:" +
  199. "\(String(describing: accessTokenExpirationDate)), current date: \(Date())")
  200. return false
  201. }
  202. }