lib/rack/saml.rb in rack-saml-0.0.1 vs lib/rack/saml.rb in rack-saml-0.0.2
- old
+ new
@@ -1,31 +1,49 @@
require 'rack'
require 'yaml'
+require 'securerandom'
module Rack
# Rack::Saml
#
# As the Shibboleth SP, Rack::Saml::Base adopts :protected_path
# as an :assertion_consumer_path. It is easy to configure and
# support omniauth-shibboleth.
# To establish single path behavior, it currently supports only
# HTTP Redirect Binding from SP to Idp
# HTTP POST Binding from IdP to SP
+ #
+ # rack-saml uses rack.session to store SAML and Discovery Service
+ # status.
+ # env['rack.session'] = {
+ # 'rack_saml' => {
+ # 'ds.session' => {
+ # 'sid' => temporally_generated_hash,
+ # 'expire_at' => xxxxx # timestamp
+ # }
+ # 'saml_authreq.session' => {
+ # 'sid' => temporally_generated_hash,
+ # 'expire_at' => xxxxx # timestamp
+ # }
+ # 'saml_res.session' => {
+ # 'sid' => temporally_generated_hash,
+ # 'expire_at' => xxxxx # timestamp,
+ # 'env' => {}
+ # }
+ # }
+ # }
class Saml
autoload "RequestHandler", 'rack/saml/request_handler'
autoload "MetadataHandler", 'rack/saml/metadata_handler'
autoload "ResponseHandler", 'rack/saml/response_handler'
- class SamlAssertionError < StandardError
- end
-
def default_config_path(config_file)
::File.expand_path("../../../config/#{config_file}", __FILE__)
end
- def default_saml_config
- default_config_path('saml.yml')
+ def default_config
+ default_config_path('rack-saml.yml')
end
def default_metadata
default_config_path('metadata.yml')
end
@@ -36,16 +54,16 @@
def initialize app, opts = {}
@app = app
@opts = opts
- if @opts[:saml_config].nil? || !::File.exists?(@opts[:saml_config])
- @opts[:saml_config] = default_saml_config
+ if @opts[:config].nil? || !::File.exists?(@opts[:config])
+ @opts[:config] = default_config
end
- @saml_config = YAML.load_file(@opts[:saml_config])
- if @saml_config['assertion_handler'].nil?
- raise ArgumentError, "'assertion_handler' parameter should be specified in the :saml_config file"
+ @config = YAML.load_file(@opts[:config])
+ if @config['assertion_handler'].nil?
+ raise ArgumentError, "'assertion_handler' parameter should be specified in the :config file"
end
if @opts[:metadata].nil? || !::File.exists?(@opts[:metadata])
@opts[:metadata] = default_metadata
end
@metadata = YAML.load_file(@opts[:metadata])
@@ -53,56 +71,156 @@
@opts[:attribute_map] = default_attribute_map
end
@attribute_map = YAML.load_file(@opts[:attribute_map])
end
+ class Session
+ RACK_SAML_COOKIE = '_rack_saml'
+ def initialize(env)
+ @rack_session = env['rack.session']
+ if @rack_session[RACK_SAML_COOKIE].nil?
+ @session = @rack_session[RACK_SAML_COOKIE] = {
+ 'ds.session' => {},
+ 'saml_authreq.session' => {},
+ 'saml_res.session' => {'env' => {}}
+ }
+ else
+ @session = @rack_session[RACK_SAML_COOKIE]
+ end
+ end
+
+ def generate_sid(length = 32)
+ SecureRandom.hex(length)
+ end
+
+ def get_sid(type)
+ @session["#{type}.session"]['sid']
+ end
+
+ def start(type, timeout = 300)
+ sid = nil
+ if timeout.nil?
+ period = nil
+ else
+ period = Time.now + timeout
+ end
+ case type
+ when 'ds'
+ sid = generate_sid(4)
+ when 'saml_authreq'
+ sid = generate_sid
+ when 'saml_res'
+ sid = generate_sid
+ end
+ @session["#{type}.session"]['sid'] = sid
+ @session["#{type}.session"]['expired_at'] = period
+ @session["#{type}.session"]
+ end
+
+ def finish(type)
+ @session["#{type}.session"] = {}
+ end
+
+ def env
+ @session['saml_res.session']['env']
+ end
+
+ def is_valid?(type, sid = nil)
+ session = @session["#{type}.session"]
+ return false if session['sid'].nil? # no valid session
+ if session['expired_at'].nil? # no expiration
+ return true if sid.nil? # no sid check
+ return true if session['sid'] == sid # sid check
+ else
+ if Time.now < Time.at(session['expired_at']) # before expiration
+ return true if sid.nil? # no sid check
+ return true if session['sid'] == sid # sid check
+ end
+ end
+ false
+ end
+ end
+
def call env
+ session = Session.new(env)
request = Rack::Request.new env
+ # saml_sp: SAML SP's entity_id
+ # generate saml_sp from request uri and default path (rack-saml-sp)
+ saml_sp_prefix = "#{request.scheme}://#{request.host}#{":#{request.port}" if request.port}#{request.script_name}"
+ @config['saml_sp'] = "#{saml_sp_prefix}/rack-saml-sp"
+ @config['assertion_consumer_service_uri'] = "#{saml_sp_prefix}#{@config['protected_path']}"
+ # for debug
#return [
# 403,
# {
# 'Content-Type' => 'text/plain'
# },
# ["Forbidden." + request.inspect]
# ["Forbidden." + env.to_a.map {|i| "#{i[0]}: #{i[1]}"}.join("\n")]
#]
if request.request_method == 'GET'
if match_protected_path?(request) # generate AuthnRequest
- handler = RequestHandler.new(request, @saml_config, @metadata['idp_lists'][@saml_config['saml_idp']])
- return Rack::Response.new.tap { |r|
- r.redirect handler.authn_request.redirect_uri
- }.finish
+ if session.is_valid?('saml_res') # the client already has a valid session
+ ResponseHandler.extract_attrs(request, session)
+ else
+ if !@config['shib_ds'].nil? # use discovery service (ds)
+ if request.params['entityID'].nil? # start ds session
+ session.start('ds')
+ return Rack::Response.new.tap { |r|
+ r.redirect "#{@config['shib_ds']}?entityID=#{URI.encode(@config['saml_sp'], /[^\w]/)}&return=#{URI.encode("#{@config['assertion_consumer_service_uri']}?target=#{session.get_sid('ds')}", /[^\w]/)}"
+ }.finish
+ end
+ if !session.is_valid?('ds', request.params['target']) # confirm ds session
+ current_sid = session.get_sid('ds')
+ session.finish('ds')
+ return create_response(500, 'text/html', "Internal Server Error: Invalid discovery service session current sid=#{current_sid}, request sid=#{request.params['target']}")
+ end
+ session.finish('ds')
+ @config['saml_idp'] = request.params['entityID']
+ end
+ session.start('saml_authreq')
+ handler = RequestHandler.new(request, @config, @metadata['idp_lists'][@config['saml_idp']])
+ return Rack::Response.new.tap { |r|
+ r.redirect handler.authn_request.redirect_uri
+ }.finish
+ end
elsif match_metadata_path?(request) # generate Metadata
- handler = MetadataHandler.new(request, @saml_config, @metadata['idp_lists'][@saml_config['saml_idp']])
- return [
- 200,
- {
- 'Content-Type' => 'application/samlmetadata+xml'
- },
- [handler.sp_metadata.generate]
- ]
+ handler = MetadataHandler.new(request, @config, @metadata['idp_lists'][@config['saml_idp']])
+ return create_response(200, 'application/samlmetadata+xml', handler.sp_metadata.generate)
end
elsif request.request_method == 'POST' && match_protected_path?(request) # process Response
- handler = ResponseHandler.new(request, @saml_config, @metadata['idp_lists'][@saml_config['saml_idp']])
- if handler.response.is_valid?
- handler.extract_attrs(env, @attribute_map, @opts)
+ if session.is_valid?('saml_authreq')
+ handler = ResponseHandler.new(request, @config, @metadata['idp_lists'][@config['saml_idp']])
+ if handler.response.is_valid?
+ session.finish('saml_authreq')
+ session.start('saml_res', @config['saml_sess_timeout'] || 1800)
+ handler.extract_attrs(env, session, @attribute_map)
+ else
+ return create_response(403, 'text/html', 'SAML Error: Invalid SAML response.')
+ end
else
- raise SamlAssertionError, "Invalid SAML response."
+ return create_response(500, 'text/html', 'No valid AuthnRequest session.')
end
end
@app.call env
end
def match_protected_path?(request)
- if @saml_config['protected_path_regexp']
- # to be fixed (Regexp)
- return (request.path_info =~ Regexp.new(@saml_config['protected_path']))
- end
- request.path_info == @saml_config['protected_path']
+ request.path_info == @config['protected_path']
end
def match_metadata_path?(request)
- request.path_info == @saml_config['metadata_path']
+ request.path_info == @config['metadata_path']
+ end
+
+ def create_response(code, content_type, message)
+ return [
+ code,
+ {
+ 'Content-Type' => content_type
+ },
+ [message]
+ ]
end
end
end