47
votes

I am using a WKWebView in my native iPhone application, on a website that allows login/registration, and stores the session information in cookies. I am trying to figure out how to persistently store the cookie information, so when the app restarts, the user still has their web session available.

I have 2 WKWebViews in the app, and they share a WKProcessPool. I start with a shared process pool:

WKProcessPool *processPool = [[WKProcessPool alloc] init];

Then for each WKWebView:

WKWebViewConfiguration *theConfiguration = [[WKWebViewConfiguration alloc] init]; 
theConfiguration.processPool = processPool; 
self.webView = [[WKWebView alloc] initWithFrame:frame configuration:theConfiguration];

When I log in using the first WKWebView, and then some time later pass the action to the 2nd WKWebView, the session is retained, so the cookies were successfully shared. However, when I relaunch the app, a new process pool is created and the session information is destroyed. Is there any way to get the session information to persist through an app restart?

8
You don't need to do additional actions to save WKWebView data - it should be saved automatically (the only thing that need be clarified is that you are using defaultDataStore in configuration, because it is only one that is saved to disk). Are you sure that the session info lost in WKWebView? Is it possible that the cookies has small ttl or something else? Did you check this?Roman Ermolov

8 Answers

25
votes

This is actually a tough one because there's a) some bug that's still not solved by Apple (I think) and b) depends on what cookies you want, I think.

I wasn't able to test this now, but I can give you some pointers:

  1. Getting cookies from NSHTTPCookieStorage.sharedHTTPCookieStorage(). This one seems buggy, apparently the cookies aren't immediately saved for NSHTTPCookieStorage to find them. People suggest to trigger a save by resetting the process pool, but I don't know whether that reliably works. You might want to try that out for yourself, though.
  2. The process pool is not really what saves the cookies (though it defines whether they are shared as you correctly stated). The documentation says that's WKWebsiteDataStore, so I'd look that up. At least getting the cookies from there using fetchDataRecordsOfTypes:completionHandler: might be possible (not sure how to set them, though, and I assume you can't just save the store in user defaults for the same reason as for the process pool).
  3. Should you manage to get the cookies you need (or rather their values), but not be able to restore them as I guess will be the case, look here (basically it shows how to simply prepare the httprequest with them already, relevant part: [request addValue:@"TeskCookieKey1=TeskCookieValue1;TeskCookieKey2=TeskCookieValue2;" forHTTPHeaderField:@"Cookie"]).
  4. If all else fails, check this. I know providing just link only answers is not good, but I can't copy all that and just want to add it for completeness sake.

One last thing in general: I said that your success might also depend on the type of cookie. That's because this answer states that cookies set by the server are not accessible via NSHTTPCookieStorage. I don't know whether that's relevant to you (but I guess it is, since you're probably looking for a session, i.e. server-set cookie, correct?) and I don't know whether this means that the other methods fail as well.

If all else fails, you might consider saving the users credentials somewhere (keychain, for example) and reuse them on the next app start to auth automatically. This might not restore all session data, but considering the user quit the app that's maybe actually desirable? Also perhaps certain values can be caught and saved for later use using an injected script, like mentioned here (obviously not for setting them at start, but maybe retrieve them at some point. You need to know how the site works then, of course).

I hope that could at least point you towards some new directions solving the issue. It's not as trivial as it should be, it seems (then again, session cookies are kind of a security relevant thing, so maybe hiding them away from the App is a conscious design choice by Apple...).

10
votes

After days of research and experiments, I have found a solution to manage sessions in WKWebView, This is a work around because I didn’t find any other way to achieve this, below are the steps:

First you need to create methods to set and get data in user defaults, when I say data it means NSData, here are the methods.

+(void)saveDataInNSDefault:(id)object key:(NSString *)key{
    NSData *encodedObject = [NSKeyedArchiver archivedDataWithRootObject:object];
    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    [defaults setObject:encodedObject forKey:key];
    [defaults synchronize];
}

+ (id)getDataFromNSDefaultWithKey:(NSString *)key{
    NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
    NSData *encodedObject = [defaults objectForKey:key];
    id object = [NSKeyedUnarchiver unarchiveObjectWithData:encodedObject];
    return object;
}

