diff --git a/FirebaseAuth/Sources/Swift/Auth/AuthTokenResult.swift b/FirebaseAuth/Sources/Swift/Auth/AuthTokenResult.swift index 76bdbb73204..19019b9d8a2 100644 --- a/FirebaseAuth/Sources/Swift/Auth/AuthTokenResult.swift +++ b/FirebaseAuth/Sources/Swift/Auth/AuthTokenResult.swift @@ -14,10 +14,12 @@ import Foundation +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) extension AuthTokenResult: NSSecureCoding {} /// A data class containing the ID token JWT string and other properties associated with the /// token including the decoded payload claims. +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @objc(FIRAuthTokenResult) open class AuthTokenResult: NSObject { /// Stores the JWT string of the ID token. @objc open var token: String @@ -47,12 +49,12 @@ extension AuthTokenResult: NSSecureCoding {} /// reserved claims as well as custom claims set by the developer via the Admin SDK. @objc open var claims: [String: Any] - private class func getTokenPayloadData(_ token: String) -> Data? { + private class func getTokenPayloadData(_ token: String) throws -> Data { let tokenStringArray = token.components(separatedBy: ".") // The JWT should have three parts, though we only use the second in this method. if tokenStringArray.count != 3 { - return nil + throw AuthErrorUtils.malformedJWTError(token: token, underlyingError: nil) } /// The token payload is always the second index of the array. @@ -69,24 +71,36 @@ extension AuthTokenResult: NSSecureCoding {} let length = tokenPayload.count + (4 - tokenPayload.count % 4) tokenPayload = tokenPayload.padding(toLength: length, withPad: "=", startingAt: 0) } - return Data(base64Encoded: tokenPayload, options: [.ignoreUnknownCharacters]) + guard let data = Data(base64Encoded: tokenPayload, options: [.ignoreUnknownCharacters]) else { + throw AuthErrorUtils.malformedJWTError(token: token, underlyingError: nil) + } + return data } - private class func getTokenPayloadDictionary(_ payloadData: Data) -> [String: Any]? { - return try? JSONSerialization.jsonObject( - with: payloadData, - options: [.mutableContainers, .allowFragments] - ) as? [String: Any] + private class func getTokenPayloadDictionary(_ token: String, + _ payloadData: Data) throws -> [String: Any] { + do { + if let dictionary = try JSONSerialization.jsonObject( + with: payloadData, + options: [.mutableContainers, .allowFragments] + ) as? [String: Any] { + return dictionary + } else { + return [:] + } + } catch { + throw AuthErrorUtils.malformedJWTError(token: token, underlyingError: error) + } } - private class func getJWT(_ payloadData: Data) -> JWT? { + private class func getJWT(_ token: String, _ payloadData: Data) throws -> JWT { // These are dates since 00:00:00 January 1 1970, as described by the Terminology section in // the JWT spec. https://tools.ietf.org/html/rfc7519 let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 decoder.keyDecodingStrategy = .convertFromSnakeCase guard let jwt = try? decoder.decode(JWT.self, from: payloadData) else { - return nil + throw AuthErrorUtils.malformedJWTError(token: token, underlyingError: nil) } return jwt } @@ -94,12 +108,10 @@ extension AuthTokenResult: NSSecureCoding {} /// Parse a token string to a structured token. /// - Parameter token: The token string to parse. /// - Returns: A structured token result. - @objc open class func tokenResult(token: String) -> AuthTokenResult? { - guard let payloadData = getTokenPayloadData(token), - let claims = getTokenPayloadDictionary(payloadData), - let jwt = getJWT(payloadData) else { - return nil - } + class func tokenResult(token: String) throws -> AuthTokenResult { + let payloadData = try getTokenPayloadData(token) + let claims = try getTokenPayloadDictionary(token, payloadData) + let jwt = try getJWT(token, payloadData) return AuthTokenResult(token: token, jwt: jwt, claims: claims) } @@ -132,9 +144,9 @@ extension AuthTokenResult: NSSecureCoding {} ) as? String else { return nil } - guard let payloadData = AuthTokenResult.getTokenPayloadData(token), - let claims = AuthTokenResult.getTokenPayloadDictionary(payloadData), - let jwt = AuthTokenResult.getJWT(payloadData) else { + guard let payloadData = try? AuthTokenResult.getTokenPayloadData(token), + let claims = try? AuthTokenResult.getTokenPayloadDictionary(token, payloadData), + let jwt = try? AuthTokenResult.getJWT(token, payloadData) else { return nil } self.init(token: token, jwt: jwt, claims: claims) diff --git a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift index e7dc9c8d0d6..b00a9802769 100644 --- a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift +++ b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift @@ -153,19 +153,21 @@ class SecureTokenService: NSObject, NSSecureCoding { if let newAccessToken = response.accessToken, newAccessToken.count > 0, newAccessToken != accessToken { - let tokenResult = AuthTokenResult.tokenResult(token: newAccessToken) - // There is an edge case where the request for a new access token may be made right - // before the app goes inactive, resulting in the callback being invoked much later - // with an expired access token. This does not fully solve the issue, as if the - // callback is invoked less than an hour after the request is made, a token is not - // re-requested here but the approximateExpirationDate will still be off since that - // is computed at the time the token is received. - if retryIfExpired, - let expirationDate = tokenResult?.expirationDate, - expirationDate.timeIntervalSinceNow <= kFiveMinutes { - // We only retry once, to avoid an infinite loop in the case that an end-user has - // their local time skewed by over an hour. - return try await requestAccessToken(retryIfExpired: false) + if let tokenResult = try? AuthTokenResult.tokenResult(token: newAccessToken) { + // There is an edge case where the request for a new access token may be made right + // before the app goes inactive, resulting in the callback being invoked much later + // with an expired access token. This does not fully solve the issue, as if the + // callback is invoked less than an hour after the request is made, a token is not + // re-requested here but the approximateExpirationDate will still be off since that + // is computed at the time the token is received. + if retryIfExpired { + let expirationDate = tokenResult.expirationDate + if expirationDate.timeIntervalSinceNow <= kFiveMinutes { + // We only retry once, to avoid an infinite loop in the case that an end-user has + // their local time skewed by over an hour. + return try await requestAccessToken(retryIfExpired: false) + } + } } accessToken = newAccessToken accessTokenExpirationDate = response.approximateExpirationDate diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index 84fc4b6c820..e324aa4da2e 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -532,9 +532,9 @@ extension User: NSSecureCoding {} /// reason other than an expiration. /// - Returns: The Firebase authentication token. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - open func getIDToken() async throws -> String { + open func getIDToken(forcingRefresh forceRefresh: Bool = false) async throws -> String { return try await withCheckedThrowingContinuation { continuation in - self.getIDTokenForcingRefresh(false) { tokenResult, error in + self.getIDTokenForcingRefresh(forceRefresh) { tokenResult, error in if let tokenResult { continuation.resume(returning: tokenResult) } else if let error { @@ -544,6 +544,12 @@ extension User: NSSecureCoding {} } } + /// API included for compatibilty with a mis-named Firebase 10 API. + /// Use `getIDToken(forcingRefresh forceRefresh: Bool = false)` instead. + open func idTokenForcingRefresh(_ forceRefresh: Bool) async throws -> String { + return try await getIDToken(forcingRefresh: forceRefresh) + } + /// Retrieves the Firebase authentication token, possibly refreshing it if it has expired. /// - Parameter completion: Optionally; the block invoked when the token is available. Invoked /// asynchronously on the main thread in the future. @@ -574,14 +580,29 @@ extension User: NSSecureCoding {} self.internalGetToken(forceRefresh: forcingRefresh) { token, error in var tokenResult: AuthTokenResult? if let token { - tokenResult = AuthTokenResult.tokenResult(token: token) - AuthLog.logDebug(code: "I-AUT000017", message: "Actual token expiration date: " + - "\(String(describing: tokenResult?.expirationDate))," + - "current date: \(Date())") + do { + tokenResult = try AuthTokenResult.tokenResult(token: token) + AuthLog.logDebug(code: "I-AUT000017", message: "Actual token expiration date: " + + "\(String(describing: tokenResult?.expirationDate))," + + "current date: \(Date())") + if let completion { + DispatchQueue.main.async { + completion(tokenResult, error) + } + } + return + } catch { + if let completion { + DispatchQueue.main.async { + completion(tokenResult, error) + } + } + return + } } if let completion { DispatchQueue.main.async { - completion(tokenResult, error) + completion(nil, error) } } } diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/UserActions.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/UserActions.swift index a2d87183c4a..6fee389392b 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/UserActions.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Models/UserActions.swift @@ -18,6 +18,7 @@ enum UserAction: String { case link = "Link/Unlink Auth Providers" case requestVerifyEmail = "Request Verify Email" case tokenRefresh = "Token Refresh" + case tokenRefreshAsync = "Token Refresh Async" case delete = "Delete" case updateEmail = "Email" case updatePhotoURL = "Photo URL" diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift index 3920df0bbd5..d4b0fad3d6f 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/Utility/Extensions.swift @@ -54,6 +54,7 @@ extension User: DataSourceProvidable { Item(title: UserAction.requestVerifyEmail.rawValue, textColor: .systemBlue), Item(title: UserAction.updatePassword.rawValue, textColor: .systemBlue), Item(title: UserAction.tokenRefresh.rawValue, textColor: .systemBlue), + Item(title: UserAction.tokenRefreshAsync.rawValue, textColor: .systemBlue), Item(title: UserAction.delete.rawValue, textColor: .systemRed), ] return Section(headerDescription: "Actions", items: actionsRows) diff --git a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/UserViewController.swift b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/UserViewController.swift index 6a8bb40220c..bc178422e47 100644 --- a/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/UserViewController.swift +++ b/FirebaseAuth/Tests/SampleSwift/AuthenticationExample/ViewControllers/UserViewController.swift @@ -86,6 +86,9 @@ class UserViewController: UIViewController, DataSourceProviderDelegate { case .tokenRefresh: refreshCurrentUserIDToken() + case .tokenRefreshAsync: + refreshCurrentUserIDTokenAsync() + case .delete: deleteCurrentUser() @@ -143,6 +146,17 @@ class UserViewController: UIViewController, DataSourceProviderDelegate { } } + public func refreshCurrentUserIDTokenAsync() { + Task { + do { + let token = try await user!.idTokenForcingRefresh(true) + print("New token: \(token)") + } catch { + self.displayError(error) + } + } + } + public func refreshUserInfo() { user?.reload { error in if let error = error {