Managing a Token Cache, iOS

This document refers to the iOS SDK v3. For the new iOS SDK v4 please see our new iOS docs.

The Facebook SDK for iOS automatically takes care of storing and fetching data on Facebook session management for your app. There are some cases when you may want to do it yourself:

  • If you have multiple users logging in on the same device and you need to handle multiple tokens.
  • Provide seamless login experience for the user across different devices. If a user logged in in one device, you show them logged in in another device using the same token.
  • You want to store the token in your server or own storage rather than locally as an extra layer of security.
  • You have tokens that were created through some other means.

Remote caching is typically done in apps that provide their own login mechanism in addition Facebook Login.

We recommend that you read through the Sessions on iOS.

About Token Caching

FBSessionTokenCachingStrategy class manages the cached data. The data is an NSDictionary and is stored in NSUserDefaults under a specific key. To handle the caching yourself, you will need to create a custom class that subclasses FBSessionTokenCachingStrategy and overrides the methods to:

  • Save the token data - When FBSession changes to FBSessionStateOpen or FBSessionStateOpenTokenExtended states during login or when additional permissions are granted override cacheTokenInformation: or cacheFBAccessTokenData:. Override cacheFBAccessTokenData: method unless you're caching additional data not in FBAccessTokenData. If you need to cache additional data, override cacheTokenInformation:. The cacheFBAccessTokenData: method takes in FBAccessTokenData input.

  • Retrieve the token data - Override fetchTokenInformation or fetchFBAccessTokenData when your app checks for a valid token, for example when it calls openActiveSession*:allowLoginUI:NO. Override the fetchFBAccessTokenData method unless you're caching additional data that's not in FBAccessTokenData. If caching additional data, override fetchTokenInformation. fetchFBAccessTokenData returns an FBAccessTokenData object.

  • Clear the token data - Override clearToken. This gets called when closeAndClearTokenInformation is called on an FBSession object.

The FBAccessTokenData token data contains the user's access_token and expiration date. It also contains the date when the token was last refreshed and the type of login that triggered the login, such as iOS6+ system account login.

When the Facebook SDK manages the token data cache, it stores it in NSUserDefaults under a key named ''FBAccessTokenInformationKey''. To modify the key where the data is stored, you need to create an instance of the FBSessionTokenCachingStrategy class using the initWithUserDefaultTokenInformationKeyName: method and pass it the key name that you wish to use. Then you need to pass your instance of FBSessionTokenCachingStrategy to the FBSession class' init method. The Facebook SDK will store the token data under the key of your choice.

Caching Samples

The examples below show two different caching scenarios:

  • Local device caching - Cache locally on the device but in a different location from the default Facebook SDK location. From a user point of view the experience is no different than the default.

  • Remote server caching: - Cached on a server for access by multiple devices. This allows a first time user to log in on one device, go to a second device, launch the same app and start off with a logged in experience.

The complete sample is at GitHub. The completed sample has a flag calledkLocalCache in the MyTokenCachingStrategy.m file that allows you to test local caching versus remote caching.

The initial Xcode project has Facebook Login implemented. It includes all the user interface components you'll need to set up the sample. What's missing is Facebook functionality that you'll add to manage your own cache.

The main classes and nib files used in the projects are:

  • AppDelegate.m: Includes code for Facebook Login.

  • ViewController.m: Handles session state callbacks that are triggered through notifications from the app delegate. The login button's method in this class calls corresponding methods in the app delegate implementation file to log in or log out a person.

  • ViewController.xib: Contains an Button object with an action tied to a method in the ViewController implementation class. The button is tied to an outlet to control the button text to show the logged in or logged out status.

Local Caching

Set up a custom class to handle the token caching tasks and modify the FBSession open method to use your custom class. The data is cached as an NSDictionary object so you can store it in a property list and use of NSDictionary methods to write to and read from a property list.