For maintaining session on webview I made my webview and WKProcessPool singleton.

- (WKWebView *)sharedWebView {
    static WKWebView *singleton;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        WKWebViewConfiguration *webViewConfig = [[WKWebViewConfiguration alloc] init];
        WKUserContentController *controller = [[WKUserContentController alloc] init];

        [controller addScriptMessageHandler:self name:@"callNativeAction"];
        [controller addScriptMessageHandler:self name:@"callNativeActionWithArgs"];
        webViewConfig.userContentController = controller;
        webViewConfig.processPool = [self sharedWebViewPool];

        singleton = [[WKWebView alloc] initWithFrame:self.vwContentView.frame configuration:webViewConfig];

    });
    return singleton;
}

- (WKProcessPool *)sharedWebViewPool {
    static WKProcessPool *pool;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{

        pool = [Helper getDataFromNSDefaultWithKey:@"pool"];

        if (!pool) {
            pool = [[WKProcessPool alloc] init];
        }

    });
    return pool;
}

In ViewDidLoad, I check if it’s not the login page and load cookies into HttpCookieStore from User Defaults so It will by pass authentication or use those cookies to maintain session.

if (!isLoginPage) {
            [request setValue:accessToken forHTTPHeaderField:@"Authorization"];

            NSMutableSet *setOfCookies = [Helper getDataFromNSDefaultWithKey:@"cookies"];
            for (NSHTTPCookie *cookie in setOfCookies) {
                if (@available(iOS 11.0, *)) {

                    [webView.configuration.websiteDataStore.httpCookieStore setCookie:cookie completionHandler:^{}];
                } else {
                    // Fallback on earlier versions
                    [[NSHTTPCookieStorage sharedHTTPCookieStorage] setCookie:cookie];
                }
            }
        }

And, Load the request.

Now, we will maintain webview sessions using cookies, so on your login page webview, save cookies from httpCookieStore into user defaults in viewDidDisappear method.

- (void)viewDidDisappear:(BOOL)animated {

    if (isLoginPage) { //checking if it’s login page.
        NSMutableSet *setOfCookies = [Helper getDataFromNSDefaultWithKey:@"cookies"]?[Helper getDataFromNSDefaultWithKey:@"cookies"]:[NSMutableArray array];
        //Delete cookies if >50
        if (setOfCookies.count>50) {
            [setOfCookies removeAllObjects];
        }
        if (@available(iOS 11.0, *)) {
            [webView.configuration.websiteDataStore.httpCookieStore getAllCookies:^(NSArray<NSHTTPCookie *> * _Nonnull arrCookies) {

                for (NSHTTPCookie *cookie in arrCookies) {
                    NSLog(@"Cookie: \n%@ \n\n", cookie);
                    [setOfCookies addObject:cookie];
                }
                [Helper saveDataInNSDefault:setOfCookies key:@"cookies"];
            }];
        } else {
            // Fallback on earlier versions
            NSArray *cookieStore = NSHTTPCookieStorage.sharedHTTPCookieStorage.cookies;
            for (NSHTTPCookie *cookie in cookieStore) {
                NSLog(@"Cookie: \n%@ \n\n", cookie);
                [setOfCookies addObject:cookie];
            }
            [Helper saveDataInNSDefault:setOfCookies key:@"cookies"];
        }
    }

    [Helper saveDataInNSDefault:[self sharedWebViewPool] key:@"pool"];
}

Note: Above method is tested for iOS 11 only, although I have written fallback for lower versions also but didn’t test those.

Hope this solves your problems !!! :)

10
votes

Finally, I have found a solution to manage sessions in WKWebView, work under swift 4, but the solution can be carried to swift 3 or object-C:

class ViewController: UIViewController {

let url = URL(string: "https://insofttransfer.com")!


@IBOutlet weak var webview: WKWebView!

override func viewDidLoad() {

    super.viewDidLoad()
    webview.load(URLRequest(url: self.url))
    webview.uiDelegate = self
    webview.navigationDelegate = self
}}

Create an extension for WKWebview...

