Data Push Apps with HTML5 SSE (2014)
Chapter 9. Authorization: Who’s That Knocking at My Door?
In the previous chapters all our data push examples have been open to everyone. In this chapter I will show how we can limit access, whether by IP, cookie, or password. The good news is that it is as straightforward as protecting any other resource on your server.
But that is not the only topic of this chapter. There has been another restriction underlying all the examples in the earlier chapters, and the time has come to deal with that one, too. The restriction is that both your HTML file (that makes the SSE request and receives the data) and your server-side script (that sends the data) have had to reside on the same server. Well, server is too imprecise: they have to be in the same origin. Later in this chapter, we will look at the definition of an origin and then how to get around this restriction.
These two topics are closely related, but notice that they are orthogonal: your data push can fail because either you lack the authorization (IP, cookie, password) or because you come from a disallowed origin, or both. For data push to be successful, the client has to satisfy both.
If you are familiar with web applications and want the distilled version of this chapter, authentication and CORS mostly work just like they do for Ajax; but watch out for browser support and bugs.
This chapter will finish by taking the FX demo application from the earlier chapters and showing how to add authentication and CORS support to it.
Cookies
Cookies can be sent to an SSE script. The browser treats an SSE connection just the same as any other HTTP request when it comes to cookies, and you don’t need to do anything. Here is a simple test frontend:
<html>
<head>
<title>Cookie logging test</title>
<script>
document.cookie="ssetest=123; path=/";
document.cookie="another-one=123; path=/";
</script>
</head>
<body>
<script>
var es = new EventSource('log_headers.php');
</script>
</body>
</html>
Of course those cookies could have been sent from another page on your website, not made in the JavaScript. This example reuses the logging script we looked at in Headers.
This example also works fine with all the fallbacks: XMLHttpRequests and iframe requests are treated just like any other HTTP request!
What about in the other direction: can the SSE server script send a cookie back? The answer is yes, as you can test with this pair of scripts. The frontend is trivial, no different from basic_sse.html, which we saw way back in Chapter 2:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>SSE: access count using cookies</title>
</head>
<body>
<pre id="x">Initializing...</pre>
<script>
var es = new EventSource("sse_sending_cookies.php");
es.addEventListener("message", function(e){
document.getElementById("x").innerHTML += "\n" + e.data;
},false);
</script>
</body>
</html>
That file is found as sse_sending_cookies.html in the book’s code, and it is connecting to sse_sending_cookies.php, which is shown next. The backend code looks like basic_sse.php, but near the top it first looks for a cookie called "accessCount". (If not found, the @ suppresses the error, and the (int) cast will turn it into a zero.) It increments and sends back the new value. The new value is also shown in the output:
<?php
header("Content-Type: text/event-stream");
$accessCount = (int)@$_COOKIE["accessCount"] + 1;
header("Set-Cookie: accessCount=".$accessCount);
while(true){
echo "data:".$accessCount.":".date("Y-m-d H:i:s")."\n\n";
@ob_flush();@flush();
sleep(1);
}
Now when you run the script you first see:
Initializing...
1:2014-02-28 14:17:33
1:2014-02-28 14:17:34
1:2014-02-28 14:17:35
...
Then if you press reload in your browser, it will instead show:
Initializing...
2:2014-02-28 14:17:40
2:2014-02-28 14:17:41
2:2014-02-28 14:17:42
...
Such fun!
Authorization (with Apache)
You can IP-restrict or password-protect any SSE script the same way you can protect any other URL. In the .htaccess file that comes with the book’s source code, I have this block:
<Files "log_headers_ip_restrict.php">
order deny,allow
deny from all
allow from 127.0.0.1
</Files>
It says that only browsers from localhost (127.0.0.1) are allowed access. Everyone else will be given a 403 error. Use log_headers.ip_restrict.html to test it: it just tries to connect, nothing else. (By the way, log_headers_ip_restrict.php is an exact copy of log_headers.php, which we created inChapter 8; the reason for duplicating it here is solely so we can apply these IP address restrictions on just that one file.)
If you are browsing from 127.0.0.1, you will get an entry in tmp.log. If you are browsing from anywhere else, there will be no entry in tmp.log (Apache would not even have started the PHP script). Browsers report the access denial in different ways. In the Firefox JavaScript console you will see something like “NetworkError: 403 Forbidden - http://example.com/log_headers_ip_restrict.php.” In Chrome, go to the Network tab of the developer tools to see a canceled request.
As an aside, here is an alternative block that allows all the private IPv4 and IPv6 networks. I often find this one more useful:
<Files "log_headers_ip_restrict.php">
order deny,allow
deny from all
allow from 127.0.0.1
allow from 172.16.0.0/12
allow from 10.0.0.0/8
allow from 192.168.0.0/16
allow from fc00::/7
</Files>
That was restricting access by something-you-are, an IP address. How about restricting access by something-you-know, i.e., with a username and password? I also have this block in the .htaccess file:
AuthUserFile /etc/apache2/sse_book_htpasswd
AuthType Basic
AuthName SSEBook
<Files "log_headers_basic_auth.php">
require valid-user
</Files>
NOTE
Actually in .htaccess you will find something like:
<Files ~ "^(log_headers_basic_auth[.]php|auth[.]basic_by_apache[.]php)$">
because it is also used by the script introduced in a later section. The tilde means it is regex, but in this case the regex is merely a list of alternatives, separated by vertical bars. The dots in filenames are matched exactly (rather than the regex meaning of a dot) by turning them into character classes (the square brackets).
I then have these contents in the /etc/apache2/sse_book_htpasswd file:
oreilly:AhsbB/t5vHsxA
That is a basic auth password of “test” for the username “oreilly.”
WARNING
Use the htpasswd program to change to a different password. The password file can be anywhere on your disk—it does not have to be under the Apache configuration directory. Just change AuthUserFile to match.
Now when you browse to log_headers.basic_auth.html, this sequence happens:
1. log_headers.basic_auth.html loads, because it is unprotected.
2. The JavaScript runs, and the EventSource object is created.
3. The browser connects to log_headers_basic_auth.php, and gets told by Apache that a username and password is needed.
4. The browser shows a dialog asking the user for that information.
5. The browser connects again, this time sending the username and password.
6. Apache verifies it and runs the PHP script.
7. The PHP script starts streaming data to the browser. (Though in this case, it logs headers then does not stream anything at all!)
Notice how the authentication is completely handled by Apache: the PHP script does not need to do anything, and knows it will not be started unless authentication has completed.
If your PHP script wants to double-check that Apache has been configured correctly and is asking for authentication, it should check that REMOTE_USER got set. log_headers_basic_auth.php has this line at the very top:
if(!@$_SERVER["REMOTE_USER"])exit;
As subtle as a big, bad bouncer. Ain’t got a pass? You can’t come in.
NOTE
In PHP you can get the username of the person connecting with either $_SERVER["REMOTE_USER"] or $_SERVER["PHP_AUTH_USER"]. $_SERVER["PHP_AUTH_PW"] is their password (in clear text). However, if PHP runs in safe mode, the PHP_AUTH_* values are not available.
HTTP POST with SSE
If you bought this book just to learn how to POST variables to an SSE backend, and you’ve turned straight to this section, I’d like you to take a deep breath, and make sure you are sitting down. You see, I have some bad news. How to break it to you gently…you remember as a child when you wanted to fly like Superman and everyone told you it couldn’t be done, you’d never manage it, and they turned out to be right? Well, it is happening again.
The SSE standard has no way to allow you to POST data to the server. This is a very annoying oversight, and might get rectified at some point. After all, the XMLHttpRequest object allows us to send POST data. (Ironically, that means we can easily send POST data for the fallback solutions introduced in Chapters 6 and 7.)
The reason I cover this in a chapter on authentication is that it is particularly annoying when it comes to doing a custom login. We do not want to send the username and password as GET data because it will be visible in the URL, will end up in server log files, and so on.
The SSE standard also does not let us specify HTTP headers, so using a custom header is out too. What to do?
Luckily there is one way to send non-URL data to an SSE process, and we looked at it at the start of this chapter: cookies! So, in your JavaScript, just before the call to new EventSource(), set a cookie something like this:
document.cookie = "login=oreilly,test;path=/";
(I know you already realize you are going to be doing something more dynamic than that, not hardcoding the username and password!) The password is in cleartext in your cookie. I strongly recommend that you only use this technique when also using SSL.
Then, over on the server side, here is how to handle the cookie data in PHP:
<?php
if (!defined("PASSWORD_DEFAULT")) { //For 5.4.x and earlier
function password_verify($password, $hash) {
return crypt($password,$hash) === $hash;
}
} //End of if (!defined("PASSWORD_DEFAULT"))
$SSE = (@$_SERVER["HTTP_ACCEPT"] == "text/event-stream");
if($SSE)header("Content-Type: text/event-stream");
else header("Content-Type: text/plain");
if(!array_key_exists("login", $_COOKIE)){
echo "data: The login cookie is missing. Exiting.\n\n";
exit;
}
list($user, $pw) = explode(",", $_COOKIE["login"]);
$fromDB = '$2a$10$4LLeBta770Y0Z7795j.8'.
'He/ZCQonnvImXIX0egalzE1MuWiEa6PQa';
if(!password_verify($pw, $fromDB)){
echo "data: The login cookie is bad. Exiting.\n\n";
exit;
}
while(true){
echo "data:".date("Y-m-d H:i:s")."\n\n";
@ob_flush();@flush();
sleep(1);
}
(That is the full code for auth.custom.php, which we will be using in the next section.)
The SSE header is done first, so that login error messages can be sent just like any other data. Then explode() turns a CSV string (our cookie) into an array, and list($user,$pw) turns that array into two variables. $fromDB is a hardcoded string here, but, as the name suggests, this would normally come from an SQL query to get the hashed password. Then the password is hashed and validated using password_verify() and if it does not match what was found in the database, access is denied.
WARNING
The hardcoded password shown in the preceding code listing was generated with password_hash(), which, along with password_verify(), was added in PHP 5.5 to encourage best practices for password security. They are easy to write in earlier versions of PHP, and the code for that is shown in Passwords. (So the preceding listing can be used out of the box in earlier versions; password_verify() has been defined inline.)
By the way, that login cookie will be sent to all pages on our site, because path was specified as /, which is likely to be undesirable. If so, in your production system, make the SSE server URL look like a path (e.g., use Apache’s mod_rewrite) and then set the cookie path to be that.
Also, we set the document cookie. That means it is tied to the domain name we loaded the HTML from. When we look at CORS later in this chapter, this will mean that if we have to connect to a different backend, we cannot send a cookie to that backend. So this “cookie-instead-of-POST” hack can only be used when the HTML file and the SSE backend are on the same server.
Multiple Authentication Choices
The following example file, auth_test.html, offers the user three ways to log in to the site. The first is by giving the values in an HTML form. (I have them filled in already to ease testing—don’t do this in production!) This puts them in a cookie and submits them to the auth.custom.php script that was shown in the previous section. The other two buttons will use HTTP basic authorization. The first has Apache do the authorization, and the second has PHP do it. We have already looked at how Apache authorization works, controlled by the .htaccess file.
The way to do basic authorization directly in PHP looks a bit like the cookie example shown in the previous section, but we get the login details from PHP_AUTH_USER and PHP_AUTH_PW. Here is the extract from auth.basic_by_php.php that handles the authentication:
$user = @$_SERVER["PHP_AUTH_USER"];
$pw = @$_SERVER["PHP_AUTH_PW"];
$fromDB = '$2a$10$4LLeBta770Y0Z7795j.8'.
'He/ZCQonnvImXIX0egalzE1MuWiEa6PQa';
if(!password_verify($pw,$fromDB)){
header('WWW-Authenticate: Basic realm="SSE Book"');
header("HTTP/1.0 401 Unauthorized");
echo "Please authenticate.\n";
exit;
}
When authentication fails, those HTTP headers are sent back to the browser. These are what cause the browser to prompt the user with a login dialog box.
Here is the full auth_test.html code. It is an interesting study because it also shows how to create a delayed EventSource connection, only on demand. In contrast, practically all our previous examples have done the connection automatically when first loaded.
<!doctype html>
<html>
<head>
<title>SSE: Basic/Custom Auth Test</title>
<meta charset="UTF-8">
<script>
var es = null;
function formSubmit(form){
document.cookie = "login="
+ form.username.value
+ "," + form.password.value
+ "; path=/";
startSSE("auth.custom.php");
}
function authByApache(){
startSSE("auth.basic_by_apache.php");
}
function authByPHP(){
startSSE("auth.basic_by_php.php");
}
function startSSE(url){
document.getElementById("x").innerHTML = "";
if(es){
document.getElementById("x").innerHTML
+= "Closing connection.\n";
es.close();
}
document.getElementById("x").innerHTML
+= "Connecting to " + url +"\n";
es = new EventSource(url);
es.addEventListener("message", function(e){
document.getElementById("x").innerHTML += "\n" + e.data;
},false);
}
</script>
</head>
<body>
<div style="float:right">
<form action="" onSubmit="formSubmit(this);return false">
Username: <input type="text" name="username" id="username" value="oreilly" />
<br/>
Password: <input type="password" name="password" value="test" />
<br/>
<input type="submit" value="Submit these credentials to auth.custom.php"/>
</form>
<br/>
<button onClick="authByApache()">Use auth.basic_by_apache.php</button>
<br/>
<button onClick="authByPHP()">Use auth.basic_by_php.php</button>
</div>
<pre id="x">Waiting...</pre>
</body>
</html>
SSL and CORS (Connecting to Other Servers)
First the good news. You can use SSE (and all the fallbacks discussed in this book) on either an HTTP server or an HTTPS server. When the HTML file is downloaded from an HTTP server, it wants to connect to get data from an HTTP server. When downloaded from an HTTPS server, it wants to get data from an HTTPS server.[33]
If you try to connect to an HTTPS server from a page downloaded from HTTP, or vice versa, you get the “The connection to … was interrupted while the page was loading.” error in Firefox. Chrome is barely any better: “Uncaught Error: SecurityError: DOM Exception 18.” Other browsers will tell you something equally obscure. In fact, they are all complaining about a CORS failure. Read on.
NOTE
If you intend to follow along in the next few sections with Chrome or Safari, make sure you have at least Chromium 26 or Safari 7, because CORS support was missing or buggy until then. Firefox support has been fine since much earlier. See Chrome and Safari and CORS.
CORS stands for Cross-Origin Resource Sharing. I wonder if they started with a catchy abbreviation then tried to find some words to fit. Anyway, CORS is the solution for the Same-Origin Policy. The Same-Origin Policy is a security feature: if you download an HTML file from a server, your browser will only let you connect back to that exact same server. (This is not specific to SSE; it affects your Ajax connections and web font requests, too.)
That is a shame, isn’t it? What if AcmeFeeds wants to sell a weather data feed, hosted at weather.example.com, and wants its clients to be able to put a little JavaScript widget on their own websites that will connect to weather.example.com? The Same-Origin Policy says this is not allowed.
Here is an alternative viewpoint. What if AcmeWeather has a weather data feed hosted at weather.example.com, and it runs a website, also at weather.example.com, that uses advertising to pay for the costs of maintaining the data feed? AcmeWeather doesn’t want some other sneaky website stealing just its data feed, for which it will get no advertising revenue.
The browser’s default state is to protect AcmeWeather: browsers do not allow someone to consume data from another website. And so CORS was invented to allow AcmeFeeds to override that default and tell the world that it is OK to take its data.
Basically, CORS is the way for a server to say it is OK to relax the Same-Origin Policy. If you have used CORS with the XMLHttpRequest object (i.e., with Ajax), you will be happy to know that the EventSource object works in basically the same way.
So, what exactly is an origin? Two resources are in the same origin if:
§ Their hostnames match (e.g., “example.com” and “somethingelse.com” are different, “www1.example.com” and “www2.example.com” are different, “10.1.2.3” and “example.com” are different even if “example.com” resolves to 10.1.2.3).
§ Their schemes match (e.g., both http:// or both https://).
§ Their ports match (e.g., “http://example.com:80” and “http://example.com:8080” are different origins, but “http://example.com” and “http://example.com:80” are the same origin).
See http://tools.ietf.org/html/rfc6454#section-4 if you need a more precise definition. See http://www.w3.org/TR/cors/ for all the gory details on CORS.
CORS is implemented by the SSE server-side script sending back extra headers to say what it allows. That is what we will look at next.
Allow-Origin
To try this out, add this line near the top of your server script:
header("Access-Control-Allow-Origin: *");
This line says: “Anyone browsing from anywhere is allowed to receive data from this server script.” It has been added to fx_server.cors.php, which is otherwise just a copy of the FX application demo server script, as of the end of Chapter 7. See the following sidebar for how to test that this header is having the desired effect.
TESTING CORS
Testing requires a bit more setup than in previous examples. You need to serve the HTML from one origin and have it connect to an SSE server in another origin, meaning a different hostname and/or a different port and/or a different protocol. However, this does not need two machines, just a bit of web server configuration. If you don’t know how to do that, a web search should bring up plenty of tutorials for your OS and web server combination.
To allow us to test CORS, I have created fx_client.cors.html, which connects to fx_server.cors.php. However, fx_client.cors.html is one of the few listings in the book’s source code that might not be usable out of the box, depending on how you have set up your servers. Instead of this line:
var url = "fx_server.cors.php?";
you will find:
var url = window.location.href.replace(
"fx_client.cors.html","fx_server.cors.php?");
It is making an absolute URL instead of a relative one. So if you were hosting this project at http://www.example.com/oreilly/sse/listings/fx_client.cors.html, it would set url to http://www.example.com/oreilly/sse/listings/fx_server.cors.php?.
Next we have:
if(url.indexOf("https") >= 0)
url = url.replace("https://","http://");
else url = url.replace("http://","https://");
These lines swap between HTTP and HTTPS. To support this I set up Apache SSL, with a self-signed certificate, but on the same IP address and pointing to the same DocumentRoot. So when I browse to http://www.example.com/oreilly/sse/listings/fx_client.cors.html, it connects to https://www.example.com/oreilly/sse/listings/fx_server.cors.php? and when I browse tohttps://www.example.com/oreilly/sse/listings/fx_client.cors.html, it connects to http://www.example.com/oreilly/sse/listings/fx_server.cors.php?.
After that we have a way to test origins that are different in the hostname part:
url = url.replace("//www1.","//www.");
When I browse to www1.example.com it will instead connect to www.example.com. When I browse to www.example.com, or anything other than www1, it does nothing, and so will continue to connect to that same domain.
I configured Apache to handle all of the above by duplicating the www.example.com virtual host, in both HTTP and HTTPS, and calling it example1.com. So, when I browse to http://www1.example.com/oreilly/sse/listings/fx_client.cors.html it connects to https://www.example.com/oreilly/sse/listings/fx_server.cors.php?.
When testing, it is often easier to just add an another IP address, rather than add another hostname. Here is a way to convert an IP address in a URL:
url = url.replace(
/([/][/]\d+[.]\d+[.]\d+)[.]51[/]/,
"$1.50/");
Vicious regex. Simply put, it changes a final IP address component of “51” to “50,” so if I browse to http://10.0.0.51/oreilly/sse/listings/fx_client.cors.html it connects to https://10.0.0.50/oreilly/sse/listings/fx_server.cors.php?.
The final addition is to report the changes it made; this is just for troubleshooting purposes:
console.log("Our URL is "
+ window.location.href
+ "; connecting to " + url);
Now, to convince yourself it is actually working, first browse to fx_server.cors.html with both http:// and https:// on a couple of different domain names. It should work. Then edit fx_server.cors.php to comment out header("Access-Control-Allow-Origin: *"); and all those variations should stop working.
Fine Access Control
The * in header("Access-Control-Allow-Origin: *"); opens it up to every Tom, Dick, and Harry. Luckily, finer control is possible. So, for example, try changing it to this: header("Access-Control-Allow-Origin: http://www.example.com"); (the http:// prefix is required). Now when you browse to http://www.example.com/oreilly/sse/listings/fx_client.cors.html, it connects to https://www.example.com/oreilly/sse/listings/fx_server.cors.php? and it works. But, as we learned at the start of this chapter, browsing from any of these will fail:
§ https://www.example.com/…/fx_client.cors.html
§ http://www1.example.com/…/fx_client.cors.html
§ http://www.example.com:88/…/fx_client.cors.html
§ http://some.other.domain.com/…/fx_client.cors.html
WARNING
Access-Control-Allow-Origin is not a substitute for proper authentication: a client can forge the Origin header. Also remember that you are reliant on the browser to implement CORS correctly.
CORS is not as flexible as you might want. You can use “*” for everything, or you can specify exactly one origin, i.e., exactly one combination of HTTP versus HTTPS, domain, and port. Two choices: one origin or every origin. For anything in between, you have to parse the Origin header in your script. Here is the most basic example, which is actually identical to using "Access-Control-Allow-Origin: *":[34]
header("Access-Control-Allow-Origin: ".@$_SERVER['HTTP_ORIGIN']);
(The @ sign means suppress errors, so if HTTP_ORIGIN is not set, it will quietly evaluate to the empty string; in this case that would mean CORS would then always cause the connection to be refused.)
Here is a more interesting example:
if(preg_match('|https?://www[1-6]\.example\.com$|',@$_SERVER["HTTP_ORIGIN"]))
header("Access-Control-Allow-Origin: ".$_SERVER["HTTP_ORIGIN"]);
else header("Access-Control-Allow-Origin: http://www.example.com");
Starting from the final line, if the regex does not match, it says you have to be browsing from http://www.example.com. I talk about regexes in Comparing Two URLs, but this one is relatively simple: it says any of wwwN.example.com will match (where N is 1, 2, 3, 4, 5, or 6), and will be told it is OK to connect. I also explicitly allow both HTTP and HTTPS URLs (the question mark after “s” means the “s” is optional). Take note that https://www.example.com will fail to match because it is not any of www1 to www6; put a ? after the [1-6] to have that be optional, too.
Don’t you feel we could do a bit better than this? Your goal was to say only clients who downloaded our application HTML from www.example.com, www1.example.com, etc., are allowed to connect. No one else is. How about we write that goal more explicitly:
if(preg_match('|https?://www[1-6]?\.example\.com$|',@$_SERVER["HTTP_ORIGIN"]))
header("Access-Control-Allow-Origin: ".$_SERVER["HTTP_ORIGIN"]);
else{
header("HTTP/1.1 403 Forbidden");
exit;
}
I fiddled with the regex so it covers www.example.com (and therefore allows both HTTP and HTTPS for that subdomain) too. But when anyone else is trying to connect, it dies immediately. I would put this code near the top of the server script.
HEAD and OPTIONS
So far we have only considered that the browser might send GET or POST requests. It would be weird of them to send something else, such as PUT, when requesting a stream of new data. In PHP, at least, all request methods are treated identically. If a client sent a HEAD request our code would behave badly: it is not supposed to send any body, and in fact we would not just be sending content but would keep the connection open forever. One option is to return from a HEAD request, just before entering the main loop (i.e., after all headers have been sent). But another approach is to decide HEAD requests are silly, and not accept them. To do this, the following code can go at the very top of the script:
switch($_SERVER["REQUEST_METHOD"]){
case "GET":case "POST":break;
case "OPTIONS":break; //TODO
default:
header("HTTP/1.0 405 Method Not Allowed");
header("Allow: GET,POST,OPTIONS");
exit;
}
}
NOTE
This HTTP method checking is not really related to the topic of this chapter, which is authentication and CORS. It is being discussed now to prepare the way for handling OPTIONS (coming up next).
If you felt the need, then for the examples in earlier chapters, where only GET makes sense, you could do it more simply by putting this at the top of your script:
if($_SERVER['REQUEST_METHOD']!='GET')↵
{header("HTTP/1.0 405 Method Not Allowed");header("Allow: GET");exit;}
The Allow header is required when sending back a 405, and it specifies what headers are allowed. What is that reference to OPTIONS though?
The idea is that a browser can call your script with an OPTIONS method to get back information on what parts of the HTTP protocol are supported. In the context of CORS, this is called a preflight request, and is typically used to ask what information is allowed to be sent to the origin in question.
NOTE
If using Apache authentication, note that the OPTIONS request will be failed with a 401 (“Authorization required”), and never actually reach your script. The browser should then prompt the user to authenticate, but some browsers (e.g., Safari 5.1) do not.
Annoyingly, sending back a wildcard for the "Access-Control-Allow-Headers" response does not seem to work, so you have to waste bandwidth trying to guess every header a browser might want to send. Here is one way to implement it:
...
case "OPTIONS":
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Headers: Last-Event-ID,".
" Origin, X-Requested-With, Content-Type, Accept,".
" Authorization");
exit;
To do the same thing in Node.js, the idea is similar. However, POST handling in Node.js is a bit complicated, so it uses two dedicated functions (not shown) to handle each of GET and POST, and the request handler is completely replaced by this switch function:
function(request,response){
switch(request.method){
case "GET":handleGET(request,response);break;
case "POST":handlePOST(request,response);break;
case "OPTIONS":
response.writeHead(200,{
"Access-Control-Allow-Origin: *",
"Access-Control-Allow-Headers: Last-Event-ID," +
" Origin, X-Requested-With, Content-Type, Accept," +
" Authorization"
});
break;
default:
response.writeHead(405,{
"Allow: GET,POST,OPTIONS"
});
break;
}
}
Chrome and Safari and CORS
Webkit-based browsers have/had some bugs that stop CORS from working correctly with native SSE. The CORS implementation of EventSource was broken/missing in Chrome 25 and earlier, and Safari 6 and earlier. As I type these words most people are now past Chrome 25, but quite a few people are still using Safari 6. If that was the bad news, the even worse news is that it is well-nigh impossible to use feature detection for this.
If CORS is an essential part of your system, the workaround is to force Chrome and Safari to use XHR instead of SSE. Sounds horrible, doesn’t it? However, it is not quite that bad, because bandwidth-wise and connection-wise, XHR is almost as good as SSE. There are really only two downsides to XHR compared to SSE:
§ Writing the extra code for both SSE and XHR support: but we have already done that.
§ Having to reconnect when memory gets too big. See Thanks for the Memories in Chapter 7.
The example at the end of this chapter uses browser version detection to tell older versions of Chrome and Safari to use XHR instead of native SSE. As it just does some regexes, it is not shown in this book, so if you are interested, take a look at function oldSafariChromeDetect() infx_client.auth.html in the book’s source code.
Chrome has another problem, though it is one that will only come up during development and testing: self-signed SSL certificates get rejected. This happens with both XHR and SSE, and the --disable-web-security command-line flag does not help. So, it is not a problem specific to Chrome’s SSE implementation. In fact, this bug is not even specific to CORS—you cannot connect with XMLHttpRequest or EventSource to a self-signed HTTPS server, period.[35] You can work around it by adding your server certificate to the Trusted Root Certificates on your local machine. Or wait for the developers to fix it. Or, because self-signed certificates are normally just used when testing and developing, develop using Firefox and other browsers and only test with Chrome when it goes on your production server.
iOS7 works with CORS and native SSE, but when connecting to an SSE data source that requests authentication, there is no dialog prompt for the password. XHR has the same problem, so we cannot work around it. If you want to support iPhone/iPad, you will have to arrange for users to access a page on your target server directly, so that they can be prompted to give the username and password. The browser will then hold on to those credentials, and they will be sent when the SSE or Ajax connection is made. (The cookie approach to authentication does not have this issue.)
Constructors and Credentials
You must know by now that the EventSource constructor takes the URL parameter to connect to. It turns out that there is a second parameter, which takes an object containing options. At the current time there is just one possible option, withCredentials, which is Boolean and defaults to false.
Try setting it to true on any of the earlier FX application code. Change this line:
es = new EventSource(u);
to this line:
es = new EventSource(u, { withCredentials: true } );
If you are connecting to the same server, it has no effect. But do the same change in our CORS version, and try to connect to a different protocol, host, or port. It breaks. In Firefox you get told “The connection was interrupted.”
We fix it by having the server-side script send these two headers instead of header("Access-Control-Allow-Origin: *");:
header("Access-Control-Allow-Origin: ".@$_SERVER["HTTP_ORIGIN"]);
header("Access-Control-Allow-Credentials: true");
The second of those headers says, “Yes, we are happy for you to send credentials.” But this header is not allowed to be used with "Access-Control-Allow-Origin: *". The browser thinks we are being a bit too promiscuous. So, do you remember that line we said earlier that was equivalent to "Access-Control-Allow-Origin: *"? That is perfect for here. It does exactly the same thing, but makes the browser go, “Ooooh, this server has obviously listened to the lecture on safe intercourse, so let’s believe it when it also says it wants to allow credentials.”
Get up and do a happy dance, because with those two lines the client can now add the withCredentials:true option, and everything works again. But just what is it we’ve allowed to happen?!
withCredentials
Say you get your HTML from http://example.com/index.html, and it tries to make an SSE connection to http://www1.example.com/sse.php. If you have made it this far in this chapter, you must know that will fail due to the Same-Origin Policy. And you know that by having the server set a"Access-Control-Allow-Origin:" that is either * or your client’s origin, the Same-Origin Policy will be overridden, and the connection will work.
You also know, if you’ve been following along, that HTTP authentication works fine with SSE.
The problem comes when we try to combine these two things. By default, when you access another origin it won’t send along the HTTP headers needed for authentication. If the SSE server script (or Apache) sends back a 401 (which would normally trigger showing the user the dialog to input his name and password), it will just be treated as an error.
NOTE
Earlier we used cookies to implement a custom login system, in lieu of POST not being supported by EventSource. withCredentials does also mean cookies can be sent, but it is no use to us here because we can only set a cookie on document, which means we are setting it on our origin, and cookies registered for one IP address or hostname cannot be sent to a different IP address or hostname.
What does that mean? It means we cannot use native SSE with a custom login system if we need to connect to a different origin. Simply Not Possible.[36]
The solution? Hope that a future version of the SSE standard will allow POST data. The hack? In the example you will find at the end of this chapter, we detect when one is trying to do custom authentication to a different origin, and force the connection to fall back to use XHR instead of SSE. Or use basic authentication. Or avoid using different origins. Or use out-of-bound authentication, so a cookie for the SSE server is received before trying to open the SSE connection.
So, to get around this the client needs to pass { withCredentials: true } as the second parameter to the EventSource constructor, as shown in the previous section, and the server needs to send back the "Access-Control-Allow-Credentials" header, set to true, as well as set the "Access-Control-Allow-Origin" header to whatever origin the client specified. Once you do that, HTTP authentication (and cookies[37]) work with CORS.
Well, they work in a modern browser. And XHR works in exactly the same way, so they work with our fallbacks. Well, kind of…see the next section.
WARNING
A reminder, once again, that none of this is real security. It all relies on the client obeying the rules. With a few lines of code in your scripting language of choice, or a curl one-liner, you can send auth headers, cookies, GET data, POST data, and even a picture of Her Majesty Queen Elizabeth II to any SSE server, whether they send you Access-Control- headers or not. While at it, you may as well also forge the User-Agent header, and the Origin header.
CORS, and withCredentials, are mainly there to prevent Cross-Site Request Forgery (CSRF) and similar attacks.
CORS and Fallbacks
Throughout this book I have tried to achieve 99% browser coverage, by showing SSE equivalents that work practically as well for older browsers. The good news is that CORS is available in XHR[38] and works exactly the same way. Therefore, because they use XMLHttpRequest, you don’t need to do anything differently for the long-poll or XHR techniques that we studied in Chapters 6 and 7. IE9 and earlier are a problem, however.
But before we look at IE8/IE9, let’s add CORS support to XHR for the browsers that support it. OK, done that. Yes, it was quick and easy because CORS is done entirely with the server headers; nothing changes in the JavaScript API.
But that is CORS without credentials support. For instance, our FX demo application sends a custom header (Last-Event-ID). So, you must use withCredentials, not just plain CORS. Let’s add withCredentials to XHR for browsers that support it. This requires changing thestartXHR() function:
function startXHR(){
...
xhr = new XMLHttpRequest();
...
xhr.open("GET", u, true);
if(lastId)xhr.setRequestHeader("Last-Event-ID", lastId);
xhr.send(null);
}
Instead of the options object that we saw with SSE’s EventSource constructor, for XHR you set the third parameter to be true.
Next, we do the same change in the startLongPoll function:
function startLongPoll(){
...
if(window.XMLHttpRequest)xhr = new XMLHttpRequest();
else{
document.getElementById("msg").innerHTML +=
"** Your browser does not support XMLHttpRequest. Sorry.**<br>";
}
...
if ("withCredentials" in xhr){
xhr.open("GET", u, true);
}else{
document.getElementById("msg").innerHTML +=
"** Your browser does not support CORS. Sorry.**<br>";
}
if(lastId)xhr.setRequestHeader("Last-Event-ID", lastId);
xhr.send(null);
}
As well as setting the third parameter of open() to be true, I also stripped out the code for doing Ajax in IE6/7, and give an error message instead. That is IE6/7 taken care of, then further down we check for withCredentials support, and if it is not available (i.e., IE8/9), we report it as an error (we’ll take care of that in the next section).
(You can find the preceding version in the book’s source code as fx_client.cors_xhr.html.)
NOTE
I could have done the same checks in startXHR(). I don’t bother because the code in connect() is already making sure IE9 and earlier don’t get there. I’ve not found a browser that ends up in startXHR() but does not support CORS and credentials; if you find one, please let me know.
CORS and IE9 and Earlier
I said long-poll and XHR techniques are fine. The iframe technique introduced in Chapter 7 is a different matter. It won’t work. For security reasons, one iframe cannot access iframe content that came from a different domain. And there is no CORS-like workaround that we can use to say it is OK. So, what that means is that IE8 and IE9 will have to use long-poll if different domains are a possibility in your application.
NOTE
If you just said, “What about IE6 or IE7?” you are asking too much: they do not have a CORS mechanism we can use, even with XHR (i.e., long-poll). So, IE7 and earlier simply cannot be made to work with different origins. You have to host the HTML and data push server on the same origin.
Do you need to use withCredentials, too? That is, do you need to send either auth headers or cookies to a server in a different origin, and have it work with IE8/IE9? Sorry, that is one requirement too far. The problem is that IE8 and IE9’s CORS equivalent, called XDomainRequest, explicitly refuses to send any custom headers (including auth headers) and explicitly refuses to send cookies. If you must have authentication and you must support IE8/IE9, then you have to serve the HTML page and the SSE server from the same origin. (Use a load balancer or reverse proxy that will have all your servers on the same domain name, and use some other way to specify any differences between them.)
NOTE
IE10 and later already use the XHR technique, and CORS works for them. And withCredentials works too! Nothing needs to change for IE10 and later.
XDomainRequest is more restrictive[39] than real CORS. The “only GET or POST” restriction does not affect us, nor does the restriction that the MIME type must be text/plain. But there is one difference you need to watch out for: different schemes are never allowed. That means an HTML page served from http://example.com cannot access a server on https://example.com, and vice versa. There is no way for our server to say it is fine.
Here is how the code in startLongPoll() has to change to use XDomainRequest so that CORS will work for IE8 and IE9:
if ("withCredentials" in xhr){
xhr.open("GET", u, true);
}else if (typeof XDomainRequest != "undefined") {
xhr = new XDomainRequest();
xhr.open("GET", u);
}else{
document.getElementById("msg").innerHTML +=
"** Your browser does not support CORS. Sorry.**<br>";
}
xhr.onreadystatechange = longPollOnReadyStateChange;
As you can see, XDomainRequest is a drop-in replacement for XMLHttpRequest. However, the way we do feature detection means we cannot see if we need it until after creating the XMLHttpRequest object. Because xhr might get created again, we cannot do anything with it until after this block. That is why the assignment to xhr.onreadystatechange has been moved to after this block.
The next two sections will show two different ways to handle using startLongPoll() with IE9 and earlier.
IE8/IE9: Always Use Long-Poll
If you know for sure that you are always dealing with different origins, it is easy: in connect(), change this block of code:
...
else if(isIE9OrEarlier){
if(window.postMessage)startIframe();
else startLongPoll();
}
...
to this:
...
else if(isIE9OrEarlier){
startLongPoll();
}
...
As a bonus you can now also rip out the iframe code. Meaning, these can go:
§ All of function startIframe()
§ A couple of clauses in function disconnect()
§ var iframe and var iframeTimer
Handling IE9 and Earlier Dynamically
What about when you do not know if you will hit the security restriction? It could be that this is library code that will be used on multiple sites. Or perhaps it is simply that the URL is sent to the browser client dynamically, and it is not known if you will be connecting to the same server or a different one.[40] In that case, change the previous code to look like this:
...
else if(is_ie_9_or_earlier){
if(window.postMessage && isSameDomain())
start_iframe();
else start_longpoll();
}
...
I have hidden all the extra logic in the isSameDomain() function.[41] What does the isSameDomain() function have to do? It has to compare url with window.location.href, and return true if all these are the same:
§ The protocol (HTTP versus HTTPS)
§ The server name (or IP address)
§ The port
There are two ways to write this. One uses regexes. The other uses a cute little JavaScript+DOM trick. You will see both ways described in the following sidebar. (In the book’s source code, fx_client.cors_xhr_ie.html implements both ways, but uses the regex approach.)
COMPARING TWO URLS
Whenever we need to compare multiple parts of two strings, regexes are the tool for the job. If you’ve been resisting learning regexes because they look utterly unreadable, just give in. You can do so much merely knowing the basic syntax.
As an aside, whatever your regex skill level, you might find using this tester tool helpful as you follow along with the explanation: http://www.regexplanet.com/advanced/javascript/index.html.
Here is the regex to extract the protocol, server name, and port from a URL:
/^(https?):[/][/]([^/:]+)(:([^/]+))?/
The / on either side mark the start and end of the regex. The ^ means this has to match at the start of the string. Parentheses surround something we want to capture, and there are three blocks of capturing going on here, shown highlighted here:
/^(https?):[/][/]([^/:]+)(:([^/]+))?/
The first string to capture is the scheme (HTTP or HTTPS), second is the domain name, and third is the optional port number. The ? after the parentheses means zero or once, so if there is no port number, the third captured string will simply be undefined. The second block, [^/:]+, says grab everything until reaching either a forward slash or a colon (the slash or colon will not be part of the captured string). The next one, [^/]+, says grab everything until reaching a forward slash. In both cases the end of the string would also terminate the capturing. (There are also another pair of parentheses, which are being used for grouping, not for capturing. Their purpose in life is to make sure the colon prefix is not part of the port number that is captured.)
Between the protocol and the domain name comes ://. Why the funny notation (:[/][/])? The forward slash symbol is already being used to mark the start and end of the regex, so forward slashes need to be escaped if used anywhere else. But, they don’t need to be escaped in character classes. Square brackets mark character classes. So [/] is the same as writing \/, and both mean match one forward slash. I personally think the character class approach is clearer (especially when putting the regex in a string where backslashes need to be escaped: then the forward slash can end up looking like \\/ or even \\\\/).
NOTE
Defining the regex between /.../ implicitly creates a RegExp object. You could also create it explicitly with var re = new RegExp('^(https?)://([^/:]+)(:([^/]+))?');. These approaches are identical. Note that in the second way, “/” is no longer used to start and end the regex, so it no longer needs to be escaped! So I can use “/” characters directly and not have to write them as [/].
I could also have not assigned the regex to the re variable, and merged the first two lines into one: var m1 = /^(https?):[/][/]([^/:]+)(:([^/]+))?/.exec( url ).
That is a bad idea for two reasons, both reasons being that I use the regex twice. The first reason is the obvious one of duplicate code being A Bad Thing™. The second reason is that by assigning a regex to a variable, it gets compiled. Because we use that compiled regex twice, we save ourselves the CPU effort of one extra regex compilation. Here that is minor. It matters more if the regex is inside a 1,000-iteration loop. But, on principle, always assign your regexes to a variable if using them more than once. Going further with that idea, if a regex is being called a lot, for instance every time the server sends us data, then I would be tempted to assign the regex to a global variable, so it is only compiled once in the whole script.
Turning all that chat into JavaScript code, here is what we get:
function isSameDomain(){
var re = /^(https?):[/][/]([^/:]+)(:([^/]+))?/;
var m1 = re.exec( url );
if(!m1)return true;
var m2 = re.exec( window.location.href );
if(m1[1] != m2[1])return false;
if(m1[2] != m2[2])return false;
if(m1[4] != m2[4]){
if(!m1[4])m1[4] = (m1[1]=='http') ? "80" : "443";
if(!m2[4])m2[4] = (m2[1]=='http') ? "80" : "443";
if(m1[4] != m2[4])return false;
}
return true;
}
exec called on a RegExp object gives an array of matches. [1] is the first match (protocol), [2] is the second match (server name), and [4] is the port number ([3] is the port number including the colon, and is not used here). The port number needs a couple of extra lines of code, because if one version contains the default port number and the other left it off, we want them to match. That is, http://example.com/ and http://example.com:80/ are the same thing. (If you ever find a browser that treats them differently, file a bug report and then add a hack to not execute those two lines for that browser!)
The regex fails if the URL is a relative URL. In other words, instead of http://example.com/fx_server.php, it is /fx_server.php. It turns out that this is a solution, not a problem: relative URLs must be the same origin, by definition! So, if the regex does not match, assume it is a relative URL, and return true immediately. That is what the if(!m1)return true; line is doing.
TIP
This assumes you never have genuinely bad values for url. But that should be under the control of your application. And, anyway, the worst that happens is that with a bad URL, IE8 will try to use iframe and fail (for a security reason) instead of using long-poll and failing (because the URL is bad).
NOTE
The regex also fails with “//example.com/…” style URLs, which are intended to use the same protocol (allowing code to be shared between HTTP and HTTPS sites). I chose not to complicate the regex even further by handling this. It is better to have two or three understandable regexes than one monster that covers all cases. To get you started, the regex you seek is /^([/][/])([^/:]+)(:([^/]+))?/. The fx_client.cors_xhr_ie.html file implements it fully.
I said there was another way to do this. Let me go straight into some code:
function isSameDomain(){
var m1 = document.createElement("a");
m1.href = url;
var m2 = document.createElement("a");
m2.href = window.location.href;
if(m1.protocol != m2.protocol)return false;
if(m1.hostname != m2.hostname)return false;
if(m1.port != m2.port)return false;
return true;
}
This relies on the fact that when JavaScript creates an <a> tag in the DOM, it will get a full Location object, which has all these lovely fields ready for you. And not a regex in sight. So cool. The downsides are that it is a bit more fragile, it is not available in IE6 or IE7, and there may be other small browser differences. You also need to test how it works in all the browsers where it will be used for the corner cases we had to deal with in the earlier code (relative URLs, “//example.com/” URLs, port number explicit in one but not in the other, etc.).
NOTE
I learned the technique at https://gist.github.com/jlong/2428561, though it was apparently discovered earlier than that. The comments on that page are also educational.
Putting It All Together
Did the last couple of sections make your head hurt, your eyes water, and mountain shepherd start to look like an attractive career choice? Internet Explorer is powerful like that. Well, the good news is that you’ve almost finished this book, and in just a few pages you will be in the appendixes. But before we part ways there is just one more example that needs to be done: let’s (just for fun) take the FX demo application, the CORS version from earlier in this chapter, and merge in the auth.html example from earlier in this chapter. Therefore, the data flow won’t start until you log in, which can be done with either basic auth or cookies. And let’s (just for some serious fun[42]) make it work with all our target browsers, too. Well, as already explained, that means we simply cannot support IE8 and IE9: their CORS implementation is incompatible, by design, with wanting to authenticate. (The page will work and can be used with IE8; it will just break if you set the target URL to be a different origin.) However, fx_client.auth.html does do the check for Chromium 25 and earlier and Safari 6 and earlier, forcing them to use XHR instead of native SSE, so that CORS will work.
What that means is that only these browsers will use native SSE in this example: Firefox 10+, Opera 12+, Chrome 26+, Safari 7+. And, when using the “custom” login technique, all browsers will fallback to using the XHR technique when the origin is different (because XHR can do POST, while SSE only has cookies, and cookies cannot be sent to a different domain).
THE BACKEND FILES
This example is even more complicated than it needs to be, because it supports the three or four different authentication methods that we looked at earlier in this chapter, but keeps all the shared code in two files. fx_server.auth.inc1.php has to come first (sets some globals, defines all the classes and functions), then fx_server.auth.inc2.php does the rest of the global code and the main loop.fx_server.auth.inc1.php and fx_server.auth.inc2.php are basically fx_server.xhr.php from the end of Chapter 7, split into two parts, with a few lines moved to the auth-specific files.
The other four files (fx_server.auth.apache.php, fx_server.auth.php.php, fx_server.auth.custom.php, and fx_server.auth.noauth.php) do their specific authentication code, in the “…” part of a file like this:
include_once("fx_server.auth.inc1.php");
...
include_once("fx_server.auth.inc2.php");
If the user could connect directly to fx_server.auth.inc1.php or fx_server.auth.inc2.php, it would be bad, so we deny access with this .htaccess entry. Yes, it is another regex:
<Files ~ "^fx_server[.]auth[.]inc[12][.]php$">
deny from all
</Files>
Let’s look at the backend first. The preceding sidebar explains why we have six files: inc1 and inc2 with most of the code (which is very similar to the code at the end of Chapter 7, so won’t be shown again here), and then the other four files are similar to the files we saw with the threeauth_test.html backends earlier in this chapter, the fourth variation being doing no authorization at all. The latter is useful to allow us to see what fails due to authorization issues, but it also represents what we would have if using the IP address as the authorization measure.
Both fx_server.auth.noauth.php and fx_server.auth.apache.php are the same code (because for fx_server.auth.apache.php Apache takes care of the authentication, and this script never gets called if the user is not valid):
<?php
include_once("fx_server.auth.inc1.php");
sendHeaders();
include_once("fx_server.auth.incs.php");
(The real code for fx_server.auth.apache.php, in addition to the this code, does a quick sanity check to make sure Apache authentication is working correctly.)
This is the version of the script fx_server.auth.php.php to handle doing the basic authentication inside our PHP script:
<?php
include_once("fx_server.auth.inc1.php");
$user = @$_SERVER["PHP_AUTH_USER"];
$pw = @$_SERVER["PHP_AUTH_PW"];
$fromDB = '$2a$10$4LLeBta770Y0Z7795j.8'.
'He/ZCQonnvImXIX0egalzE1MuWiEa6PQa';
if(!password_verify($pw, $fromDB)){
header('WWW-Authenticate: Basic realm="SSE Book"');
header("HTTP/1.0 401 Unauthorized");
echo "Please authenticate.\n";
exit;
}
sendHeaders();
include_once("fx_server.auth.inc2.php");
Notice how the sendHeaders() call comes after the validation; if a problem occurs, we want to send back auth headers instead of SSE headers.
Finally, here is the most complex version, for doing a custom authentication based on cookie data. Except unlike the earlier example, it will accept the authentication data coming in by either cookies or POST data:
<?php
include_once("fx_server.auth.inc1.php");
sendHeaders();
if(array_key_exists("login",$_COOKIE))$d = $_COOKIE["login"];
elseif(array_key_exists("login",$_POST))$d = $_POST["login"];
else{
sendData(array(
"action"=>"auth",
"msg"=>"The login data is missing. Exiting."
));
exit;
}
if(strpos($d,",")===false){
sendData(array(
"action"=>"auth",
"msg"=>"The login data is invalid. Exiting."
));
exit;
}
list($user,$pw) = explode(",",$d);
$fromDB = '$2a$10$4LLeBta770Y0Z7795j.8'.
'He/ZCQonnvImXIX0egalzE1MuWiEa6PQa';
if(!password_verify($pw,$fromDB)){
sendData(array(
"action"=>"auth",
"msg"=>"The login is bad. Exiting."
));
exit;
}
include_once("fx_server.auth.inc2.php");
sendHeaders() is called first, so that we can use sendData() to send back auth failures. They will be given to the browser as SSE messages.
NOTE
Troubleshooting an SSE backend from inside a browser can be a frustrating experience. But, unlike in earlier chapters, running PHP scripts directly from the command line is not a choice, because we need to specify headers and cookies. The best option for these quick tests is curl. Here are commands to test each of the three authentication approaches. (They assume the files are on http://example.com, in an sse/ directory, so adapt them for your own installation.)
curl -uoreilly:test http://example.com/sse/fx_server.auth.apache.php
curl -uoreilly:test http://example.com/sse/fx_server.auth.php.php
curl --cookie "login=oreilly,test" ↵
http://example.com/sse/fx_server.auth.custom.php
Add -v to see headers, or --trace - for information overload about what is passing back and forth. Add -H "Origin: http://127.0.0.1" to specify an origin.
Play around with the cookie or username:password values to see the error reporting.
Also make sure that these fail to connect:
curl http://example.com/sse/fx_server.auth.inc1.php
curl http://example.com/sse/fx_server.auth.inc2.php
Now to the frontend. When you load it, it looks like Figure 9-1.
Figure 9-1. Initial view of fx_client.auth.html
Following are the main differences from the previous versions, fx_client.xhr.html (end of Chapter 7) and fx_client.cors.html (earlier in this chapter):
§ An HTML form to allow you to select: (1) the connection technique (SSE, XHR, iframe, long-poll) to use; (2) the target URL (e.g., you can change the domain name, or IP address, or switch between HTTP and HTTPS); and (3) the auth technique to use.
§ A no-auth technique has been added.
§ Older versions of Chrome and Safari are detected, based on their user-agents.
§ When using the custom connection method with a different origin, XHR will POST the data, instead of using a cookie, and Native SSE will switch to also using POST by using the XHR fallback.
§ Auth failures are intercepted and reported.
Taken together, that makes fx_client.auth.html the longest source code file, but a lot of the new code is form handling, which we will not look at in any depth here. I am also not going to look at the Chrome/Safari detection, which is just applying a few regexes.
The first code I will show is quite simple. When our custom authentication code (fx_server.auth.custom.php) has an error to report, it sends it back using the SSE data stream. It uses the action field set to "auth" to identify this. So, in processOneLine(), the following block has been added:
function processOneLine(s){
...
else if(d.action == "auth"){
var x = document.getElementById("msg");
x.innerHTML += "Auth Failure:" + d.msg + "<br/>";
disconnect();
}
}
The call to disconnect() is very important: we don’t want it to keep trying to connect, and we don’t even want a keep-alive mechanism to keep trying to connect.
Now that we have a form, what happens if the user clicks one of the connect buttons when a connection is already running? There is a new function called reconnect() that is used in this case:
this.reconnect = function(newUrl,newOptions){
disconnect();
url = newUrl;
for(var key in newOptions)
options[key] = newOptions[key];
connect();
}
So, it first calls disconnect() to make sure not just that the current connection is closed, but that all timers get stopped. Then it sets the new URL, and any new options, and then it tries to connect to the new URL with those new settings.
The fallback from SSE to XHR has been implemented with the following highlighted changes to the startSSE() function:
function startEventSource(){
if(es){es.close();es=null;}
if(!isSameDomain()){
if(options.post || isOldSafariChrome){startXHR();return;}
}
if(options.post)document.cookie = options.post +"; path=/";
var u = url;
if(lastId)u += "lastId="
+ encodeURIComponent(lastId) + "&";
es = new EventSource(u, { withCredentials: true } );
es.addEventListener("message", function(e){processOneLine(e.data);},false);
es.addEventListener("error", handleError, false);
}
As background to this, the options object now has an optional post element, in which we put "login=username,password" when using custom authentication. The first of the highlighted clauses is saying that when connecting to a different origin and wanting to send a cookie it will not work, so use the XHR approach instead. The second part says if wanting to send a cookie and connecting to the same origin, then set a cookie.
The || isOldSafariChrome part is because old browsers that haven’t implemented CORS for SSE will not work with a different origin, whether sending a cookie or not, so they should use XHR here instead.
The second half of that is how to handle POST in startXHR():
function startXHR(){
...
var ds = null;
fallback = "xhr=1&t=" + (new Date().getTime());
if(options.post){
xhr.open("POST", url, true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
ds = fallback + "&" + options.post;
}
else{
xhr.open("GET", url + fallback, true);
}
...
xhr.send(ds);
}
NOTE
The code you will see in fx_client.xhr.html is quite different from this, because it has been refactored to move most of the code into a helper function, called useXMLHttpRequest(), that is then shared between both startXHR() and startLongPoll().
So, when options.post is not set, it is the same as the previous code: xhr and t will be sent in the URL. But when options.post is set, we have to set an extra header, then build all the data we want to send in ds, which is then passed to xhr.send(ds).
And that is it. Try a few tests. For example, if you are browsing it at http://example.com/sse/listings/fx_client.auth.html, then change “Base URL to connect to” to “https://example.com/sse/listings/”, or “http://www1.example.com/sse/listings/”, etc. Then try each of the buttons, and watch to see if the data comes through, or if you get an authentication error. And, assuming data comes through, have a look in Firebug (or whatever developer tools you are using) to see if the connection is using SSE or XHR or long-poll, and if it is using GET or POST, and to see what cookies are being sent.
The Future Holds More of the Same
This has been a long and complicated chapter. It would have been considerably simpler if (1) the SSE standard, and its implementations, allowed us to set our own headers and send POST data, as we can with Ajax; (2) old browsers did not exist.
Based on the experience of the past 15 years, old browsers and browser bugs will always be with us, and we just have to be prepared to cope with them. However, for dealing with point (1) (the limitations of the SSE standard), the fact that we had already written the fallbacks for the older browsers meant that we could relatively easily handle those limitations. In fact, the workaround ended up as simple as this:
if(!isSameDomain()){
if(options.post || isOldSafariChrome){startXHR();return;}
}
The Server-Sent Events API is still quite new, and I would not be surprised if it gets some improvements in the next year or two. But it is also very useful even in its current form, and I hope you find many good uses for it in your own projects.
[33] In Chrome at the time of writing, EventSource will not work with a self-signed SSL certificate, nor will any of the fallbacks.
[34] Did I say identical? There is a key difference when it comes to using credentials. See Constructors and Credentials.
[35] You can follow the bug report at http://code.google.com/p/chromium/issues/detail?id=96007.
[36] Well, not quite. In Firefox, at least, you can send cookies from http://example.com to https://example.com, and vice versa (i.e., origins that differ in just the scheme part). My suggestion is not to rely on this behavior, because it is inconsistent with the CORS/cookies behavior of XHR and therefore might change in the future.
[37] Those that are allowed to be sent. Other cookies still apply so, for instance, a cookie for www1.example.com cannot be sent to www2.example.com.
[38] Firefox has supported CORS with XHR since 3.5, Chrome since 4.0, Safari since 4.0, IE since either 8.0 or 10.0 depending on the level of support, and iOS Safari and Android since 3.2 and 2.1, respectively. In other words, excepting Internet Explorer, all your users can be assumed to have CORS support for XMLHttpRequest.
[39] See http://bit.ly/1csbEHT for how it works in IE8 and IE9. Note that only GET or POST are allowed, cookies are not sent, and it must be text/plain.
[40] I find this “URL is sent to the browser client dynamically” scenario a bit of a stretch. In such a case it sounds like you would mostly be connecting to another server. If so, simplify the code to always use long-poll.
[41] This function will make an appearance again, in the final example in this chapter, when deciding whether to use SSE-with-cookies or having to fallback to XHR so that POST data can be sent.
[42] If you are cute, female, and actually thought that does sound like fun, we should get together…no, hang on, there has to be a catch. Nobody could be that perfect. You probably have some really weird hobby, involving toads or Excel or something.