We’ve long had a small static site that we only wanted to be accessible to users signed into our app. Individually signed urls weren’t an option - we’d need to sign all of the links in these html files (and update them when they expired). Since this was a low traffic site in the end we just put a small sinatra app in front of the static content that used a CAS inspired single signon mechanism.
However, a few weeks ago AWS announced exactly what we needed: CloudFront signed cookies allow you to set some cookies that CloudFront will use to guard access to your content.
Once you’ve setup your CloudFront distribution (as covered in the documentation), the basic scheme is that you set 3 cookies:
- The
CloudFront-Key-Pair-Id
cookie tells CloudFront which keypair you are signing the request with (CloudFront has its own set of RSA keys, unrelated to the api keys used by the other services or the keypairs EC2 uses). - The
CloudFront-Policy
cookie contains a JSON document that tells CloudFront what you are granting access to. This comprises a resource, an expiry time and optionally an IP range. - The
CloudFront-Signature
cookie allows CloudFront to verify that these cookies were crafted by you and have not been tampered with.
The code to generate these values is quite straightforward. The cookie_data
method below accepts a resource to protect and an expiry date and returns a hash of cookie names and the values that need to be set.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
|
This process is pretty simple, especially compared to some of the other AWS signing mechanisms. The one thing that tripped me up was that I was unsure whether you are supposed to sign the base64 policy document or non encoded policy document (it’s the latter). You also need to remove newlines from the result, in addition to translating +=/
. CloudFront isn’t very communicative if you get any of this wrong - you just get a 403 response with “invalid access”. Make sure that your distribution’s behaviour has ‘Restrict Viewer Access’ on and that your S3 origin has the “Restrict Access” option set.
You can have multiple active keypairs so it is straightforward to rotate them: create a new keypair, propagate those to your instances and then delete the old key pair when you are sure it is no longer in user?
If you are using Rails then you can just iterate over this hash to set the cookies
1 2 3 |
|
Domain Issues: The Easy Way
This whole scheme relies on the ability of your app to set cookies that the browser will send to CloudFront. However if your app is running on example.com
then it won’t be able to set cookies for http://d123456789abcde.cloudfront.net
(you won’t get an error message - the browser just won’t store the cookies). In general you can set cookies for any domain that is a superdomain of yours and you can also set cookies for all of your subdomains.
To be specific, if a request is being served by www.example.com
then you can set cookies for www.example.com
or example.com
but not .com
, anotherdomain.com
, bar.example.com
When a browser sends a request to www.example.com
it would include cookies from www.example.com
, something.www.example.com
, so the net result is that the simplest thing you can do is (assuming you are serving your requests from www.example.com
)
- Set the cookie domain to
example.com
- Configure the CloudFront distribution to have a CNAME of
something.example.com
The drawbacks to this approach are that
- CloudFront cookies will be included in more requests than are needed, not just the ones to
something.example.com
. - You won’t be able to serve content over https, unless SNI is acceptable or if you pay the extra cloudfront SSL charge (since the cloudfront certificate won’t be valid for your CNAME).
- You are limited in your ability to protect multiple distributions simultaneously: since they are all subdomains of
example.com
, the browser would send the same cookies to all of them.
The More Complete Way
There is a slightly more complicated approach that addresses this issue. It basically boils down to CAS style single signon, slightly simplified by the fact that the two services are in fact the exact same application.
You’ll need some controller actions like this
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Your distribution needs two CloudFront behaviours:
- One backed by your S3 bucket, as for the CNAME based approach
- A new origin that is backed by your Rails app. You’ll need to enable query string and cookie forwarding (you can use a dummy whitelist) so cookies returned by your app to CloudFront are propagated to the viewer.
The flow is as follows
- The user goes to
https://example.com/authorization/get_ticket?service=http://d123456789abcde.cloudfront.net/
- You are now on your app’s domain so you validate the user’s identity and whether they should access the url specified (show a login window, use an existing session etc.)
- You generate a ticket and redirect the user to
https://d123456789abcde.cloudfront.net/authorization/set_cookies?ticket=<TICKETVALUE>
- Cloudfront forwards this request to your app.
- You validate the ticket and set the cookies. Because the browser request was sent to
https://d123456789abcde.cloudfront.net
you can now set cookies for that domain. - The user is redirected to https://d123456789abcde.cloudfront.net
- The user has the required signed cookies and can now view the content.
The ticket needs to be something that your app can use to validate the user without access to any cookies. The easiest thing is for the ticket to be a randomly generate opaque identifier for an object in the database, redis, memcached etc. When you create the ticket you store the user’s identity using that identifier. To verify the ticket you just query the datastore.
Making it seamless
We didn’t want users going to docs.example.com
without these cookies set to see an obscure error message from CloudFront: we wanted to redirect users into this authorization flow. If they were already logged into our app the process should be seamless.
You can do this with a custom error document - this is just a file in S3, for example /errors/403.html
(make sure the file is public). Then add a CloudFront behaviour to say that requests to /errors/*
should go to the bucket hosting the 403 page (it can be the same bucket as your source content). Make sure that this behaviour allows anonymous access.
Lastly add a custom error page to your CloudFront distribution and say that 403 errors should be responded to with /errors/403.html. Our 403 file just does a javascript redirect to the start of the authorization flow. If you make the response be returned to the browser as a 403, be wary! the browser may cache your error file and the user will enter a redirect loop. If you do want to serve the error response as a 403, make sure that your 403 page serves cache-control headers that will make it non cacheable.
Putting it all toogether
I’ve bashed together a small app that demos this. It’s really not much beyond a freshly generated rails app + devise but it illustrates the approach. It also includes a CloudFormation stack that configures the AWS resources you need for this setup.