Create a new class file by right-clicking on the project folder > New File > Objective-C class template. Name the class ''MyTokenCachingStrategy'' and select NSObject as the subclass.

Open MyTokenCachingStrategy header file and make the following code changes:

#import 
#import @interface MyTokenCachingStrategy : NSObject
@interface MyTokenCachingStrategy : FBSessionTokenCachingStrategy

Open up the MyTokenCachingStrategy implementation file. First, create properties and helper methods that define the location for the property list that's used to cache the data locally:

...
#import "MyTokenCachingStrategy.h"

// Local cache - unique file info
static NSString* kFilename = @"TokenInfo.plist";

@interface MyTokenCachingStrategy ()
@property (nonatomic, strong) NSString *tokenFilePath;
- (NSString *) filePath;
@end

@implementation MyTokenCachingStrategy

- (id) init
{
    self = [super init];
    if (self) {
        _tokenFilePath = [self filePath];
    }
    return self;
}

- (NSString *) filePath {
    NSArray *paths =
    NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,
                                        NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths lastObject];
    return [documentsDirectory stringByAppendingPathComponent:kFilename];
}
...

Next, add helper methods that read and write to the property list file:

- (void) writeData:(NSDictionary *) data {
    NSLog(@"File = %@ and Data = %@", self.tokenFilePath, data);
    BOOL success = [data writeToFile:self.tokenFilePath atomically:YES];
    if (!success) {
        NSLog(@"Error writing to file");
    }
}

- (NSDictionary *) readData {
    NSDictionary *data = [[NSDictionary alloc] initWithContentsOfFile:self.tokenFilePath];
    NSLog(@"File = %@ and data = %@", self.tokenFilePath, data);
    return data;
}

Finally, implement the FBSessionTokenCachingStrategy class methods to handle persistence, retrieval, and clearing of the cached token info:

- (void)cacheFBAccessTokenData:(FBAccessTokenData *)accessToken {
    NSDictionary *tokenInformation = [accessToken dictionary];
    [self writeData:tokenInformation];
}

- (FBAccessTokenData *)fetchFBAccessTokenData
{
    NSDictionary *tokenInformation = [self readData];
    if (nil == tokenInformation) {
        return nil;
    } else {
        return [FBAccessTokenData createTokenFromDictionary:tokenInformation];
    }
}

- (void)clearToken
{
    [self writeData:[NSDictionary dictionaryWithObjectsAndKeys:nil]];
}  


Open a Session

Open up the app delegate implementation file.

First, import the custom class header file:

#import "MyTokenCachingStrategy.h"    

Next, add a private property for the token caching object:

@interface AppDelegate ()
@property (nonatomic, strong) MyTokenCachingStrategy *tokenCaching;
@end
...

Next, open up the app delegate implementation class and replace the openSessionWithAllowLoginUI: definition with the following:

- (BOOL)openSessionWithAllowLoginUI:(BOOL)allowLoginUI {
    BOOL openSessionResult = NO;
    // Set up token strategy, if needed
    if (nil == _tokenCaching) {
        _tokenCaching = [[MyTokenCachingStrategy alloc] init];
    }
    // Initialize a session object with the tokenCacheStrategy
    FBSession *session = [[FBSession alloc] initWithAppID:nil
                                              permissions:@[@"public_profile"]
                                          urlSchemeSuffix:nil
                                       tokenCacheStrategy:_tokenCaching];
    // If showing the login UI, or if a cached token is available,
    // then open the session.
    if (allowLoginUI || session.state == FBSessionStateCreatedTokenLoaded) {
        // For debugging purposes log if cached token was found
        if (session.state == FBSessionStateCreatedTokenLoaded) {
            NSLog(@"Cached token found.");
        }
        // Set the active session
        [FBSession setActiveSession:session];
        // Open the session.
        [session openWithBehavior:FBSessionLoginBehaviorUseSystemAccountIfPresent
                completionHandler:^(FBSession *session,
                                    FBSessionState state,
                                    NSError *error) {
                    [self sessionStateChanged:session
                                        state:state
                                        error:error];
                }];
        // Return the result - will be set to open immediately from the session
        // open call if a cached token was previously found.
        openSessionResult = session.isOpen;
    }
    return openSessionResult;
}

