I have a strange bug that is occurring only on few user iPhones, details below -
The app consumes a universal framework (developed by ourself) to save accessToken and refreshToken after successful login to the Keychain. We are using Locksmith to achieve the functionality - Save, load data and delete when the user is logged out.
Everytime when the app is killed and launched or applicationWillEnterForeground, the tokens are refreshed with the help of a service call and are saved to keychain again. When the refreshToken expires (this token is valid for one month), user is notified that the app has not been used for a long time and he is logged out.
The actual problem is here that for only few users, the refresh mechanism fails even when they are using the app daily (i.e. not before completion of one month of the refreshToken). After verification with backend team, the refresh service is always up so I suspect the Locksmith loadDataForUserAccount but unable to reproduce the issue. Also, may users do NOT face the problem. Everything works normally as expected.
Can someone help me move further how to identify the cause?
Below is the code to refresh the accessToken and refreshToken
** Refresh token call From the App when app enters foreground or killed and launched**
if let mySession = ServiceLayer.sharedInstance.session {
mySession.refresh { result in
switch result {
case .failure(.authenticationFailure):
if isBackgroundFetch {
print("👤⚠️ Session refresh failed, user is now logged out.")
self.myService.logoutCurrentUser()
// Logout Current user
mySession.invalidate()
self.showLoginUI()
}
else {
// user accessToken is invalid but provide access to QR
// on the home screen. disable all other actions except logout button
self.showHomeScreen()
}
default:
mySession.getAccessToken { result in
switch result {
case let .success(value):
print("Access Token from App Delegate \(value)")
myAccessToken = value
case let .failure(error):
print("❌ Failed to fetch AccessToken: \(error)")
}
}
}
}
}
From the framework where the refresh mechanism is implemented
public func refresh(_ completion: #escaping (MyResult<String, MyError>) -> (Void)) {
guard isValid else {
completion(.failure(.invalidSession))
return
}
getRefreshToken { result in
switch result {
case let .success(refreshToken):
// Get new tokens.
ServiceClient.requestJSON(ServiceRequest.refreshToken(refreshToken: refreshToken)) { result in
switch result {
case let .success(dictionary):
var newAccessToken: String?
var newRefreshToken: String?
for (key, value) in dictionary {
if key as! String == "access_token" {
newAccessToken = value as? String
}
if key as! String == "refresh_token" {
newRefreshToken = value as? String
}
}
guard newAccessToken != nil && newRefreshToken != nil else {
completion(.failure(.general))
return
}
print("Renewed session tokens.")
do {
try Locksmith.updateData(data: [MySession.accessTokenKeychainKey: newAccessToken!, MySession.refreshTokenKeychainKey: newRefreshToken!],
forUserAccount: MySession.myKeychainAccount)
}
catch {
completion(.failure(.general))
}
completion(.success(newAccessToken!))
case let .failure(error):
if error == MyError.authenticationFailure {
print(“Session refresh failed due to authentication error; invalidating session.")
self.invalidate()
}
completion(.failure(error))
}
}
case let .failure(error):
completion(.failure(error))
}
}
}
The app is likely being launched in the background while the device is locked (for app refresh or other background mode you've configured). Protected data (including Keychain) is not necessarily available at that time. You can check UIApplication.isProtectedDataAvailable to check if it's available, and you can reduce the protection of the item to kSecAttrAccessibleAfterFirstUnlock in order to have background access more reliably (though not 100% promised, even in that mode). Locksmith calls this AfterFirstUnlock.
Related
I am doing an Alamofire request, and during login, it gives me access token and refresh token. After getting access token, I save it in keychain. Every 20 minutes the access token expires and I need to convert it to refresh token.
Below is the code of saving in keychain.
final class KeychainManager {
let keychain = Keychain(service: "com.app")
func saveToken(token: String) {
do {
try keychain.set(token, key: "accessToken")
} catch let error {
print(error)
}
}
func getAccessToken() -> String? {
let token = try? keychain.getString(accessTokenKey)
return token
}
}
And here is my Alamofire request
AF.upload(multipartFormData: { multiFormData in
for form in bodyKeyValue {
multiFormData.append(Data(form.sValue.utf8), withName: form.sKey)
}
}, to: url).responseData { response in
switch response.result {
case .success(_):
do {
let decodedData = try JSONDecoder().decode(LoginResponseBody.self, from: response.data!)
self.keychain.saveToken(token: decodedData.data.accessToken)
completion(.success(decodedData))
} catch {
completion(.failure(.serverError))
}
case .failure(_):
print("fail")
}
}
Now I don't know how to use , refresh token here, so when access token expires, it will be converted to refresh token. Does Alamofire have a function for that?
Generally as a good rule of thumb.
When your access token expires and you need to use the refresh token.
What you should do is:
When the app makes the API call and the token is no longer valid (IE: time to use refresh) , when the call fails here in the do block.
do {
let decodedData = try JSONDecoder().decode(LoginResponseBody.self, from: response.data!)
self.keychain.saveToken(token: decodedData.data.accessToken)
completion(.success(decodedData))
} catch {
completion(.failure(.serverError))
}
When the completionHandler(.failure(.serverError)) is triggered
You can make another call here to retrieve the refresh token/generate a new one. Either inside this function or in the viewController.
so in your app, when your function call returns completionHandler(.failure(.serverError)) , add the function call into the app(either in the failure block or inside the viewController, depending on your app and dev preference) on failure retrieve new access token/refresh token then make the same API called that failed.
If a user prematurely cancels a Firebase auth multifactor session, is there any way to reset that session?
The error is FIRAuthErrorCodeWebContextAlreadyPresented; code 17057.
I am creating an asynchronous task that retrieves the multifactor session and attempts to verify that user's phone number but there is a use case that causes the page to unwind and logout to a previous page when the app is pushed to the background. When this happens, there is no way to reset this session and try to verify the phone number except by closing the app and restarting.
The easy answer would be to prevent my app from unwinding and prematurely cancelling this flow but I figured there must be SOME way of resetting this session.
Here is some example code that I have been following: https://cloud.google.com/identity-platform/docs/ios/mfa
mfaEnrollmentTask = Task {() -> String? in
do {
guard let multifactorSession = try? await user.multiFactor.session() else {
print("Unable to configure multi-factor session.")
return nil
}
try Task.checkCancellation()
let verificationId = try await PhoneAuthProvider.provider().verifyPhoneNumber(phoneNumber, uiDelegate: nil, multiFactorSession: multifactorSession)
return verificationId
} catch {
print(error.localizedDescription)
return nil
}
}
return try? await mfaEnrollmentTask?.value
I have a very weird issue to me right now. Initially I thought it has to do with realm, cos I was initially persisting auth token in realm. I eventually had to move it keychain. The issue is this - when a new user signup successfully an auth token is saved to keychain for resuse to hit authenticated end point. However for every new users that signup and tries to make request to an authenticated route, it keeps saying invalid auth token. But the moment the new user terminates the app and relaunch it the error goes away and the new user who was previously unable to make request to authenticated end point will now be able to. I have research the root countless number of times and still no head way I will appreciate your support and pointers to resolved this issue. when i print the auth token from key chain the moment the user is making the request, the token is available. this is how I am retrieving the token from keychain
class UserData {
static let instance = UserData()
private init() {}
func userAuthToken() -> String {
guard let authToken = KeychainWrapper.standard.string(forKey: Const.authToken) else { return ""}
return authToken
}
func userisAuthenticated() -> Bool {
guard let isEnabled = KeychainWrapper.standard.bool(forKey: Const.isEnabled) else { return false}
return isEnabled
}
func userRefreshToken() -> String {
guard let refreshToken = KeychainWrapper.standard.string(forKey: Const.refreshToken) else { return ""}
return refreshToken
}
}
this is the code for saving to keychain at the time of signup
KeychainWrapper.standard.set(true, forKey: Const.isEnabled)
KeychainWrapper.standard.set("2308r20390f", forKey: Const.authToken)
KeychainWrapper.standard.set("2729894820", forKey: Const.refreshToken)
May be you call API in signup process that store default value to token , in request and not change to new saved one until you close app and open again ,
so you have to change API Manager to deal with this point.
to Test this try to use any token in Any api before Signup process
In my iOS app, I'm using Firebase Transactions to update scoreboards based on user-generated data to avoid mistakes with concurrent updates. I save data at three points:
when the user presses 'Sign Out'
when the user force quits the app (not working yet due to timeout issue)
when the day changes while the app is running
Below is the transaction code:
func saveDataToFirebase(signUserOut: Bool)
{
let countyRef = Database.database().reference().child("countyleaderboard")
countyRef.child(self.userCounty).runTransactionBlock({ (currentData: MutableData) in
var value = currentData.value as? Float
if value == nil
{
value = Float(0.00)
}
currentData.value = value! + self.currentSessionAlt
return TransactionResult.success(withValue: currentData)
}, andCompletionBlock: {
error, commited, snap in
if commited && signUserOut
{
do
{
print("transaction complete")
try Auth.auth().signOut()
}
catch let logoutError
{
print(logoutError)
}
}
})
}
The code reaches the sign out method, but the database is not being updated. I ran the code separately, without including user sign out and the transaction completes fine.
Why does the transaction not actually complete before signing the user out? And is there a way to fix this?
I’m using Realm Object Server for a simple test project and I’m facing problems synchronizing ROS connection setup and follow up usage of the realm object to access the database.
In viewDidLoad I’m calling connectROS function to initialize realmRos object/connection:
var realmRos: Realm!
override func viewDidLoad() {
connectROS()
if(FBSDKAccessToken.current() != nil){
// logged in
getFBUserData()
}else{
// not logged in
print("didLoad, FB user not logged in")
}
}
func connectROS() {
let username = "realm-admin"
let password = "*********"
SyncUser.logIn(with: .usernamePassword(username: username, password: password, register: false), server: URL(string: "http://146.185.154.***:9080")!)
{ user, error in
print("ROS: checking user credentials")
if let user = user {
print("ROS: user credentials OK")
DispatchQueue.main.async {
// Opening a remote Realm
print("ROS: entering dispatch Q main async")
let realmURL = URL(string: "realm://146.185.154.***:9080/~/***book_test1")!
let config = Realm.Configuration(syncConfiguration: SyncConfiguration(user: user, realmURL: realmURL))
self.realmRos = try! Realm(configuration: config)
// Any changes made to this Realm will be synced across all devices!
}
} else if let error = error {
// handle error
print("ROS: user check FAIL")
fatalError(String(describing: error))
}
}
}
In viewDidLoad function next step is to get FB logged user (in this case I’m using FB authentication). After the logged FB user is fetched, the application perform check is that FB user is new user for my application and my proprietary ROS User’s table.
func checkForExistingProfile(user: User) -> Bool {
var userThatExist: User?
do {
try self.realmRos.write() {
userThatExist = self.realmRos.object(ofType: User.self, forPrimaryKey: user.userName)
}
} catch let error as NSError {
print("ROS is not connected")
print(error.localizedDescription)
}
if userThatExist != nil {
return true
} else {
return false
}
}
At this point checkForExistingProfile usually (not always) crashes at try self.realmRos.write() which happens to be nil.
I think the problem comes from the synchronization between connectROS execution (which is asynchrony) and checkForExistingProfile which execution comes before connectROS completion.
Since you didn't show how checkForExistingProfile() is called after viewDidLoad() this is conjecture, but based on everything else you described it's the likely cause.
What you need to do is not call checkForExistingProfile() until the sync user has logged in and your self.realmRos variable has been initialized. Cocoa Touch does nothing to automatically synchronize code written using an asynchronous pattern (like logIn(), which returns immediately but reports its actual completion state in a callback), so you need to manually ensure that whatever logIn() is supposed to do has been done before you call any additional code that depends on its completion.