Stack Exchange API V2.0: JS Auth Library
Posted: 2012/01/25 Filed under: code, pontification | Tags: apiv2 1 CommentIn a previous article I discussed why we went with OAuth 2.0 for authentication in V2.0 of the Stack Exchange API (beta and contest currently underway), and very shortly after we released a simple javascript library to automate the whole affair (currently also considered “in beta”, report any issues on Stack Apps). The motivations for creating this are, I feel, non-obvious as is why it’s built the way it is.
Motivations
I’m a strong believer in simple APIs. Every time a developer using your API has to struggle with a concept or move outside their comfort zone, your design has failed in some small way. When you look at the Stack Exchange API V2.0, the standout “weird” thing is authentication. Every other function in the system is a simple GET (well, there is one POST with /filters/create), has no notion of state, returns JSON, and so on. OAuth 2.0 requires user redirects, obviously has some notion of state, has different flows, and is passing data around on query strings or in hashes. It follows that, in pursuit of overall simplicity, it’s worthwhile to focus on simplifying consumers using our authentication flows. The question then becomes “what can we do to simplify authentication?”, with an eye towards doing as much good as possible with our limited resources. The rationale for a javascript library is that:
- web applications are prevalent, popular, and all use javascript
- we lack expertise in the other (smaller) comparable platforms (Android and iOS, basically)
- web development makes it very easy to push bug fixes to all consumers (high future bang for buck)
- other APIs offer hosted javascript libraries (Facebook, Twitter, Twilio, etc.)
Considerations
The first thing that had to be decided was the scope of the library, as although the primary driver for the library was the complexity of authentication that did not necessarily mean that’s all the library should offer. Ultimately, all it did cover is authentication, for reasons of both time and avoidance of a chilling affect. Essentially, scoping the library to just authentication gave us the biggest bang for our buck while alleviating most fears that we’d discourage the development of competing javascript libraries for our API. It is, after all, in Stack Exchange’s best interest for their to be a healthy development community around our API. I also decided that it was absolutely crucial that our library be as small as possible, and quickly served up. Negatively affecting page load is unacceptable in a javascript library, basically. In fact, concerns about page load times are why the Stack Exchange sites themselves do not use Facebook or Twitter provided javascript for their share buttons (and also why there is, at time of writing, no Google Plus share option). It would be hypocritical to expect other developers to not have the same concerns we do about third-party includes.
Implementation
Since it’s been a while since there’s been any code in this discussion, I’m going to go over the current version (which reports as 453) and explain the interesting bits. The source is here, though I caution that a great many things in it are implementation details that should not be depended upon. In particular, consumers should always link to our hosted version of the library (at https://api.stackexchange.com/js/2.0/all.js).
The first three lines sort of set the stage for “small as we can make it”.
window.SE = (function (navigator, document,window,encodeURIComponent,Math, undefined) { "use strict"; var seUrl, clientId, loginUrl, proxyUrl, fetchUserUrl, requestKey, buildNumber = '@@~~BuildNumber~~@@';
I’m passing globals as parameters to the closure defining the interface in those cases where we can expect minification to save space (there’s still some work to be done here, where I will literally be counting bytes for every reference). We don’t actually pass an undefined to this function, which both saves space and assures nobody’s done anything goofy like giving undefined a value. I intend to spend some time seeing if similar proofing for all passed terms is possible (document and window are already un-assignable, at least in some browsers). Note that we also declare all of our variables in batches throughout this script, to save bytes from repeating “var” keywords.
Implementation Detail: “@@~~BuildNumber~~@@” is replaced as part of our deploy. Note that we pass it as a string everywhere, allow us to change the format of the version string in the future. Version is provided only for bug reporting purposes, consumers should not depend on its format nor use it in any control flow.
function rand() { return Math.floor(Math.random() * 1000000); }
Probably the most boring part of the entire implementation, gives us a random number. Smaller than inlining it everywhere where we need one, but not by a lot even after minifying references to Math. Since we only ever use this to avoid collisions, I’ll probably end up removing it altogether in a future version to save some bytes.
function oldIE() { if (navigator.appName === 'Microsoft Internet Explorer') { var x = /MSIE ([0-9]{1,}[\.0-9]{0,})/.exec(navigator.userAgent); if (x) { return x[1] <= 8.0; } } return false; }
Naturally, there’s some Internet Explorer edge case we have to deal with. For this version of the library, it’s that IE8 has all the appearances of supporting postMessage but does not actually have a compliant implementation. This is a fairly terse check for Internet Explorer versions <= 8.0, inspired by the Microsoft recommended version. I suspect a smaller one could be built, and it’d be nice to remove the usage of navigator if possible.
Implementation Detail: There is no guarantee that this library will always treat IE 8 or lower differently than other browsers, nor is there a guarantee that it will always use postMessage for communication when able.
Now we get into the SE.init function, the first method that every consumer will need to call. You’ll notice that we accept parameters as properties on an options object; this is a future proofing consideration, as we’ll be able to add new parameters to the method without worrying (much) about breaking consumers.
You’ll also notice that I’m doing some parameter validation here:
if (!cid) { throw "`clientId` must be passed in options to init"; } if (!proxy) { throw "`channelUrl` must be passed in options to init"; } if (!complete) { throw "a `complete` function must be passed in options to init"; } if (!requestKey) { throw "`key` must be passed in options to init"; }
This is something of a religious position, but I personally find it incredibly frustrating when a minified javascript library blows up because it expected a parameter that wasn’t passed. This is inordinately difficult to diagnose given how trivial the error is (often being nothing more than a typo), so I’m checking for it in our library and thereby hopefully saving developers some time.
Implementation Detail: The exact format of these error messages isn’t specified, in fact I suspect we’ll rework them to reduce some repetition and thereby save some bytes. It is also not guaranteed that we will always check for required parameters (though I doubt we’ll remove it, it’s still not part of the spec) so don’t go using try-catch blocks for control flow.
This odd bit:
if (options.dev) { seUrl = 'https://dev.stackexchange.com'; fetchUserUrl = 'https://dev.api.stackexchange.com/2.0/me/associated'; } else { seUrl = 'https://stackexchange.com'; fetchUserUrl = 'https://api.stackexchange.com/2.0/me/associated'; }
Is for testing on our dev tier. At some point I’ll get our build setup to strip this out from the production version, there’s a lot of wasted bytes right there.
Implementation Detail: If the above wasn’t enough, don’t even think about relying on passing dev to SE.init(); it’s going away for sure.
The last bit of note in SE.init, is the very last line:
setTimeout(function () { complete({ version: buildNumber }); }, 1);
This is a bit of future proofing as well. Currently, we don’t actually have any heaving lifting to do in SE.init() but there very well could be some in the future. Since we’ll never accept blocking behavior, we know that any significant additions to SE.init() will be asynchronous; and a complete function would be the obvious way to signal that SE.init() is done.
Implementation Detail: Currently, you can get away with calling SE.authenticate() immediately, without waiting for the complete function passed to SE.init() to execute. Don’t do this, as you may find that your code will break quite badly if our provided library starts doing more work in SE.init().
Next up is fetchUsers(), an internal method that handles fetching network_users after an authentication session should the consumer request them. We make a JSONP request to /me/associated, since we cannot rely on the browser understanding CORS headers (which are themselves a fairly late addition to the Stack Exchange API).
Going a little out of order, here’s how we attach the script tag.
while (window[callbackName] || document.getElementById(callbackName)) { callbackName = 'sec' + rand(); } window[callbackName] = callbackFunction; src += '?access_token=' + encodeURIComponent(token); src += '&pagesize=100'; src += '&key=' + encodeURIComponent(requestKey); src += '&callback=' + encodeURIComponent(callbackName); src += '&filter=!6RfQBFKB58ckl'; script = document.createElement('script'); script.type = 'text/javascript'; script.src = src; script.id = callbackName; document.getElementsByTagName('head')[0].appendChild(script);
The only interesting bit here is the while loop making sure we don’t pick a callback name that is already in use. Such a collision would be catastrophically bad, and since we can’t guarantee anything about the hosting page we don’t have a choice but to check.
Implementation Detail: JSONP is the lowest common denominator, since many browsers still in use do not support CORS. It’s entirely possible we’ll stop using JSONP in the future, if CORS supporting browsers become practically universal.
Our callbackFunction is defined earlier as:
callbackFunction = function (data) { try { delete window[callbackName]; } catch (e) { window[callbackName] = undefined; } script.parentNode.removeChild(script); if (data.error_id) { error({ errorName: data.error_name, errorMessage: data.error_message }); return; } success({ accessToken: token, expirationDate: expires, networkUsers: data.items }); };
Again, this is fairly pedestrian. One important thing that is often overlooked when making these sorts of libraries is the cleanup of script tags and callback functions that are no longer needed. Leaving those lingering around does nothing but negatively affect browser performance.
Implementation Detail: The try-catch block is a workaround for older IE behaviors. Some investigation into whether setting the callback to undefined performs acceptably for all browsers may let us shave some bytes there, and remove the block.
Finally, we get to the whole point of this library: the SE.authenticate() method.
We do the same parameter validation we do in SE.init, though there’s a special case for scope.
if (scopeOpt && Object.prototype.toString.call(scopeOpt) !== '[object Array]') { throw "`scope` must be an Array in options to authenticate"; }
Because we can’t rely on the presence of Array.isArray in all browsers, we have to fall back on this silly toString() check.
The meat of SE.authenticate() is in this block:
if (window.postMessage && !oldIE()) { if (window.attachEvent) { window.attachEvent("onmessage", handler); } else { window.addEventListener("message", handler, false); } } else { poll = function () { if (!opened) { return; } if (opened.closed) { clearInterval(pollHandle); return; } var msgFrame = opened.frames['se-api-frame']; if (msgFrame) { clearInterval(pollHandle); handler({ origin: seUrl, source: opened, data: msgFrame.location.hash }); } }; pollHandle = setInterval(poll, 50); } opened = window.open(url, "_blank", "width=660, height=480");
In a nutshell, if a browser supports (and properly implements, unlike IE8) postMessage we use that for cross-domain communication other we use the old iframe trick. The iframe approach here isn’t the most elegant (polling isn’t strictly required) but it’s simpler.
Notice that if we end up using the iframe approach, I’m wrapping the results up in an object that quacks enough like a postMessage event to make use of the same handler function. This is easier to maintain, and saves some space through code reuse.
Implementation Detail: Hoy boy, where to start. First, the usage of postMessage or iframes shouldn’t be relied upon. Nor should the format of those messages sent. The observant will notice that stackexchange.com detects that this library is in use, and only create an iframe named “se-api-frame” when it is; this behavior shouldn’t be relied upon. There’s quite a lot in this method that should be treated as a black box; note that the communication calisthenics this library is doing isn’t necessary if you’re hosting your javascript under your own domain (as is expected of other, more fully featured, libraries like those found on Stack Apps).
Here’s the handler function:
handler = function (e) { if (e.origin !== seUrl || e.source !== opened) { return; } var i, pieces, parts = e.data.substring(1).split('&'), map = {}; for (i = 0; i < parts.length; i++) { pieces = parts[i].split('='); map[pieces[0]] = pieces[1]; } if (+map.state !== state) { return; } if (window.detachEvent) { window.detachEvent("onmessage", handler); } else { window.removeEventListener("message", handler, false); } opened.close(); if (map.access_token) { mapSuccess(map.access_token, map.expires); return; } error({ errorName: map.error, errorMessage: map.error_description }); };
You’ll notice that we’re religious about checking the message for authenticity (origin, source, and state checks). This is very important as it helps prevent malicious scripts from using our script as a vector into a consumer; security is worth throwing bytes at.
Again we’re also conscientious about cleaning up, making sure to unregister our event listener, for the same performance reasons.
I’m using a mapSuccess function to handle the conversion of the response and invokation of success (and optionally calling fetchUsers()). This is probably wasting some space and will get refactored sometime in the future.
I’m passing expirationDate to success as a Date because of a mismatch between the Stack Exchange API (which talks in “seconds since the unix epoch”) and javascript (which while it has a dedicated Date type, thinks in “milliseconds since unix epoch”). They’re just similar enough to be confusing, so I figured it was best to pass the data in an unambiguous type.
Implementation Detail: The manner in which we’re currently calculating expirationDate can over-estimate how long the access token is good for. This is legal, because the expiration date of an access token technically just specifies a date by which the access token is guaranteed to be unusable (consider what happens to an access token for an application a user removes immediately after authenticating to).
Currently we’ve managed to squeeze this whole affair down into a little less than 3K worth of minified code, which gets down under 2K after compression. Considering caching (and our CDN) I’m pretty happy with the state of the library, though I have some hope that I can get us down close to 1K after compression.
[ed. As of version 568, the Stack Exchange Javascript SDK is down to 1.77K compressed, 2.43K uncompressed.]
Better IE detect: http://james.padolsey.com/javascript/detect-ie-in-js-using-conditional-comments/