window.external.Notify in iOS UIWebView

I recently ran into an interesting problem working on putting support for the Windows Azure Access Control Service (ACS) into the Windows Azure iOS Toolkit.

It turns out that once you’ve gone through all of the ACS authentication process, a security token is generated. You need this token to authenticate yourself against ACS-bound web sites. However the ACS web site passes the token to you by calling window.external.Notify()

window.external.Notify("security-token-here");

While window.external.Notify is supported in Internet Explorer on the desktop and also in Windows Phone, it is not supported in iOS. Worse, while the WebView control on the Mac can call your Objective C code from JavaScript, the iOS UIWebView does not support this.

Another mechanism was needed. Here is how I solved it.

Extending the JavaScript object model

The first challenge was how to add in a new method under the built-in window object. What if iOS prevented changes to the window object? Well thankfully it doesn’t. And because objects in JavaScript are all just dictionaries, it is fairly easy to extend objects at runtime.

The second challenge was how to get information from the JavaScript back to my iOS code, particularly given that we don’t have access to the Objective C integration we have on the Mac. Actually there is really only one way, and that is by telling the browser to access some URL in a way that we can catch in our UIWebViewDelegate code.

Here is the JavaScript I wrote to extend the window object:

<script type=\"text/javascript\">
    window.external =
    {
        'Notify': function(s) { document.location = 'acs://settoken?token=' + s; },
        'notify': function(s) { document.location = 'acs://settoken?token=' + s; }
    };
</script>

The code above registers the “external” property on the window object, and adds two functions. Both uppercase and lowercase versions were required.

I ran into a third problem while trying to hook this JavaScript into the page generated by the ACS web site.

UIWebViewDelegate is fairly simple in what it provides:

@protocol UIWebViewDelegate <NSObject>
 
@optional
- (BOOL)webView:(UIWebView *)webView
     shouldStartLoadWithRequest:(NSURLRequest *)request
                 navigationType:(UIWebViewNavigationType)navigationType;
- (void)webViewDidStartLoad:(UIWebView *)webView;
- (void)webViewDidFinishLoad:(UIWebView *)webView;
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error;
 
@end

I seemed like webViewDidFinishLoad: would give me an easy way to integrate this. The problem was that the ACS web site would attempt to call window.external.Notify() immediately after the page loaded, right before my delegate was called. Worse, webViewDidStartLoad: seemed to be too early! I needed to find another way.

Hooking my script into the UIWebView

The iOS UIWebView is very simple to use, but that simplicity means there are precious few hooks for doing this type of integration. In fact the only direct way to interact with the page is through the call below:

- (NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;

It seemed in theory I could use this call to hook in some JavaScript, but as I mentioned, I couldn’t get this call in early enough to register it. I needed a more brute-force approach!

My only alternative was webView:shouldStartLoadWithRequest:navigationType:. However, this delegate call is simply telling me that the browser is about to load the content. I would need to load the content myself, prepend my JavaScript (so that it was guaranteed to run before the ACS JavaScript) and give it to the UIWebView.

This was my final version of the delegate call.

- (BOOL)webView:(UIWebView *)webView
     shouldStartLoadWithRequest:(NSURLRequest *)request
                 navigationType:(UIWebViewNavigationType)navigationType
{
    if(_url)
    {
        /* make the call re-entrant when we re-load the content ourselves */
        if([_url isEqual:[request URL]])
        {
            return YES;
        }
 
        [_url release];
    }
 
    _url = [[request URL] retain];
    NSString* scheme = [_url scheme];
 
    if([scheme isEqualToString:@"acs"])
    {
        // parse the JSON URL parameter into a dictionary
        NSDictionary* pairs = [self parsePairs:[_url absoluteString]];
        if(pairs)
        {
            WACloudAccessToken* accessToken;
            accessToken = [[WACloudAccessToken alloc] initWithDictionary:pairs];
            [WACloudAccessControlClient setToken:accessToken];
 
            [self dismissModalViewControllerAnimated:YES];
        }
 
        return NO;
    }
 
    [NSURLConnection connectionWithRequest:request delegate:self];
 
    return NO;
}

You can see this code is really doing 3 things:

  • We remember the URL that was requested. We are going to have to load the HTML ourselves, then give it to UIWebView. When we do that it will call our delegate a second time. We need to recognize that, and treat it like a no-op, or go around in an endless loop.
  • We need to detect if the URL was our “acs://” call made by our JavaScript hook, and if so, take the JSON URL parameter and use it to parse out the security token.
  • Finally if it is a regular HTML load, we need to begin the process of loading it ourselves and tell UIWebView to not load it by returning NO.

I’m using fairly standard code for my NSURLConnectionDelegate handlers. The only thing out of the ordinary is that once the HTML has finished loading, I prepend the JavaScript we saw earlier and tell the UIWebView to load it.

- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error
{
    if(_data)
    {
        [_data release];
        _data = nil;
    }
}
 
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
{
    if(!_data)
    {
        _data = [data mutableCopy];
    }
    else
    {
        [_data appendData:data];
    }
}
 
- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
    if(_data)
    {
        NSString* content = [[NSString alloc] initWithData:_data
                                                  encoding:NSUTF8StringEncoding];
 
        [_data release];
        _data = nil;
 
        // prepend the HTML with our custom JavaScript
        content = [ScriptNotify stringByAppendingString:content];
 
        [_webView loadHTMLString:content baseURL:_url];
    }
}