extension WKWebView {

enum PrefKey {
    static let cookie = "cookies"
}

func writeDiskCookies(for domain: String, completion: @escaping () -> ()) {
    fetchInMemoryCookies(for: domain) { data in
        print("write data", data)
        UserDefaults.standard.setValue(data, forKey: PrefKey.cookie + domain)
        completion();
    }
}


 func loadDiskCookies(for domain: String, completion: @escaping () -> ()) {
    if let diskCookie = UserDefaults.standard.dictionary(forKey: (PrefKey.cookie + domain)){
        fetchInMemoryCookies(for: domain) { freshCookie in

            let mergedCookie = diskCookie.merging(freshCookie) { (_, new) in new }

            for (cookieName, cookieConfig) in mergedCookie {
                let cookie = cookieConfig as! Dictionary<String, Any>

                var expire : Any? = nil

                if let expireTime = cookie["Expires"] as? Double{
                    expire = Date(timeIntervalSinceNow: expireTime)
                }

                let newCookie = HTTPCookie(properties: [
                    .domain: cookie["Domain"] as Any,
                    .path: cookie["Path"] as Any,
                    .name: cookie["Name"] as Any,
                    .value: cookie["Value"] as Any,
                    .secure: cookie["Secure"] as Any,
                    .expires: expire as Any
                ])

                self.configuration.websiteDataStore.httpCookieStore.setCookie(newCookie!)
            }

            completion()
        }

    }
    else{
        completion()
    }
}

func fetchInMemoryCookies(for domain: String, completion: @escaping ([String: Any]) -> ()) {
    var cookieDict = [String: AnyObject]()
    WKWebsiteDataStore.default().httpCookieStore.getAllCookies { (cookies) in
        for cookie in cookies {
            if cookie.domain.contains(domain) {
                cookieDict[cookie.name] = cookie.properties as AnyObject?
            }
        }
        completion(cookieDict)
    }
}}

Then Create an extension for our View Controller Like this

extension ViewController: WKUIDelegate, WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
   //load cookie of current domain
    webView.loadDiskCookies(for: url.host!){
        decisionHandler(.allow)
    }
}

public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
   //write cookie for current domain
    webView.writeDiskCookies(for: url.host!){
        decisionHandler(.allow)
    }
}
}

Where url is current URL:

    let url = URL(string: "https://insofttransfer.com")!
10
votes

I'm a little late in answering this but I would like to add some insights to the existing answers. The answer mentioned here provides valuable information into Cookie Persistence on WKWebView. However, there are a few caveats to it.

  1. WKWebView doesn't work well with NSHTTPCookieStorage, so for iOS 8, 9, 10 you will have to use UIWebView.
  2. You don't need to necessarily hold on to the WKWebView as a singleton but you do need to use the same instance of WKProcessPool every time to get the desired cookies again.
  3. It is preferable to set the cookies first using the setCookie method and then instantiate the WKWebView.

I would also like to highlight the iOS 11+ Solution in Swift.

let urlString = "http://127.0.0.1:8080"
var webView: WKWebView!
let group = DispatchGroup()
    
override func viewDidLoad() {
    super.viewDidLoad()
    self.setupWebView { [weak self] in
        self?.loadURL()
    }
}

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    if #available(iOS 11.0, *) {
        self.webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
            self.setData(cookies, key: "cookies")
        }
    } else {
        // Fallback on earlier versions
    }
}

private func loadURL() {
    let urlRequest = URLRequest(url: URL(string: urlString)!)
    self.webView.load(urlRequest)
}
    
private func setupWebView(_ completion: @escaping () -> Void) {
    
    func setup(config: WKWebViewConfiguration) {
        self.webView = WKWebView(frame: CGRect.zero, configuration: config)
        self.webView.navigationDelegate = self
        self.webView.uiDelegate = self
        self.webView.translatesAutoresizingMaskIntoConstraints = false
        self.view.addSubview(self.webView)
        
        NSLayoutConstraint.activate([
            self.webView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            self.webView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            self.webView.topAnchor.constraint(equalTo: self.view.topAnchor),
            self.webView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)])
    }
    
    self.configurationForWebView { config in
        setup(config: config)
        completion()
    }
    
}

