Dynamic cookie domains with Rack’s middleware

Handling sessions in multi-domain environment is not the simplest things to do, because of the fact that cookies are scoped to a domain they were set by.

Recently we were developing an application with such an idea in mind:

  • Application will work as a base for other mini-applications (which we call sites)
  • Each site can be accessed via different url types: site.example.org and example.org/site
  • We want the users to remain logged in when switching from one url type to another

I won’t be covering application structure, routing, etc. here, I will only write about maintaing the sessions is such an environment.

So this is pretty simple here – all that we needed to do was to set cookie domain to .example.org (note the “dot” at the beginning). This could be done via:

ActionController::Base.session = {
  :domain => ".example.org"
}

However there was an additional requirement that we need to deal with:

  • Each site can be accessed via custom domain – site.com
  • Of course there’s no way here to keep the user logged in when he’s switching from site.com to example.org/site or site.example.org, at least it cannot be done with setting cookie domain to whatever value

Technically, to access the site via site.com, that domain must point to our IP address. Then we need to detect that the site is being accessed via custom domain and set cookie domain respectively.

This could be done via some funky before_filters in an Application Controller, however we found much better and cleaner way.

Rack’s middleware to the rescue

Rack itself is a minimal interface between web server and your ruby framework. It’s used by Ruby on Rails (since 2.3) and Merb. The request comes from web server, goes through middleware layers and enters the application.

So we wrote a middleware layer that detects the host with which our application is accessed and sets cookie domain for the request. Here it is:

app/middlewares/set_cookie_domain.rb

class SetCookieDomain
  def initialize(app, default_domain)
    @app = app
    @default_domain = default_domain
  end
 
  def call(env)
    host = env["HTTP_HOST"].split(':').first
    env["rack.session.options"][:domain] = custom_domain?(host) ? ".#{host}" : "#{@default_domain}"
    @app.call(env)
  end
 
  def custom_domain?(host)
    domain = @default_domain.sub(/^\./, '')
    host !~ Regexp.new("#{domain}$", Regexp::IGNORECASE)
  end
end

Now we need to turn it on:

environment.rb

config.load_paths += %W( #{RAILS_ROOT}/app/middlewares )

production.rb

config.middleware.use "SetCookieDomain", ".example.org"

.example.org is the default domain that will be used unless the application is accessed via custom domain (like site.com), we give it different values depending on environment (production/staging/development etc).

And since we’re fans of test driven development, here’s the test that ensures us that everything works as expected:

tests/integration/set_cookie_domain_test.rb

require 'test_helper'
 
class SetCookieDomainTest < ActionController::IntegrationTest
 
  context "when accessing site at example.org" do
    setup do
      host! 'example.org'
      visit '/'
    end
 
    should "set cookie_domain to .example.org" do
      assert_equal '.example.org', @integration_session.controller.request.session_options[:domain]
    end
  end
 
  context "when accessing site at site.com" do
    setup do
      host! 'site.com'
      visit '/'
    end
 
    should "set cookie_domain to .site.com" do
      assert_equal '.site.com', @integration_session.controller.request.session_options[:domain]
    end
  end
 
  context "when accessing site at site.example.org" do
    setup do
      host! 'site.example.org'
      visit '/'
    end
 
    should "set cookie_domain to .example.org" do
      assert_equal '.example.org', @integration_session.controller.request.session_options[:domain]
    end
  end
 
end

Test is sponsored by great Shoulda and Webrat gems.

Feel free to comment and share.

  • http://blog.thoughtpropulsion.com Bill Burcham

    This is clean. I like the fact that there’s no monkey-patching monkey-business.

    Along these lines, have you see the merb_routing plugin which lets you use Merb routing instead of Rails routing in a Rails app? We have it working on Rails 2.3.2 w/ rspec and friends here:

    http://github.com/Bill/merb_routing/tree/master

    If you look at the code, you’ll notice I had to use the nifty new rack-test gem to build a Rack session at one point. It’s interesting that the two kinds of routing are mostly interchangeable due to their _almost_ being Rack-compliant. There is still the AC::Request which isn’t exactly a Rack Request but at least it’s a straightforward subclass.