Build and run the project to make sure it runs without errors. Before the login button is clicked, you should see a debug message that the token data found is null. Tap the ''Login'' button to log in with Facebook. Once authenticated, the button text should change to ''Logout''. After the login flow is completed, you should see messages showing the token data that is saved.

Stop the running app from Xcode. On your test device, double-tap the Home button and stop the app from running there as well. Launch the app to test the cached data fetching flow. The button should say ''Logout'' as cached data is read from your cached location.

Restart the app from Xcode. Tap the ''Logout'' button and verify that the button text changes to ''Login''. You should see a debug message that the token data is empty. Stop the running app from Xcode once more and make sure the app is not running on your test device. Launch the app. The button should say ''Login'' as no token data has been found in the cache.

Test with an iOS6+ device where you've logged in to the Facebook account on the system. Verify that the login flow uses the iOS native Login Dialog.

Remote Caching

In this step, you'll store the token data on a server instead of locally.

You'll add server-side code to process the incoming token data and client-side code to send and receive this data.

Server-Side Code

Set up a simple endpoint that handles HTTP POST requests to store token data and HTTP GET requests to fetch token data. The endpoint returns a JSON response with a ''success'' parameter that is set to ''true'' when the data is stored or retrieved successfully and is set to ''false'' in other cases.

If you don't have your own back-end server, consider using Parse. The server-side sample code is written in PHP but you can take the same concepts and apply it to the implementation stack you support.

Create a file and name it ''token.php'' and host it on your server. Add the following content to the file:

<?php
// Copyright 2004-present Facebook. All Rights Reserved.

// Enforce https on production
if ($_SERVER['HTTP_X_FORWARDED_PROTO'] == "http" && $_SERVER['REMOTE_ADDR'] != '127.0.0.1') {
  header("Location: https://" . $_SERVER["HTTP_HOST"] . $_SERVER["REQUEST_URI"]);
  exit();
}

// If POST, save token data and return a success flag.
// If GET, check the unique info to send back a saved token data.

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
  if (isset($_REQUEST['token_info']) && isset($_REQUEST['unique_id'])) {
    // Get the unique id passed in
    $unique_id = strip_tags($_REQUEST['unique_id']);
    // Use the unique id to create the data storage file
    $token_filename = $unique_id . '.txt';
    $file = dirname(__FILE__) . '/data/' . $token_filename;
    // Get the token data info
    $auth_response['token_info'] = strip_tags($_REQUEST['token_info']);
    // JSON encode the data
    $data = json_encode($auth_response);
    // Create a new file or overwrite file
    if (file_put_contents($file, $data) === false) {
      $response['status'] =  'false';
      $response['errorCode'] =  '50001';
      $response['errorMessage'] =  'Could not write file contents.';
    } else {
      $response['status'] =  'true';
    }
  } else {
      $response['status'] =  'false';
      $response['errorCode'] =  '30001';
      $response['errorMessage'] =  'Invalid data input.';
  }
} else if ($_SERVER['REQUEST_METHOD'] == 'GET') {
  if (isset($_REQUEST['unique_id'])) {
    // Get the unique id passed in
    $unique_id = strip_tags($_REQUEST['unique_id']);
    // Use the unique id to find the file to check
    $token_filename = $unique_id . '.txt';
    $file = dirname(__FILE__) . '/data/' . $token_filename;
    // Get the file contents and decode the JSON info
    $data = json_decode(file_get_contents($file));
    if (($data ===  false) || empty($data)) {
      $response['status'] =  'false';
      $response['errorCode'] =  '50002';
      $response['errorMessage'] =  'Could not read file contents or data empty.';
    } else {
      // Return the token data 
      $response['status'] =  'true';
      $response['token_info'] = $data->token_info;
    }
  } else {
    $response['status'] =  'false';
    $response['errorCode'] =  '30001';
    $response['errorMessage'] =  'Invalid data input.';
  }
} else {
  $response['status'] =  'false';
  $response['errorCode'] =  '50003';
  $response['errorMessage'] =  'Unsupported method: ' . $_SERVER['REQUEST_METHOD'];
}

