├── .gitignore ├── Gemfile ├── lib ├── session_injector │ └── version.rb └── session_injector.rb ├── Gemfile.lock ├── session_injector.gemspec └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | source :gemcutter 3 | 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/session_injector/version.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module SessionInjector 3 | VERSION = "0.0.2.snapshot" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | session_injector (0.0.1) 5 | activesupport (>= 3) 6 | rack (>= 1.2) 7 | 8 | GEM 9 | remote: http://rubygems.org/ 10 | remote: http://rubygems.org/ 11 | specs: 12 | activesupport (3.0.5) 13 | rack (1.2.2) 14 | 15 | PLATFORMS 16 | ruby 17 | 18 | DEPENDENCIES 19 | session_injector! 20 | -------------------------------------------------------------------------------- /session_injector.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/session_injector/version", __FILE__) 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "session_injector" 5 | s.version = Rack::SessionInjector::VERSION 6 | s.platform = Gem::Platform::RUBY 7 | s.authors = ["Aaron Hamid"] 8 | s.email = ["aaron@incandescentsoftware.com"] 9 | s.homepage = "http://github.com/incandescent/session-injector" 10 | s.summary = "A Rack session injector middleware" 11 | s.description = "A Rack middleware that allows injecting a session across domains" 12 | 13 | s.required_rubygems_version = ">= 1.3.6" 14 | 15 | # lol - required for validation 16 | #s.rubyforge_project = "" 17 | 18 | # If you have other dependencies, add them here 19 | s.add_dependency "activesupport", ">= 3" 20 | s.add_dependency "rack", ">= 1.2" 21 | 22 | s.files = Dir["{lib}/**/*.rb", "bin/*", "LICENSE", "*.md"] 23 | s.require_path = 'lib' 24 | end 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | If you are developing an application that serves subdomains, the `:all` cookie store domain parameter will most likely serve your needs. However if your application serves distinct domains, you will most likely encounter some difficulties, as secure browsers will not accept "third party cookies" (i.e. any cookies you issue for a different domain will be disregarded). 5 | 6 | There are a couple of approaches, neither of which are particularly elegant: http://stackoverflow.com/questions/263010/whats-your-favorite-cross-domain-cookie-sharing-approach 7 | 8 | This gem provides a middleware that implements a "handshake" protocol based on a token inserted into a URL parameter, which allows you to transparently re-establish a Rack/Rails session accross domains. It parses incoming parameters for the handshake/token parameter, decrypts and verifies the token, and sets the session id in the request, thereby re-establishing the session on the target domain. 9 | 10 | Usage 11 | ===== 12 | 13 | If you are using Rails, insert this into your `config/application.rb`: 14 | 15 | config.middleware.insert_before ActionDispatch::Cookies, "Rack::Middleware::SessionInjector", :key => '_your_session' 16 | 17 | Configuration options: 18 | 19 | # the 'key' for your session (if you have set a custom session key) 20 | @session_id_key = options[:key] 21 | # the encryption key. omit for a dynamically generated key 22 | @token_key = options[:token_key] || generated_token_key 23 | # receiver-enforced lifetime of token. default: 5 seconds 24 | @enforced_lifetime = options[:token_lifetime] 25 | # should we die when we recieve an invalid token, or just continue (without session injection naturally) 26 | @die_on_handshake_failure = options[:die_on_handshake_failure] 27 | 28 | There are three public methods through which you can initiate the session transfer: 29 | 30 | Rack::Middleware::SessionInjector.generate_handshake_token(request, target_domain, lifetime = nil) 31 | Rack::Middleware::SessionInjector.generate_handshake_parameter(request, target_domain, lifetime = nil) 32 | Rack::Middleware::SessionInjector.propagate_session(request, target_domain, lifetime = nil) 33 | 34 | you can append the parameter to a link: 35 | 36 | link_to "http://otherdomain?#{Rack::Middleware::SessionInjector.generate_handshake_parameter(request, 'myotherhost')}" 37 | 38 | or tell the middleware to rewrite the Location header on an HTTP redirect response: 39 | 40 | Rack::Middleware::SessionInjector.propagate_session(request, 'myotherhost') 41 | 42 | or you can just generate the token and use some custom method to convey it to the request on the target domain: 43 | 44 | token = Rack::Middleware::SessionInjector.generate_handshake_token(request, 'myotherhost') 45 | 46 | Security 47 | ======== 48 | 49 | The "handshake" token is generated via `ActiveSupport::MessageEncryptor` using a dynamically generated key (although you can specify a static key yourself). 50 | 51 | The token data consists of: 52 | 53 | handshake = { 54 | :request_ip => request.ip, 55 | :request_path => request.fullpath, # more for accounting/stats than anything else 56 | :src_domain => request.host, 57 | :tgt_domain => target_domain, 58 | :token_create_time => Time.now.to_i, 59 | # the most important thing 60 | :session_id => extract_session_id(request, session_injector.session_id_key) 61 | } 62 | 63 | This token is verified in the following manner: 64 | 65 | * client request ip must match 66 | * target domain must match 67 | * token must not be older than receiver-specified lifetime 68 | * token must not be older than sender-specified lifetime 69 | -------------------------------------------------------------------------------- /lib/session_injector.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/message_encryptor' 2 | require 'uri' 3 | 4 | module Rack 5 | module Middleware 6 | class SessionInjector 7 | 8 | class InvalidHandshake < StandardError; end 9 | 10 | RACK_COOKIE_STRING = 'rack.request.cookie_string'.freeze 11 | RACK_COOKIE_HASH = 'rack.request.cookie_hash'.freeze 12 | HTTP_COOKIE = 'HTTP_COOKIE'.freeze 13 | 14 | DEFAULT_OPTIONS = { 15 | # use the AbstractStore default key as our session id key 16 | # if you have configured a custom session store key, you must 17 | # specify that as the value for this middleware 18 | :key => ActionDispatch::Session::AbstractStore::DEFAULT_OPTIONS[:key], 19 | :token_lifetime => 5000, # five seconds should be enough 20 | :die_on_handshake_failure => true 21 | } 22 | 23 | # the env key we will use to stash ourselves for downstream access 24 | SESSION_INJECTOR_KEY = '_session_injector'; 25 | # the env key upstream uses to stash a flag to tell us to propagate a session 26 | # this is a convenience for manually adding a request parameter to a redirect response location 27 | SESSION_PROPAGATE_KEY = '_session_propagate'; 28 | 29 | # the internal parameter we will use to convey the session handshake token 30 | HANDSHAKE_PARAM = '_hs_'; 31 | 32 | def initialize(app, options = {}) 33 | @app = app 34 | options = DEFAULT_OPTIONS.merge(options) 35 | @session_id_key = options[:key] 36 | # statically generated token key in case we 37 | # need to fall back (no cookie token key has been set) 38 | # handshakes are by definition transient, so the only 39 | # important requirement is that the middleware that generates 40 | # the token can decrypt the token. when not under a clustered/balanced 41 | # architecture, that most likely means the same process/middelware 42 | # so the key value is not important 43 | # in fact, non-durability of the token is a security feature 44 | generated_token_key = SecureRandom.random_bytes(16).unpack("H*")[0] 45 | @token_key = options[:token_key] || generated_token_key 46 | @enforced_lifetime = options[:token_lifetime] 47 | @die_on_handshake_failure = options[:die_on_handshake_failure] 48 | end 49 | 50 | def call(env) 51 | env[SESSION_INJECTOR_KEY] = self; # store ourselves for downstream access 52 | reconstitute_session(env) 53 | response = @app.call(env) 54 | response = propagate_session(env, *response) 55 | response 56 | end 57 | 58 | # rewrites location header if requested 59 | def propagate_session(env, status, headers, response) 60 | propagate_flag = env.delete(SESSION_PROPAGATE_KEY) 61 | location = headers["Location"] 62 | if propagate_flag and location 63 | # we've been told to rewrite the location header and it is present 64 | uri = URI::parse(location) 65 | prefix = uri.query ? "&" : "" 66 | # append handshake param to query 67 | uri.query = [uri.query, prefix, SessionInjector.generate_handshake_parameter(Rack::Request.new(env), propagate_flag[0], propagate_flag[1])].join 68 | headers["Location"] = uri.to_s 69 | end 70 | [ status, headers, response] 71 | end 72 | 73 | # generates the handshake token we can send to the target domain 74 | def self.generate_handshake_token(request, target_domain, lifetime = nil) 75 | # retrieve the configured middleware instance 76 | session_injector = request.env[SESSION_INJECTOR_KEY] 77 | # note: scheme is not included in handshake 78 | # a session initiated on https may be established on http 79 | handshake = { 80 | :request_ip => request.ip, 81 | :request_path => request.fullpath, # more for accounting/stats than anything else 82 | :src_domain => request.host, 83 | :tgt_domain => target_domain, 84 | :token_create_time => Time.now.to_i, 85 | # the most important thing 86 | :session_id => extract_session_id(request, session_injector.session_id_key) 87 | } 88 | handshake[:requested_lifetime] = lifetime if lifetime 89 | # we could reuse ActionDispatch::Cookies.TOKEN_KEY if it is present but let's not! 90 | ActiveSupport::MessageEncryptor.new(session_injector.token_key).encrypt_and_sign(handshake); 91 | end 92 | 93 | # generates the handshake parameter key=value string 94 | def self.generate_handshake_parameter(request, target_domain, lifetime = nil) 95 | "#{HANDSHAKE_PARAM}=#{generate_handshake_token(request, target_domain, lifetime)}" 96 | end 97 | 98 | # helper that sets a flag to rewrite the location header with session propagation handshake 99 | def self.propagate_session(request, target_domain, lifetime = nil) 100 | request.env[SESSION_PROPAGATE_KEY] = [ target_domain, lifetime ] 101 | end 102 | 103 | # find the current session id 104 | def self.extract_session_id(request, session_id_key) 105 | #request.session_options[:id] 106 | request.cookies[session_id_key] 107 | end 108 | 109 | # return the env key containing the session id 110 | def session_id_key 111 | @session_id_key 112 | end 113 | 114 | # return the key we use for encryption and hashing 115 | def token_key 116 | @token_key 117 | end 118 | 119 | protected 120 | 121 | # validates the handshake against the current environment 122 | def validate_handshake(handshake, env) 123 | # is the handshake token expired? 124 | token_create_time = handshake[:token_create_time] 125 | raise InvalidHandshake, "token creation time missing" unless token_create_time 126 | now = Time.now.to_i 127 | token_age = now - token_create_time 128 | raise InvalidHandshake, "token has is expired" unless token_age < @enforced_lifetime 129 | # ok, we can accept this token, but does the source want us to? 130 | raise InvalidHandshake, "token has outlived requested expiration" if handshake[:requested_lifetime] and token_age > handshake[:requested_lifetime] 131 | 132 | # cool, token is not expired 133 | # is it for the right domain? 134 | this_request = Rack::Request.new(env) 135 | raise InvalidHandshake, "target domain mismatch" unless handshake[:tgt_domain] == this_request.host 136 | 137 | # it's FOR the right domain 138 | # is it FROM the right domain? 139 | # SKIP THIS CHECK 140 | # 'referrer' is not reliable, is up to the client to send, and we may not always be coming from a redirect 141 | # raise InvalidHandshake, "source domain mismatch" unless handshake[:src_domain] == URI::parse(this_request.referrer).host 142 | 143 | # finally, is this the same client that was associated with the source session? 144 | # this really should be the case unless some shenanigans is going on (either somebody is replaying the token 145 | # or there is some client balancing or proxying going on) 146 | raise InvalidHandshake, "client ip mismatch" unless handshake[:request_ip] = this_request.ip 147 | end 148 | 149 | private 150 | 151 | # load and inject any session that might be conveyed in this request 152 | def reconstitute_session(env) 153 | request = Rack::Request.new(env) 154 | token = request.params[HANDSHAKE_PARAM] 155 | return unless token 156 | 157 | # decrypt the token and get the session cookie value 158 | handshake = decrypt_handshake_token(token, env) 159 | return unless handshake 160 | 161 | cookie_value = handshake[:session_id] 162 | 163 | # fix up Rack env 164 | # ensure the cookie string is set 165 | env[HTTP_COOKIE] = [env[HTTP_COOKIE], "#{@session_id_key}=#{cookie_value}"].compact.join(';') 166 | # Rack request object parses cookies on demand and stores data in internal env keys 167 | # but the current implementation is not good about writing back through to the env 168 | # Since requests objects are transient wrappers we have to be prepared to encounter an env 169 | # that may already be initialized with some state 170 | # if the cookie string has already been read by Rack, update Rack's internal cookie string variable 171 | if env[RACK_COOKIE_STRING] 172 | env[RACK_COOKIE_STRING] = [env[RACK_COOKIE_STRING], "#{@session_id_key}=#{cookie_value}"].compact.join(';') 173 | end 174 | # if the cookie string has already been read by Rack, update Rack's internal cookie hash variable 175 | request = Rack::Request.new(env) 176 | request.cookies[@session_id_key] = cookie_value # call cookies() to make Rack::Request do its stuff 177 | end 178 | 179 | # decrypts a handshake token sent to us from a source domain 180 | def decrypt_handshake_token(token, env) 181 | handshake = ActiveSupport::MessageEncryptor.new(@token_key).decrypt_and_verify(token); 182 | begin 183 | validate_handshake(handshake, env) 184 | return handshake 185 | rescue InvalidHandshake 186 | raise if @die_on_handshake_failure 187 | end 188 | return nil 189 | end 190 | end 191 | end 192 | end --------------------------------------------------------------------------------