Some time ago I wrote on how you can handle your own HTTP cookies instead of relying on Cocoa’s automatic support. The primary motivation for this is to portray multiple users to the same website. That is being logged in to the same website as different users with each user account having its own cookie jar & therefore it’s own session at the other end.
Apparently my original approach of monitoring HTTP responses for cookies wasn’t enough. Cookies added via JavaScript aren’t visible in the response headers. However “normal” web browsers acknowledges these JavaScript cookies and send them along with their comrades born in the headers. In light of this, you’ll also need to inspect the web scripting environment and also watch for cookies added there.
I found this out the hard way when Yammer did some changes sometime last week and broke Scuttlebutt‘s “show in website” functionality. Scuttlebutt is a Yammer client that allows the user to be logged simultaneously to the site. This is done by separating each WebView instance’s cookie storage – each account gets is own cookie space and therefore Yammer will store distinct session IDs for individual users in their respective cookie jars.
The Code
So I’ve extended my cookie storage to handle cookies obtained from the JavaScript environment. I’ve added a method that can parse JavaScript’s document.cookie
string and add it to the cookie storage.
Unfortunately document.cookie
only has the name/value pair of those cookies and none of the other attributes. Their paths, expiry dates, domains, and other attributes can’t be accessed from JavaScript. Hence I had to guess the other attributes and apparently these assumptions work quite well with Yammer at the moment:
- Use the document’s current URL as the path for searching existing cookies and for the domain of new cookies.
- If a cookie with the same name is already present, just update its value, nudge the expiry date, and keep all other attributes the same.
- Create new cookies as domain-global with five years’ expiry date, and don’t set the “secure” flag even if the current URL is HTTPS.
Without further ado, here is the method that parses JavaScript’s cookie string and creates or updates cookie instances as necessary.
-(void) handleWebScriptCookies:(NSString*) jsCookiesString forURLString:(NSString*) urlString { if (![jsCookiesString isKindOfClass:[NSString class]]) { DebugLog(@"Not a valid cookie string: %@",jsCookiesString); return; } if (![urlString isKindOfClass:[NSString class]]) { DebugLog(@"Not a URL string: %@",urlString); return; } if ([@"undefined" isEqualToString:jsCookiesString] || [@"null" isEqualToString:jsCookiesString]) { DebugLog(@"Invalid cookie string"); return; } NSURL* jsUrl = [NSURL URLWithString:urlString]; if (!jsUrl) { DebugLog(@"Malformed URL String: %@",urlString); return; } NSString* const urlDomain = [jsUrl host]; if (!urlDomain) { DebugLog(@"No domain in URL %@ - ignoring.",urlDomain); return; } NSArray* cookiesArray = [jsCookiesString componentsSeparatedByString:@";"]; NSMutableDictionary* cookiesDict = [NSMutableDictionary dictionaryWithCapacity:cookiesArray.count]; NSCharacterSet* whitespace = [NSCharacterSet whitespaceCharacterSet]; for (NSString* cookiePair in cookiesArray) { NSArray* pair = [cookiePair componentsSeparatedByString:@"="]; if (pair.count == 2) { NSString* key = [pair[0] stringByTrimmingCharactersInSet:whitespace]; NSString* value = [pair[1] stringByTrimmingCharactersInSet:whitespace]; if (key.length > 0 && value.length > 0) { // don't decode the cookie values cookiesDict[key] = value; } } } // five years expiry for JavaScript cookies. // why five years? Because it looks like Yammer's JavaScript-based cookies usually expires in five years. NSDate* const cookieExpiryDate = [NSDate dateWithTimeIntervalSinceNow:5 * 365.25f * 24 * 3600]; // we got all JavaScript's cookie name/value pairs in 'cokiesDict' Now find existing cookies and override their values. NSArray* existingCookies = [self cookiesForURL:jsUrl]; for (NSHTTPCookie* existingCookie in existingCookies) { NSString* existingName = existingCookie.name; NSString* updatedValue = cookiesDict[existingName]; if (updatedValue) { if (![updatedValue isEqualToString:existingCookie.value]) { // override NSMutableDictionary* cookieProperties = [NSMutableDictionary dictionaryWithDictionary:existingCookie.properties]; cookieProperties[NSHTTPCookieValue] = updatedValue; cookieProperties[NSHTTPCookieExpires] = cookieExpiryDate; NSHTTPCookie* updatedCookie = [NSHTTPCookie cookieWithProperties:cookieProperties]; [self setCookie:updatedCookie]; } // we already found an existing one, don't add it as domain global cookie [cookiesDict removeObjectForKey:existingName]; } } // now set the rest as domain-global cookies if (cookiesDict.count > 0) { NSString* cookieDomain = urlDomain; NSArray* domainComponents = [urlDomain componentsSeparatedByString:@"."]; if (domainComponents.count > 2) { NSMutableString* makeDomain = [NSMutableString stringWithCapacity:cookieDomain.length]; [domainComponents enumerateObjectsUsingBlock:^(NSString* component, NSUInteger idx, BOOL *stop) { if (idx == 0) { return; // skip the first one } [makeDomain appendFormat:@".%@",component]; }]; cookieDomain = makeDomain; } NSString* const cookiePath = @"/"; [cookiesDict enumerateKeysAndObjectsUsingBlock:^(NSString* cookieName, NSString* cookieValue, BOOL *stop) { NSMutableDictionary* cookieParams = [NSMutableDictionary dictionaryWithCapacity:6]; cookieParams[NSHTTPCookieDomain] = cookieDomain; cookieParams[NSHTTPCookiePath] = cookiePath; cookieParams[NSHTTPCookieExpires] = cookieExpiryDate; cookieParams[NSHTTPCookieName] = cookieName; cookieParams[NSHTTPCookieValue] = cookieValue; NSHTTPCookie* cookie = [NSHTTPCookie cookieWithProperties:cookieParams]; [self setCookie:cookie]; }]; } }
The method takes in a string and a URL because the cookie storage is an application-logic class and shouldn’t be dependent on user interface components like WebView
. Besides I also want the class to be portable on both OS X and iOS, just in case I want to do a mobile version of the app.
You use the method by feeding it with the document.cookies
and document.URL
strings obtained from the JavaScript environment. This is done whenever the user clicks on any link on the web page so that the cookie storage can snatch the web scripting environment’s list of cookies before the web server wants it.
Here’s how you plug in the call to the method above. You’ll need it in WebView
‘s decidePolicyForNavigationAction
delegate method so that you’ll get a chance to take a snapshot of JavaScript’s cookies before the web view starts spewing HTTP requests.
- (void)webView: (WebView *) webView decidePolicyForNavigationAction: (NSDictionary *) actionInformation request: (NSURLRequest *) request frame: (WebFrame *) frame decisionListener: (id < WebPolicyDecisionListener >) listener { // … do whatever else you need to do here … WebScriptObject* webScript = [webView windowScriptObject]; NSString* jsCookies = [webScript evaluateWebScript:@"document.cookie"]; NSString* jsURL = [webScript evaluateWebScript:@"document.URL"]; // Get the cookie storage – you should keep this instance alive with every other request/WebView // that you have with this user. It's also a good idea to persist the storage. BSHTTPCookieStorage cookieStorage = …; [cookieStorage handleWebScriptCookies:jsCookies forURLString:jsURL]; // … then let navigation proceed. [listener use]; }
That’s just about it. Enjoy! You can find a complete listing of the updated cookie storage in this gist. It took me a few days to come to this conclusion and made a fix for it – I hope you’ll find this useful and save you time and grief.
Take care.
Sadly, this solution doesn’t handle correctly cookies set by js document.cookie.
I’m using at this example app but I wasn’t able to make it work correctly for sites like gmail: https://github.com/jjconti/swift-webview-isolated
Thanks. I’ve been looking for this post.