echo json_encode($response);

Create a directory called ''data'' in the same directory where the PHP file is hosted. If you store the data in a different directory, ex: outside the document web root, be sure to modify the ''token.php'' file accordingly.

Note: For security reasons, in a production set up you would not store the token data in the filesystem, especially if it's easily accessible from the web. A real world scenario would involve storing the info in a database. Additionally, the unique_id could be represented by a third party session that's generated by your server. So one possible user flow that's more secure could be the following:

  • User opens up your app and is authenticated against your server via a different login mechanism. Your app gets a session (third-party session) from the server.
  • Your app uses the third-party session info to make a call to your server to check for a cached Facebook token.
  • When the Facebook token data needs to be stored, your server is called using the the third-party session info. This session info is used to retrieve user info and the user info is in turn used to store the Facebook token data.
  • The next time the user opens your app and the cached token needs to be checked, the server code maps the third-party session to a user, checks it's database to retrieve the token data.

Modify the Class

Open up the MyTokenCachingStrategy header file and add a public property that is used to identify the user for remote caching support:

// In a real app this uniquely identifies the user and is something
// the app knows before an FBSession open is attempted.
@property (nonatomic, strong) NSString *thirdPartySessionId;

Open up the MyTokenCachingStrategy implementation file. Make the following modifications to remove the property and helper methods related to local file caching and add those used for remote data caching:

...
#import "MyTokenCachingStrategy.h"


// Local cache - unique file info
static NSString* kFilename = @"TokenInfo.plist";

@interface MyTokenCachingStrategy ()
@property (nonatomic, strong) NSString *tokenFilePath;
- (NSString *) filePath;
@end


@implementation MyTokenCachingStrategy

- (id) init
{
    self = [super init];
    if (self) {
        _tokenFilePath = [self filePath];
        _thirdPartySessionId = @"";
    }
    return self;
}


- (NSString *) filePath {
    NSArray *paths =
    NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,
                                        NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths lastObject];
    return [documentsDirectory stringByAppendingPathComponent:kFilename];
}

...

You'll see errors in the writeData: and readData methods due to the deleted tokenFilePath property. You'll be swapping out these methods shortly so ignore the errors for now.

Next, add code to set up the remote caching:

// Remote cache - back-end server
static NSString* kBackendURL = @"<YOUR_BACKEND_SERVER>/token.php";

// Remote cache - date format
static NSString* kDateFormat = @"yyyy-MM-dd'T'HH:mm:ss.SSSZZZ";

@implementation MyTokenCachingStrategy
...

Replace <YOUR_BACKEND_SERVER> with the path to the endpoint where you've stored token.php or your token caching endpoint.

Next, add helper code used to process the server's response. This is be used by the writeData: and readData methods:

/*
 * Helper method to look for strings that represent dates and
 * convert them to NSDate objects.
 */
- (NSMutableDictionary *) dictionaryDateParse: (NSDictionary *) data {
    // Date format for date checks
    NSDateFormatter* dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:kDateFormat];
    // Dictionary to return
    NSMutableDictionary *resultDictionary = [[NSMutableDictionary alloc] init];
    // Enumerate through the input dictionary
    [data enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
        // Check if strings are dates
        if ([obj isKindOfClass:[NSString class]]) {
            NSDate *objDate = nil;
            BOOL isDate = [dateFormatter getObjectValue:&objDate
                                              forString:obj
                                       errorDescription:nil];
            if (isDate) {
                resultDictionary[key] = objDate;
                [resultDictionary setObject:objDate forKey:key];
            } else {
                resultDictionary[key] = obj;
            }
        } else {
            // Non-string, just keep as-is
            resultDictionary[key] = obj;
        }
    }];
    return resultDictionary;
}

