Cross site HTTP Auth via JavaScript

The best way to authenticate against another website is of course OAuth. But sometimes such an mechanism is not provided. E.g. Magnatune.com currently only supports HTTP Auth. Now that is something completely different, you might say (no communication between the two web servers possible). Well, once authenticated via HTTP Auth with Magnatune.com any site can embed a HTML5 audio element to play the member streams instead of just the free versions. So to enable a member feature in my Magnatune Player Greattune Player I came up with a hack to authenticate against Magnatune via JavaScript. Actually it's several hacks for different browsers and browser versions.

HTTP Auth behaves very differently in different browsers. All non-WebKit browsers show an Auth dialog box when you embed any content from a site that requires the user to be authenticated. WebKit/Chrome does not do that because users might think this box belongs to the website they are on and not to the third party website. (HTTP Auth sessions can be used for user tracking.) Instead it just fails to embed the resource.

So for Firefox, Opera and Internet Explorer I wrote this code:

_loginBrowser: function () {
// HTTP Auth hack for Firefox, Opera and IE.
this._loginScript("http://stream.magnatune.com");
},
_loginScript: function (origin, options) {
var finished = false;
var onerror = function (event) {
if (finished) return;
if (event.originalEvent.target === script) {
finished = true;
Magnatune.authenticated = false;
Magnatune.Player.setMember(false);
$(window).off('error',onerror);
$(this).off('readystatechange', onreadystatechange).remove();
if (options && options.error) {
options.error.call(this,event);
}
}
};
var onload = function (event) {
if (finished) return;
finished = true;
Magnatune.authenticated = true;
try { Magnatune.Player.reload(); } catch (e) { console.error(e); }
$(window).off('error',onerror);
$(this).off('readystatechange', onreadystatechange).remove();
if (options && options.success) {
options.success.call(this,event);
}
};
var onreadystatechange = function (event) {
if (finished) return;
if (this.readyState === "loaded" || this.readyState === "complete") {
// delay so onerror can fire in IE (which old IE does not do)
setTimeout(onload.bind(this,event), 0);
}
};
$(window).on('error',onerror);
var script = tag('script',{
type:'text/javascript',
src: origin+"/info/changed.txt?"+(new Date().getTime()),
onload: onload,
onerror: onerror,
onabort: onerror
});
if ($.browser.msie) {
$(script).on('readystatechange',onreadystatechange);
}
document.body.appendChild(script);
},

I needed to embed some resource from streams.magnatune.com to trigger the HTTP Auth dialog. http://magnatune.com/info/changed.txt just contains a decimal number so it is a valid JavaScript and I could embed it with a script tag.

If this script tag's onload fires this means the login was ok. onerror will be fired if the login was not ok because scripts may not be transferred with the HTTP status 401. And even if they could be transferred that way the returned document contains HTML which would raise a JavaScript SyntaxError and will fire the onerror event on the window object.

But only IE>=9 supports the onload and onerror events on script elements, so I just use onreadystatechange for older IEs. Note that in this case I cannot detect if login failed (it will always give the readyState "complete"). But I just don't care for those old IEs.

With this I managed to force the browser's HTTP Auth dialog box and could even detect if login was successful (except for old IEs). But in Chrome/WebKit this did not work. Instead I wrote my own credentials dialog in HTML and used the http://username:password@domain/ syntax to force the browser to login.

_loginUrl: function () {
// HTTP Auth hack for Chrome (< 19)/WebKit
var username = $('#username').val();
var password = $('#password').val();
if (!username || !password) {
alert("Please enter your username and password.");
return;
}
var spinner = $('#login-spinner');
var spin = function () {
spinner.show().rotate({
angle: 0,
animateTo: 360,
easing: function (x,t,b,c,d) {
return c*(t/d)+b;
},
callback: function () {
if (spinner.is(':visible')) {
spin();
}
}
});
};
spin();
this._loginScript("http://"+encodeURIComponent(username)+":"+
encodeURIComponent(password)+"@stream.magnatune.com", {
success: function () {
Magnatune.Player.hideCredentials();
},
error: function () {
spinner.hide();
Magnatune.Player._showCredentials();
}
});
},

But this URL syntax can be used even better for user tracking, because it does not even trigger a dialog box, so this feature was removed in Chrome 19. This meant I had to come up with yet another trick. So no embedded resource shows the HTTP Auth dialog, but when I open a new window on streams.magnatune.com Chrome did display the HTTP Auth overlay. But I don't get any load/error events from such a child window, so what to do? Incidentally Magnatune has a page that accepts an url query parameter and redirects to that url when login was successful. So I wrote this code:

_loginPopup: function () {
// HTTP Auth hack for Chrome >= 19
var width = 348;
var height = 170;
var top = $.window.screenY() + Math.round(($.window.outerHeight() - height) * 0.5);
var left = $.window.screenX() + Math.round(($.window.outerWidth() - width) * 0.5);
// Note that I append a time stamp to the URL to be sure that the browser does not do any caching.
var url = "http://stream.magnatune.com/redir?url="+encodeURIComponent(absurl("login.html"))+"&"+(new Date().getTime());
var target = "MagnatuneLogin";
var options = "top="+top+",left="+left+",width="+width+",height="+height+
",resizeable=false,location=false,menubar=false,status=false"+
",dependant=true,scrollbars=false";
var child = window.open(url, target, options);
var ended = false;
var loginTimer = null;
try {
child.document.title = "Magnatune Login";
}
catch (e) {
console.error(e);
}
function endLogin () {
ended = true;
try { $(child).off("load", loadLogin); } catch (e) {}
if (loginTimer !== null) {
clearInterval(loginTimer);
loginTimer = null;
}
if (!child.closed) {
try { child.close(); } catch (e) {}
}
}
function loadLogin (event) {
if (ended) { return; }
var href, access;
try {
href = child.location ? child.location.href : null;
access = true;
} catch (e) {
access = false;
}
if (!access) {
// on other domain -&gt; user clicked cancel
// Chrome seem not to throw an exception on an illegal access
// but instead return null/undefined. But it does not hurt to
// handle this case anyway.
endLogin();
Magnatune.authenticated = false;
Magnatune.Player.setMember(false);
}
else if (child.closed || href !== "about:blank") {
endLogin();
if (child.MagnatuneLoginSuccess) {
Magnatune.authenticated = true;
try { Magnatune.Player.reload(); } catch (e) { console.error(e); }
}
else {
Magnatune.authenticated = false;
Magnatune.Player.setMember(false);
}
}
}
loginTimer = setInterval(loadLogin, 1000);
$(child).on('load', loadLogin);
$(child).on('unload', setTimeout.bind(window, loadLogin, 20));
},
view raw new_chrome.js hosted with ❤ by GitHub

login.html just sets MagnatuneLoginSuccess to true and closes the child window again:

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<title>Magnatune Login</title>
<script type="text/javascript">
// <![CDATA[
window.open('','_self','');
window.MagnatuneLoginSuccess = true;
window.close();
// ]]>
</script>
</head>
<body>
<center>Login Successful!</center>
</body>
</html>
view raw login.html hosted with ❤ by GitHub

I don't say this is pretty, but it works good enough. See the full source here.

Comments

Popular posts from this blog

Save/download data generated in JavaScript

Reverse engineering a simple game archive

How to write a binary file format