Space Vatican

Ramblings of a curious coder

Using Cloudfront Signed Cookies

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
def cookie_data(resource, expiry)
  raw_policy = policy(resource, expiry)
  {
    'CloudFront-Policy' => safe_base64(raw_policy),
    'CloudFront-Signature' => sign(raw_policy),
    'CloudFront-Key-Pair-Id' => ENV['CLOUDFRONT_KEY_PAIR_ID']
  }
end

private

def policy(url, expiry)
  {
     "Statement"=> [
        {
           "Resource" => url,
           "Condition"=>{
              "DateLessThan" =>{"AWS:EpochTime"=> expiry.utc.to_i}
           }
        }
     ]
  }.to_json.gsub(/\s+/,'')
end

def safe_base64(data)
  Base64.strict_encode64(data).tr('+=/', '-_~')
end

def sign(data)
  digest = OpenSSL::Digest::SHA1.new
  key    = OpenSSL::PKey::RSA.new ENV['CLOUDFRONT_PRIVATE_KEY']
  result = key.sign digest, data
  safe_base64(result)
end

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
cookie_data('http://docs.example.com/*', 1.hour.from_now).each do |k,v|
  cookies[k] = {:value => v, :domain => "example.com", :path => '/'}
end

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
def get_ticket
  if current_user
    ticket = current_user.tickets.create! service: params[:service]
    redirect_to set_cookies_url(:ticket => ticket.token, :host => URI.parse(params[:service]).host)
  else
    store_location_for(:user, get_ticket_path(service: params[:service]))
    redirect_to new_user_session_path
  end
end

def set_cookies
  ticket = Ticket.find_by(token: params[:ticket])
  if ticket && URI.parse(ticket.service).host
    CloudfrontSigner.cookie_data("http*://#{URI.parse(ticket.service).host}/*", 1.hour.from_now).each do |name, value|
      cookies[name] = value
    end
    redirect_to ticket.service
    ticket.destroy!
  end
end

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

  1. The user goes to https://example.com/authorization/get_ticket?service=http://d123456789abcde.cloudfront.net/
  2. 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.)
  3. You generate a ticket and redirect the user to https://d123456789abcde.cloudfront.net/authorization/set_cookies?ticket=<TICKETVALUE>
  4. Cloudfront forwards this request to your app.
  5. 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.
  6. The user is redirected to https://d123456789abcde.cloudfront.net
  7. 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.