/*
 * Helper method to check the back-end server response
 * for both reads and writes.
 */
- (NSDictionary *) handleResponse:(NSData *)responseData {
    NSError *jsonError = nil;
    id result = [NSJSONSerialization JSONObjectWithData:responseData
                                                options:0
                                                  error:&jsonError];
    if (jsonError) {
        return nil;
    }
    // Check for a properly formatted response
    if ([result isKindOfClass:[NSDictionary class]] &&
        result[@"status"]) {
        // Check if we got a success case back
        BOOL success = [result[@"status"] boolValue];
        if (!success) {
            // Handle the error case
            NSLog(@"Error: %@", result[@"errorMessage"]);
            return nil;
        } else {
            // Check for returned token data (in the case of read requests)
            if (result[@"token_info"]) {
                // Create an NSDictionary of the token data
                NSData *jsonData = [result[@"token_info"]
                                    dataUsingEncoding:NSUTF8StringEncoding];
                if (jsonData) {
                    jsonError = nil;
                    NSDictionary *tokenResult =
                    [NSJSONSerialization JSONObjectWithData:jsonData
                                                    options:0
                                                      error:&jsonError];
                    if (jsonError) {
                        return nil;
                    }

                    // Check if valid data returned, i.e. not nil
                    if ([tokenResult isKindOfClass:[NSDictionary class]]) {
                        // Parse the results to handle conversion for
                        // date values.
                        return [self dictionaryDateParse:tokenResult];
                    } else {
                        return nil;
                    }
                } else {
                    return nil;
                }
            } else {
                return nil;
            }
        }
    } else {
        NSLog(@"Error, did not get any data back");
        return nil;
    }
}

The handleResponse: helper method is called after a response is received from reads or writes. In the case of reads, the code calls the dictionaryDateParse: method to convert any NSString objects that represent dates into NSDate objects. This is to make sure that the returned token data is in the expected format.

Next, add the code that writes token data to the server. Replace the previously existing writeData: method:

- (void) writeData:(NSDictionary *) data {
    NSLog(@"Write - Data = %@", data);
    NSDateFormatter* dateFormatter = [[NSDateFormatter alloc] init];
    [dateFormatter setDateFormat:kDateFormat];
    NSError *error = nil;
    NSString *jsonDataString = @"";
    if (nil != data) {
        NSMutableDictionary *copyData = [data mutableCopy];
        // Enumerate through the input dictionary
        [data enumerateKeysAndObjectsUsingBlock:^(id key, id object, BOOL *stop) {
            if([object isKindOfClass:[NSDate class]]) {
                copyData[key] = [dateFormatter stringFromDate:object];
            } else {
                copyData[key] = object;
            }
        }];
        NSData *jsonData = [NSJSONSerialization
                            dataWithJSONObject:copyData
                            options:0
                            error:&error];
        if (error) {
            NSLog(@"JSON error: %@", error);
            return;
        }
        jsonDataString = [[NSString alloc] initWithData:jsonData
                                               encoding:NSUTF8StringEncoding];
    }

    NSURLResponse *response = nil;
    error = nil;
    // Set up a URL request to the back-end server
    NSMutableURLRequest *urlRequest = [[NSMutableURLRequest alloc] initWithURL:
                                       [NSURL URLWithString:kBackendURL]];
    // Configure an HTTP POST
    [urlRequest setHTTPMethod:@"POST"];
    // Pass in post data: the unique ID and the JSON string
    // representation of the token data.
    NSString *postData = [NSString stringWithFormat:@"unique_id=%@&token_info=%@",
                          self.thirdPartySessionId,jsonDataString];
    [urlRequest setHTTPBody:[postData dataUsingEncoding:NSUTF8StringEncoding]];
    // Make a synchronous request
    NSData *responseData = (NSMutableData *)[NSURLConnection
                                             sendSynchronousRequest:urlRequest
                                             returningResponse:&response
                                             error:&error];
    // Process the returned data
    [self handleResponse:responseData];
}