private func configurationForWebView(_ completion: @escaping (WKWebViewConfiguration) -> Void) {
            
    let configuration = WKWebViewConfiguration()
    
    //Need to reuse the same process pool to achieve cookie persistence
    let processPool: WKProcessPool

    if let pool: WKProcessPool = self.getData(key: "pool")  {
        processPool = pool
    }
    else {
        processPool = WKProcessPool()
        self.setData(processPool, key: "pool")
    }

    configuration.processPool = processPool
    
    if let cookies: [HTTPCookie] = self.getData(key: "cookies") {
        
        for cookie in cookies {
            
            if #available(iOS 11.0, *) {
                group.enter()
                configuration.websiteDataStore.httpCookieStore.setCookie(cookie) {
                    print("Set cookie = \(cookie) with name = \(cookie.name)")
                    self.group.leave()
                }
            } else {
                // Fallback on earlier versions
            }
        }
        
    }
    
    group.notify(queue: DispatchQueue.main) {
        completion(configuration)
    }
}

Helper methods:

func setData(_ value: Any, key: String) {
    let ud = UserDefaults.standard
    let archivedPool = NSKeyedArchiver.archivedData(withRootObject: value)
    ud.set(archivedPool, forKey: key)
}

func getData<T>(key: String) -> T? {
    let ud = UserDefaults.standard
    if let val = ud.value(forKey: key) as? Data,
        let obj = NSKeyedUnarchiver.unarchiveObject(with: val) as? T {
        return obj
    }
    
    return nil
}

Edit: I had mentioned that it's preferable to instantiate WKWebView post setCookie calls. I ran into some issues wherein the setCookie completion handlers were not getting called the second time I tried to open the WKWebView. This seems to a be a bug in the WebKit. Therefore, I had to instantiate WKWebView first and then call setCookie on the configuration. Make sure to load the URL only after all the setCookie calls have returned.

8
votes

After extensive search and manual debug I reached these simple conclusions (iOS11+).

You need to considerate these two categories:

  • You are using WKWebsiteDataStore.nonPersistentDataStore:

    Then the WKProcessPool does not matter.

    1. Extract cookies using websiteDataStore.httpCookieStore.getAllCookies()
    2. Save these cookies into UserDefaults (or preferably the Keychain).
    3. ...
    4. Later when you re-create these cookies from storage, call websiteDataStore.httpCookieStore.setCookie() for each cookie and you're good to go.
  • You are using WKWebsiteDataStore.defaultDataStore:

    Then the WKProcessPool associated with configuration DOES matter. It has to be saved along with the cookies.

    1. Save the webview configuration's processPool into UserDefaults (or preferably the Keychain).
    2. Extract cookies using websiteDataStore.httpCookieStore.getAllCookies()
    3. Save these cookies into UserDefaults (or preferably the Keychain).
    4. ...
    5. Later re-create the process pool from storage and assign it to the web view's configuration
    6. Re-create the cookies from storage and call websiteDataStore.httpCookieStore.setCookie() for each cookie

Note: there are many detailed implementations already available so I keep it simple by not adding more implementation details.

3
votes

I am a bit late to the party but people might find this useful. There is a workaround, it's a bit annoying but as far as I can say it is the only solution that works reliably, at least until apple fix their dumb APIs...

I've spend a good 3 days trying to get the cached cookies out of the WKWebView needless to say that got me nowhere... eventually I've released that I could just get the cookies directly from the server.

The first thing I tried to do is get all the cookies with javascript that was running within the WKWebView and then pass them to the WKUserContentController where I would just store them to UserDefaults. This didn't work since my cookies where httponly and apparently you can't get those with javascript...

I've ended up fixing it by inserting a javascript call into the page on the server side (Ruby on Rail in my case) with the cookies as the parameter, e.g.

sendToDevice("key:value")

The above js function is simply passing cookies to the device. Hope this will help someone stay sane...

0
votes

WKWebView conforms to NSCoding ,so you can use NSCoder to decode/encode your webView ,and store it somewhere else ,like NSUserDefaults.

//return data to store somewhere
NSData* data = [NSKeyedArchiver archivedDataWithRootObject:self.webView];/

self.webView = [NSKeyedUnarchiver unarchiveObjectWithData:data];
-2
votes

Store the information in NSUserDefaults. At the same time if the session information is very critical, it is better to store it in KeyChain.