Final thoughts

While the mechanism shown here may seem a little convoluted, it is solving a fairly specific problem if how to hook into a web page’s JavaScript early enough to influence what happens.

I’m curious to know if there is a cleaner way to do this. Let me know.

Finally, here is the code I wrote for converting the ACS security token JSON into a dictionary without using an external JSON parser library such as SBJSON or YAJL. While not as general purpose a third party library, this was good enough for our needs.

- (NSDictionary*)parsePairs:(NSString*)urlStr
{
    NSRange r = [urlStr rangeOfString:@"="];
    if(r.length == 0)
    {
        return nil;
    }
 
    // the JSON-encoded token is after the = in the URL
    NSString* token = [[urlStr substringFromIndex:r.location + 1] URLDecode];
 
    // remove the leading and trailing { } characters
    NSCharacterSet* objectMarkers;
    objectMarkers = [NSCharacterSet characterSetWithCharactersInString:@"{}"];
    token = [token stringByTrimmingCharactersInSet:objectMarkers];
 
    NSError* regexError;
    NSMutableDictionary* pairs = [NSMutableDictionary dictionaryWithCapacity:10];
 
    // parse name-value pairs with string values
    NSRegularExpression* regex;
    regex = [NSRegularExpression regularExpressionWithPattern:@"\"([^\"]*)\":\"([^\"]*)\""
                                                      options:0
                                                        error:&regexError];
    NSArray* matches = [regex matchesInString:token
                                      options:0
                                        range:NSMakeRange(0, token.length)];
 
    for(NSTextCheckingResult* result in matches)
    {
        for(int n = 1; n < [result numberOfRanges]; n += 2)
        {
            NSRange r = [result rangeAtIndex:n];
            if(r.length > 0)
            {
                NSString* name = [token substringWithRange:r];
 
                r = [result rangeAtIndex:n + 1];
                if(r.length > 0)
                {
                    NSString* value = [token substringWithRange:r];
 
                    [pairs setObject:value forKey:name];
                }
            }
        }
    }
 
    // parse name-value pairs with numeric values
    regex = [NSRegularExpression regularExpressionWithPattern:@"\"([^\"]*)\":([0-9]*)"
                                                      options:0
                                                        error:&regexError];
    matches = [regex matchesInString:token
                              options:0
                                range:NSMakeRange(0, token.length)];
 
    for(NSTextCheckingResult* result in matches)
    {
        for(int n = 1; n < [result numberOfRanges]; n += 2)
        {
            NSRange r = [result rangeAtIndex:n];
            if(r.length > 0)
            {
                NSString* name = [token substringWithRange:r];
 
                r = [result rangeAtIndex:n + 1];
                if(r.length > 0)
                {
                    NSString* value = [token substringWithRange:r];
                    NSNumber* number = [NSNumber numberWithInt:[value intValue]];
 
                    [pairs setObject:number forKey:name];
                }
            }
        }
    }
 
    return pairs;
}

Leave a Reply

*