Finally, add code to read the cached token data from the server and make use of the helper methods you've just defined. This code replaces the previous readData method:

- (NSDictionary *) readData {
    NSURLResponse *response = nil;
    NSError *error = nil;
    // Set up a URL request to the back-end server, a
    // GET request with the unique ID passed in.
    NSString *urlString = [NSString stringWithFormat:@"%@?unique_id=%@",
                           kBackendURL, self.thirdPartySessionId];
    NSURLRequest *urlRequest = [[NSURLRequest alloc] initWithURL:
                                [NSURL URLWithString:urlString]];
    // Make a synchronous request
    NSData *responseData = (NSMutableData *)[NSURLConnection
                                             sendSynchronousRequest:urlRequest
                                             returningResponse:&response
                                             error:&error];
    if (nil != responseData) {
        // Process the returned data
        return [self handleResponse:responseData];
    } else {
        return nil;
    }
}

You'll notice that the token data reads and writes are synchronous HTTP calls that block the user interface. This is a simple mechanism for this sample app to ensure that the FBSession is in the correct state whenever the app is launched. You should consider making your real-world app requests asynchronous, especially if Facebook Login is not an immediate requirement for your app's functionality.


Open a Session

Make code changes to set the property that uniquely identifies a user. You'll set this property when you initialize the MyTokenCachingStrategy instance. Open up the app delegate implementation file and add modify the relevant code in the openSessionWithAllowLoginUI: method:

...
if (nil == _tokenCaching) {
    _tokenCaching = [[MyTokenCachingStrategy alloc] init];

    // Hard-code for demo purposes, should be set to
    // a unique value that identifies the user of the app.
    [_tokenCaching setThirdPartySessionId:@"213465780"];
}
...

Limit the type of login you allow, to guard against inconsistencies if one of the user's devices does not have iOS6. You'll basically disable the ability to log in using the iOS system's Facebook account credentials. Make the following change in the openSessionWithAllowLoginUI: method found in the app delegate implementation file:

[session openWithBehavior:FBSessionLoginBehaviorUseSystemAccountIfPresent
[session openWithBehavior:FBSessionLoginBehaviorWithFallbackToWebView

Delete the app from your test device.

Build and run the project to make sure it runs without errors. Before the login button is clicked, you should see a debug message that no data was found. Tap the ''Login'' button to log in with Facebook. Once authenticated, the button text should change to ''Logout''. After the login flow is completed, you should see messages showing the token data that is saved.

Stop the running app from Xcode. On your test device, double-tap the Home button and stop the app from running there as well. Launch the app to test the cached data fetching flow. The button should say ''Logout'' as cached data is read from your cached location.

Restart the app from Xcode. Tap the ''Logout'' button and verify that the button text changes to ''Login''. You should see a debug message that the token data is empty. Stop the running app from Xcode once more and make sure the app is not running on your test device. Launch the app. The button should say ''Login'' as no token data has been found in the cache.

To test the central caching feature, log in to cache the token data on the server. Delete the app from the test device to simulate the user going to a new device. Restart the app from Xcode. The app should start in an authenticated state as the cached token data is read from the server. You should see a debug message that a cached token was found.

Test with an iOS6+ device where you've logged in to the Facebook account on the system. Verify that the login flow does not use the iOS native Login Dialog.

Disabling Login Dialog

If you cache session data remotely then you should disable the iOS6 native Login Dialog flow. The native login flow requires users to log in from each device. A central token caching mechanism violates this principle and may result in an inconsistent user experience during authentication.

Additional Info