omniauth-iu-cas 1.1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.editorconfig +16 -0
- data/.gitignore +21 -0
- data/.ruby-version +1 -0
- data/.travis.yml +10 -0
- data/CHANGELOG.md +20 -0
- data/Gemfile +9 -0
- data/LICENSE +22 -0
- data/README.md +133 -0
- data/Rakefile +15 -0
- data/lib/omniauth-iu-cas.rb +1 -0
- data/lib/omniauth/cas.rb +2 -0
- data/lib/omniauth/cas/version.rb +5 -0
- data/lib/omniauth/strategies/cas.rb +232 -0
- data/lib/omniauth/strategies/cas/logout_request.rb +58 -0
- data/lib/omniauth/strategies/cas/service_ticket_validator.rb +112 -0
- data/omniauth-cas.gemspec +28 -0
- data/spec/fixtures/cas_failure.xml +4 -0
- data/spec/fixtures/cas_success.xml +17 -0
- data/spec/fixtures/cas_success_jasig.xml +19 -0
- data/spec/fixtures/iu_cas_failure +1 -0
- data/spec/fixtures/iu_cas_success +1 -0
- data/spec/omniauth/strategies/cas/logout_request_spec.rb +103 -0
- data/spec/omniauth/strategies/cas/service_ticket_validator_spec.rb +55 -0
- data/spec/omniauth/strategies/cas_spec.rb +339 -0
- data/spec/spec_helper.rb +13 -0
- metadata +191 -0
@@ -0,0 +1,58 @@
|
|
1
|
+
module OmniAuth
|
2
|
+
module Strategies
|
3
|
+
class CAS
|
4
|
+
class LogoutRequest
|
5
|
+
def initialize(strategy, request)
|
6
|
+
@strategy, @request = strategy, request
|
7
|
+
end
|
8
|
+
|
9
|
+
def call(options = {})
|
10
|
+
@options = options
|
11
|
+
|
12
|
+
begin
|
13
|
+
result = single_sign_out_callback.call(*logout_request)
|
14
|
+
rescue StandardError => err
|
15
|
+
return @strategy.fail! :logout_request, err
|
16
|
+
else
|
17
|
+
result = [200,{},'OK'] if result == true || result.nil?
|
18
|
+
ensure
|
19
|
+
return unless result
|
20
|
+
|
21
|
+
# TODO: Why does ActionPack::Response return [status,headers,body]
|
22
|
+
# when Rack::Response#new wants [body,status,headers]? Additionally,
|
23
|
+
# why does Rack::Response differ in argument order from the usual
|
24
|
+
# Rack-like [status,headers,body] array?
|
25
|
+
return Rack::Response.new(result[2],result[0],result[1]).finish
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def logout_request
|
32
|
+
@logout_request ||= begin
|
33
|
+
saml = Nokogiri.parse(@request.params['logoutRequest'])
|
34
|
+
name_id = saml.xpath('//saml:NameID').text
|
35
|
+
sess_idx = saml.xpath('//samlp:SessionIndex').text
|
36
|
+
inject_params(name_id:name_id, session_index:sess_idx)
|
37
|
+
@request
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def inject_params(new_params)
|
42
|
+
rack_input = @request.env['rack.input'].read
|
43
|
+
params = Rack::Utils.parse_query(rack_input, '&').merge new_params
|
44
|
+
@request.env['rack.input'] = StringIO.new(Rack::Utils.build_query(params))
|
45
|
+
rescue
|
46
|
+
# A no-op intended to ensure that the ensure block is run
|
47
|
+
raise
|
48
|
+
ensure
|
49
|
+
@request.env['rack.input'].rewind
|
50
|
+
end
|
51
|
+
|
52
|
+
def single_sign_out_callback
|
53
|
+
@options[:on_single_sign_out]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'net/https'
|
3
|
+
require 'nokogiri'
|
4
|
+
|
5
|
+
module OmniAuth
|
6
|
+
module Strategies
|
7
|
+
class CAS
|
8
|
+
class ServiceTicketValidator
|
9
|
+
VALIDATION_REQUEST_HEADERS = { 'Accept' => '*/*' }
|
10
|
+
|
11
|
+
attr_reader :success_body
|
12
|
+
|
13
|
+
# Build a validator from a +configuration+, a
|
14
|
+
# +return_to+ URL, and a +ticket+.
|
15
|
+
#
|
16
|
+
# @param [Hash] options the OmniAuth Strategy options
|
17
|
+
# @param [String] return_to_url the URL of this CAS client service
|
18
|
+
# @param [String] ticket the service ticket to validate
|
19
|
+
def initialize(strategy, options, return_to_url, ticket)
|
20
|
+
@options = options
|
21
|
+
@uri = URI.parse(strategy.service_validate_url(return_to_url, ticket))
|
22
|
+
end
|
23
|
+
|
24
|
+
# Executes a network request to process the CAS Service Response
|
25
|
+
def call
|
26
|
+
@response_body = get_service_response_body
|
27
|
+
@success_body = find_authentication_success(@response_body)
|
28
|
+
self
|
29
|
+
end
|
30
|
+
|
31
|
+
# Request validation of the ticket from the CAS server's
|
32
|
+
# serviceValidate (CAS 2.0) function.
|
33
|
+
#
|
34
|
+
# Swallows all XML parsing errors (and returns +nil+ in those cases).
|
35
|
+
#
|
36
|
+
# @return [Hash, nil] a user information hash if the response is valid; +nil+ otherwise.
|
37
|
+
#
|
38
|
+
# @raise any connection errors encountered.
|
39
|
+
def user_info
|
40
|
+
parse_user_info(@success_body)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
# turns an `<cas:authenticationSuccess>` node into a Hash;
|
46
|
+
# returns nil if given nil
|
47
|
+
def parse_user_info(node)
|
48
|
+
return nil if node.nil?
|
49
|
+
return node if node.is_a? Hash
|
50
|
+
{}.tap do |hash|
|
51
|
+
node.children.each do |e|
|
52
|
+
node_name = e.name.sub(/^cas:/, '')
|
53
|
+
unless e.kind_of?(Nokogiri::XML::Text) || node_name == 'proxies'
|
54
|
+
# There are no child elements
|
55
|
+
if e.element_children.count == 0
|
56
|
+
hash[node_name] = e.content
|
57
|
+
elsif e.element_children.count
|
58
|
+
# JASIG style extra attributes
|
59
|
+
if node_name == 'attributes'
|
60
|
+
hash.merge!(parse_user_info(e))
|
61
|
+
else
|
62
|
+
hash[node_name] = [] if hash[node_name].nil?
|
63
|
+
hash[node_name].push(parse_user_info(e))
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# finds an `<cas:authenticationSuccess>` node in
|
72
|
+
# a `<cas:serviceResponse>` body if present; returns nil
|
73
|
+
# if the passed body is nil or if there is no such node.
|
74
|
+
def find_authentication_success(body)
|
75
|
+
return nil if body.nil? || body == ''
|
76
|
+
|
77
|
+
if body =~ /^(yes|no)[[:space:]]+(.*?)[[:space:]]*$/m
|
78
|
+
return nil unless $~[1] == 'yes'
|
79
|
+
return { @options[:uid_field].to_s => $~[2] }
|
80
|
+
end
|
81
|
+
|
82
|
+
begin
|
83
|
+
doc = Nokogiri::XML(body)
|
84
|
+
begin
|
85
|
+
doc.xpath('/cas:serviceResponse/cas:authenticationSuccess')
|
86
|
+
rescue Nokogiri::XML::XPath::SyntaxError
|
87
|
+
doc.xpath('/serviceResponse/authenticationSuccess')
|
88
|
+
end
|
89
|
+
rescue Nokogiri::XML::XPath::SyntaxError
|
90
|
+
nil
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# retrieves the `<cas:serviceResponse>` XML from the CAS server
|
95
|
+
def get_service_response_body
|
96
|
+
result = ''
|
97
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
98
|
+
http.use_ssl = @uri.port == 443 || @uri.instance_of?(URI::HTTPS)
|
99
|
+
if http.use_ssl?
|
100
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @options.disable_ssl_verification?
|
101
|
+
http.ca_path = @options.ca_path
|
102
|
+
end
|
103
|
+
http.start do |c|
|
104
|
+
response = c.get "#{@uri.path}?#{@uri.query}", VALIDATION_REQUEST_HEADERS.dup
|
105
|
+
result = response.body
|
106
|
+
end
|
107
|
+
result
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/omniauth/cas/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Derek Lindahl", "Daniel Pierce"]
|
6
|
+
gem.email = ["dlindahl@customink.com", "dlpierce@indiana.edu"]
|
7
|
+
gem.summary = %q{CAS Strategy for OmniAuth with custom IU extensions}
|
8
|
+
gem.description = gem.summary
|
9
|
+
gem.homepage = "https://github.com/IUBLibTech/omniauth-cas"
|
10
|
+
|
11
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
|
+
gem.files = `git ls-files`.split("\n")
|
13
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
14
|
+
gem.name = "omniauth-iu-cas"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = Omniauth::Cas::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency 'omniauth', '~> 1.2'
|
19
|
+
gem.add_dependency 'nokogiri', '~> 1.5'
|
20
|
+
gem.add_dependency 'addressable', '~> 2.3'
|
21
|
+
|
22
|
+
gem.add_development_dependency 'rake', '~> 10.0'
|
23
|
+
gem.add_development_dependency 'webmock', '~> 2.3.1'
|
24
|
+
gem.add_development_dependency 'rspec', '~> 3.1.0'
|
25
|
+
gem.add_development_dependency 'rack-test', '~> 0.6'
|
26
|
+
|
27
|
+
gem.add_development_dependency 'awesome_print'
|
28
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
|
2
|
+
<cas:authenticationSuccess>
|
3
|
+
<cas:user>psegel</cas:user>
|
4
|
+
<cas:employeeid>54</cas:employeeid>
|
5
|
+
<cas:first_name>P. Segel</cas:first_name>
|
6
|
+
<cas:first_name>Peter</cas:first_name>
|
7
|
+
<cas:last_name>Segel</cas:last_name>
|
8
|
+
<cas:email>psegel@intridea.com</cas:email>
|
9
|
+
<cas:location>Washington, D.C.</cas:location>
|
10
|
+
<cas:image>/images/user.jpg</cas:image>
|
11
|
+
<cas:phone>555-555-5555</cas:phone>
|
12
|
+
<cas:hire_date>2004-07-13</cas:hire_date>
|
13
|
+
<cas:roles>senator</cas:roles>
|
14
|
+
<cas:roles>lobbyist</cas:roles>
|
15
|
+
<cas:roles>financier</cas:roles>
|
16
|
+
</cas:authenticationSuccess>
|
17
|
+
</cas:serviceResponse>
|
@@ -0,0 +1,19 @@
|
|
1
|
+
<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
|
2
|
+
<cas:authenticationSuccess>
|
3
|
+
<cas:user>psegel</cas:user>
|
4
|
+
<cas:attributes>
|
5
|
+
<cas:employeeid>54</cas:employeeid>
|
6
|
+
<cas:first_name>P. Segel</cas:first_name>
|
7
|
+
<cas:first_name>Peter</cas:first_name>
|
8
|
+
<cas:last_name>Segel</cas:last_name>
|
9
|
+
<cas:email>psegel@intridea.com</cas:email>
|
10
|
+
<cas:location>Washington, D.C.</cas:location>
|
11
|
+
<cas:image>/images/user.jpg</cas:image>
|
12
|
+
<cas:phone>555-555-5555</cas:phone>
|
13
|
+
<cas:hire_date>2004-07-13</cas:hire_date>
|
14
|
+
<cas:roles>senator</cas:roles>
|
15
|
+
<cas:roles>lobbyist</cas:roles>
|
16
|
+
<cas:roles>financier</cas:roles>
|
17
|
+
</cas:attributes>
|
18
|
+
</cas:authenticationSuccess>
|
19
|
+
</cas:serviceResponse>
|
@@ -0,0 +1 @@
|
|
1
|
+
no
|
@@ -0,0 +1 @@
|
|
1
|
+
yes
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe OmniAuth::Strategies::CAS::LogoutRequest do
|
4
|
+
let(:strategy) { double('strategy') }
|
5
|
+
let(:env) do
|
6
|
+
{ 'rack.input' => StringIO.new('','r') }
|
7
|
+
end
|
8
|
+
let(:request) { double('request', params:params, env:env) }
|
9
|
+
let(:params) { { 'url' => url, 'logoutRequest' => logoutRequest } }
|
10
|
+
let(:url) { 'http://notes.dev/signed_in' }
|
11
|
+
let(:logoutRequest) do
|
12
|
+
%Q[
|
13
|
+
<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion\" ID="123abc-1234-ab12-cd34-1234abcd" Version="2.0" IssueInstant="#{Time.now.to_s}">
|
14
|
+
<saml:NameID>@NOT_USED@</saml:NameID>
|
15
|
+
<samlp:SessionIndex>ST-123456-123abc456def</samlp:SessionIndex>
|
16
|
+
</samlp:LogoutRequest>
|
17
|
+
]
|
18
|
+
end
|
19
|
+
|
20
|
+
subject { described_class.new(strategy, request).call(options) }
|
21
|
+
|
22
|
+
describe 'SAML attributes' do
|
23
|
+
let(:callback) { Proc.new{} }
|
24
|
+
let(:options) do
|
25
|
+
{ on_single_sign_out: callback }
|
26
|
+
end
|
27
|
+
|
28
|
+
before do
|
29
|
+
@rack_input = nil
|
30
|
+
allow(callback).to receive(:call) do |req|
|
31
|
+
@rack_input = req.env['rack.input'].read
|
32
|
+
true
|
33
|
+
end
|
34
|
+
subject
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'are parsed and injected into the Rack Request parameters' do
|
38
|
+
expect(@rack_input).to eq 'name_id=%40NOT_USED%40&session_index=ST-123456-123abc456def'
|
39
|
+
end
|
40
|
+
|
41
|
+
context 'that raise when parsed' do
|
42
|
+
let(:env) { { 'rack.input' => nil } }
|
43
|
+
|
44
|
+
before do
|
45
|
+
allow(strategy).to receive(:fail!)
|
46
|
+
subject
|
47
|
+
expect(strategy).to have_received(:fail!)
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'responds with an error' do
|
51
|
+
expect(strategy).to have_received(:fail!)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe 'with a configured callback' do
|
57
|
+
let(:options) do
|
58
|
+
{ on_single_sign_out: callback }
|
59
|
+
end
|
60
|
+
|
61
|
+
context 'that returns TRUE' do
|
62
|
+
let(:callback) { Proc.new{true} }
|
63
|
+
|
64
|
+
it 'responds with OK' do
|
65
|
+
expect(subject[0]).to eq 200
|
66
|
+
expect(subject[2].body).to eq ['OK']
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
context 'that returns Nil' do
|
71
|
+
let(:callback) { Proc.new{} }
|
72
|
+
|
73
|
+
it 'responds with OK' do
|
74
|
+
expect(subject[0]).to eq 200
|
75
|
+
expect(subject[2].body).to eq ['OK']
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
context 'that returns a tuple' do
|
80
|
+
let(:callback) { Proc.new{ [400,{},'Bad Request'] } }
|
81
|
+
|
82
|
+
it 'responds with OK' do
|
83
|
+
expect(subject[0]).to eq 400
|
84
|
+
expect(subject[2].body).to eq ['Bad Request']
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
context 'that raises an error' do
|
89
|
+
let(:exception) { RuntimeError.new('error' )}
|
90
|
+
let(:callback) { Proc.new{raise exception} }
|
91
|
+
|
92
|
+
before do
|
93
|
+
allow(strategy).to receive(:fail!)
|
94
|
+
subject
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'responds with an error' do
|
98
|
+
expect(strategy).to have_received(:fail!)
|
99
|
+
.with(:logout_request, exception)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe OmniAuth::Strategies::CAS::ServiceTicketValidator do
|
4
|
+
let(:strategy) do
|
5
|
+
double('strategy',
|
6
|
+
service_validate_url: 'https://example.org/serviceValidate'
|
7
|
+
)
|
8
|
+
end
|
9
|
+
let(:provider_options) do
|
10
|
+
double('provider_options',
|
11
|
+
disable_ssl_verification?: false,
|
12
|
+
ca_path: '/etc/ssl/certsZOMG'
|
13
|
+
)
|
14
|
+
end
|
15
|
+
let(:validator) do
|
16
|
+
OmniAuth::Strategies::CAS::ServiceTicketValidator.new( strategy, provider_options, '/foo', nil )
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '#call' do
|
20
|
+
before do
|
21
|
+
stub_request(:get, 'https://example.org/serviceValidate?')
|
22
|
+
.to_return(status: 200, body: '')
|
23
|
+
end
|
24
|
+
|
25
|
+
subject { validator.call }
|
26
|
+
|
27
|
+
it 'returns itself' do
|
28
|
+
expect(subject).to eq validator
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'uses the configured CA path' do
|
32
|
+
subject
|
33
|
+
expect(provider_options).to have_received :ca_path
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe '#user_info' do
|
38
|
+
let(:ok_fixture) do
|
39
|
+
File.expand_path(File.join(File.dirname(__FILE__), '../../../fixtures/cas_success.xml'))
|
40
|
+
end
|
41
|
+
let(:service_response) { File.read(ok_fixture) }
|
42
|
+
|
43
|
+
before do
|
44
|
+
stub_request(:get, 'https://example.org/serviceValidate?')
|
45
|
+
.to_return(status: 200, body:service_response)
|
46
|
+
validator.call
|
47
|
+
end
|
48
|
+
|
49
|
+
subject { validator.user_info }
|
50
|
+
|
51
|
+
it 'parses user info from the response' do
|
52
|
+
expect(subject).to include 'user' => 'psegel'
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,339 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe OmniAuth::Strategies::CAS, type: :strategy do
|
4
|
+
include Rack::Test::Methods
|
5
|
+
|
6
|
+
let(:my_cas_provider) { Class.new(OmniAuth::Strategies::CAS) }
|
7
|
+
before do
|
8
|
+
stub_const 'MyCasProvider', my_cas_provider
|
9
|
+
end
|
10
|
+
let(:app) do
|
11
|
+
Rack::Builder.new {
|
12
|
+
use OmniAuth::Test::PhonySession
|
13
|
+
use MyCasProvider,
|
14
|
+
name: :cas,
|
15
|
+
host: 'cas.example.org',
|
16
|
+
ssl: false,
|
17
|
+
port: 8080,
|
18
|
+
uid_field: :employeeid,
|
19
|
+
cassvc: 'ANY',
|
20
|
+
fetch_raw_info: Proc.new { |v, opts, ticket, info, node|
|
21
|
+
info.nil? or info.empty? ? {} : {
|
22
|
+
"roles" => node.xpath('//cas:roles').map(&:text),
|
23
|
+
}
|
24
|
+
}
|
25
|
+
run lambda { |env| [404, {'Content-Type' => 'text/plain'}, [env.key?('omniauth.auth').to_s]] }
|
26
|
+
}.to_app
|
27
|
+
end
|
28
|
+
|
29
|
+
# TODO: Verify that these are even useful tests
|
30
|
+
shared_examples_for 'a CAS redirect response' do
|
31
|
+
let(:redirect_params) { 'cassvc=ANY&casurl=' + Rack::Utils.escape("http://example.org/auth/cas/callback?url=#{Rack::Utils.escape(return_url)}") }
|
32
|
+
|
33
|
+
before { get url, nil, request_env }
|
34
|
+
|
35
|
+
subject { last_response }
|
36
|
+
|
37
|
+
it { should be_redirect }
|
38
|
+
|
39
|
+
it 'redirects to the CAS server' do
|
40
|
+
expect(subject.headers).to include 'Location' => "http://cas.example.org:8080/login?#{redirect_params}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe '#cas_url' do
|
45
|
+
let(:params) { Hash.new }
|
46
|
+
let(:provider) { MyCasProvider.new(nil, params) }
|
47
|
+
|
48
|
+
subject { provider.cas_url }
|
49
|
+
|
50
|
+
it 'raises an ArgumentError' do
|
51
|
+
expect{subject}.to raise_error ArgumentError, %r{:host and :login_url MUST be provided}
|
52
|
+
end
|
53
|
+
|
54
|
+
context 'with an explicit :url option' do
|
55
|
+
let(:url) { 'https://example.org:8080/my_cas' }
|
56
|
+
let(:params) { super().merge url:url }
|
57
|
+
|
58
|
+
before { subject }
|
59
|
+
|
60
|
+
it { should eq url }
|
61
|
+
|
62
|
+
it 'parses the URL into it the appropriate strategy options' do
|
63
|
+
expect(provider.options).to include ssl:true
|
64
|
+
expect(provider.options).to include host:'example.org'
|
65
|
+
expect(provider.options).to include port:8080
|
66
|
+
expect(provider.options).to include path:'/my_cas'
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
context 'with explicit URL component' do
|
71
|
+
let(:params) { super().merge host:'example.org', port:1234, ssl:true, path:'/a/path' }
|
72
|
+
|
73
|
+
before { subject }
|
74
|
+
|
75
|
+
it { should eq 'https://example.org:1234/a/path' }
|
76
|
+
|
77
|
+
it 'parses the URL into it the appropriate strategy options' do
|
78
|
+
expect(provider.options).to include ssl:true
|
79
|
+
expect(provider.options).to include host:'example.org'
|
80
|
+
expect(provider.options).to include port:1234
|
81
|
+
expect(provider.options).to include path:'/a/path'
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
describe 'defaults' do
|
87
|
+
subject { MyCasProvider.default_options.to_hash }
|
88
|
+
|
89
|
+
it { should include('ssl' => true) }
|
90
|
+
end
|
91
|
+
|
92
|
+
describe 'GET /auth/cas' do
|
93
|
+
let(:return_url) { 'http://myapp.com/admin/foo' }
|
94
|
+
|
95
|
+
context 'with a referer' do
|
96
|
+
let(:url) { '/auth/cas' }
|
97
|
+
|
98
|
+
let(:request_env) { { 'HTTP_REFERER' => return_url } }
|
99
|
+
|
100
|
+
it_behaves_like 'a CAS redirect response'
|
101
|
+
end
|
102
|
+
|
103
|
+
context 'with an explicit return URL' do
|
104
|
+
let(:url) { "/auth/cas?url=#{return_url}" }
|
105
|
+
|
106
|
+
let(:request_env) { {} }
|
107
|
+
|
108
|
+
it_behaves_like 'a CAS redirect response'
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
describe 'GET /auth/cas/callback' do
|
113
|
+
context 'without a ticket' do
|
114
|
+
before { get '/auth/cas/callback' }
|
115
|
+
|
116
|
+
subject { last_response }
|
117
|
+
|
118
|
+
it { should be_redirect }
|
119
|
+
|
120
|
+
it 'redirects with a failure message' do
|
121
|
+
expect(subject.headers).to include 'Location' => '/auth/failure?message=no_ticket&strategy=cas'
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
context 'with an invalid ticket' do
|
126
|
+
before do
|
127
|
+
stub_request(:get, /^http:\/\/cas.example.org:8080?\/serviceValidate\?([^&]+&)?cassvc=ANY&casticket=9391d/).
|
128
|
+
to_return( body: File.read('spec/fixtures/cas_failure.xml') )
|
129
|
+
get '/auth/cas/callback?cassvc=ANY&casticket=9391d'
|
130
|
+
end
|
131
|
+
|
132
|
+
subject { last_response }
|
133
|
+
|
134
|
+
it { should be_redirect }
|
135
|
+
|
136
|
+
it 'redirects with a failure message' do
|
137
|
+
expect(subject.headers).to include 'Location' => '/auth/failure?message=invalid_ticket&strategy=cas'
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
describe 'GET /auth/cas/callback with an invalid ticket at IU' do
|
142
|
+
before do
|
143
|
+
stub_request(:get, /^http:\/\/cas.example.org(:8080)?\/serviceValidate\?([^&]+&)?cassvc=ANY&casticket=9391d/).
|
144
|
+
to_return( :body => File.read('spec/fixtures/iu_cas_failure') )
|
145
|
+
get '/auth/cas/callback?cassvc=ANY&casticket=9391d'
|
146
|
+
end
|
147
|
+
|
148
|
+
subject { last_response }
|
149
|
+
|
150
|
+
it { should be_redirect }
|
151
|
+
it 'should have a failure message' do
|
152
|
+
subject.headers['Location'].should == "/auth/failure?message=invalid_ticket&strategy=cas"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
describe 'with a valid ticket' do
|
157
|
+
shared_examples :successful_validation do
|
158
|
+
before do
|
159
|
+
stub_request(:get, /^http:\/\/cas.example.org:8080?\/serviceValidate\?([^&]+&)?cassvc=ANY&casticket=593af/)
|
160
|
+
.with { |request| @request_uri = request.uri.to_s }
|
161
|
+
.to_return( body: File.read("spec/fixtures/#{xml_file_name}") )
|
162
|
+
|
163
|
+
get "/auth/cas/callback?cassvc=ANY&casticket=593af&url=#{return_url}"
|
164
|
+
end
|
165
|
+
|
166
|
+
it 'strips the casticket parameter from the callback URL' do
|
167
|
+
expect(@request_uri.scan('casticket=').size).to eq 1
|
168
|
+
end
|
169
|
+
|
170
|
+
it 'strips the cassvc parameter from the callback URL' do
|
171
|
+
expect(@request_uri.scan('cassvc=').size).to eq 1
|
172
|
+
end
|
173
|
+
|
174
|
+
it 'properly encodes the service URL' do
|
175
|
+
expect(WebMock).to have_requested(:get, 'http://cas.example.org:8080/serviceValidate')
|
176
|
+
.with(query: {
|
177
|
+
cassvc: 'ANY',
|
178
|
+
casticket: '593af',
|
179
|
+
casurl: 'http://example.org/auth/cas/callback?url=' + Rack::Utils.escape('http://127.0.0.10/?some=parameter')
|
180
|
+
})
|
181
|
+
end
|
182
|
+
|
183
|
+
context "request.env['omniauth.auth']" do
|
184
|
+
subject { last_request.env['omniauth.auth'] }
|
185
|
+
|
186
|
+
it { should be_kind_of Hash }
|
187
|
+
|
188
|
+
it 'identifes the provider' do
|
189
|
+
expect(subject.provider).to eq :cas
|
190
|
+
end
|
191
|
+
|
192
|
+
it 'returns the UID of the user' do
|
193
|
+
expect(subject.uid).to eq '54'
|
194
|
+
end
|
195
|
+
|
196
|
+
context 'the info hash' do
|
197
|
+
subject { last_request.env['omniauth.auth']['info'] }
|
198
|
+
|
199
|
+
it 'includes user info attributes' do
|
200
|
+
expect(subject.name).to eq 'Peter Segel'
|
201
|
+
expect(subject.first_name).to eq 'Peter'
|
202
|
+
expect(subject.last_name).to eq 'Segel'
|
203
|
+
expect(subject.nickname).to eq 'psegel'
|
204
|
+
expect(subject.email).to eq 'psegel@intridea.com'
|
205
|
+
expect(subject.location).to eq 'Washington, D.C.'
|
206
|
+
expect(subject.image).to eq '/images/user.jpg'
|
207
|
+
expect(subject.phone).to eq '555-555-5555'
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
context 'the extra hash' do
|
212
|
+
subject { last_request.env['omniauth.auth']['extra'] }
|
213
|
+
|
214
|
+
it 'includes additional user attributes' do
|
215
|
+
expect(subject.user).to eq 'psegel'
|
216
|
+
expect(subject.employeeid).to eq '54'
|
217
|
+
expect(subject.hire_date).to eq '2004-07-13'
|
218
|
+
expect(subject.roles).to eq %w(senator lobbyist financier)
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
context 'the credentials hash' do
|
223
|
+
subject { last_request.env['omniauth.auth']['credentials'] }
|
224
|
+
|
225
|
+
it 'has a ticket value' do
|
226
|
+
expect(subject.casticket).to eq '593af'
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
it 'calls through to the master app' do
|
232
|
+
expect(last_response.body).to eq 'true'
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
let(:return_url) { 'http://127.0.0.10/?some=parameter' }
|
237
|
+
|
238
|
+
context 'with JASIG flavored XML' do
|
239
|
+
let(:xml_file_name) { 'cas_success_jasig.xml' }
|
240
|
+
|
241
|
+
it_behaves_like :successful_validation
|
242
|
+
end
|
243
|
+
|
244
|
+
context 'with classic XML' do
|
245
|
+
let(:xml_file_name) { 'cas_success.xml' }
|
246
|
+
|
247
|
+
it_behaves_like :successful_validation
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
describe 'GET /auth/cas/callback with a valid IU CAS 1.0 ticket' do
|
252
|
+
class MyCasProvider < OmniAuth::Strategies::CAS; end # TODO: Not really needed. just an alias but it requires the :name option which might confuse users...
|
253
|
+
def app
|
254
|
+
Rack::Builder.new {
|
255
|
+
use OmniAuth::Test::PhonySession
|
256
|
+
use MyCasProvider,
|
257
|
+
name: :cas,
|
258
|
+
host: 'cas.example.org',
|
259
|
+
ssl: false,
|
260
|
+
port: 8080,
|
261
|
+
cassvc: 'ANY'
|
262
|
+
run lambda { |env| [404, {'Content-Type' => 'text/plain'}, [env.key?('omniauth.auth').to_s]] }
|
263
|
+
}.to_app
|
264
|
+
end
|
265
|
+
|
266
|
+
let(:return_url) { "http://127.0.0.10/?some=parameter" }
|
267
|
+
before do
|
268
|
+
stub_request(:get, /^http:\/\/cas.example.org(:8080)?\/serviceValidate\?([^&]+&)?cassvc=ANY&casticket=593af/).
|
269
|
+
with { |request| @request_uri = request.uri.to_s }.
|
270
|
+
to_return( :body => File.read('spec/fixtures/iu_cas_success') )
|
271
|
+
|
272
|
+
get "/auth/cas/callback?cassvc=ANY&casticket=593af&url=#{return_url}"
|
273
|
+
end
|
274
|
+
|
275
|
+
context "request.env['omniauth.auth']" do
|
276
|
+
subject { last_request.env['omniauth.auth'] }
|
277
|
+
it { should be_kind_of Hash }
|
278
|
+
it 'identifies the provider' do
|
279
|
+
expect(subject.provider).to eq :cas
|
280
|
+
end
|
281
|
+
it 'returns the UID of the user' do
|
282
|
+
expect(subject.uid).to eq 'psegel'
|
283
|
+
end
|
284
|
+
context "the info hash" do
|
285
|
+
subject { last_request.env['omniauth.auth']['info'] }
|
286
|
+
it "contains only the user" do
|
287
|
+
expect(subject.size).to eq 1
|
288
|
+
expect(subject.nickname).to eq 'psegel'
|
289
|
+
end
|
290
|
+
end
|
291
|
+
context "the extra hash" do
|
292
|
+
subject { last_request.env['omniauth.auth']['extra'] }
|
293
|
+
it "contains only the user" do
|
294
|
+
expect(subject.size).to eq 1
|
295
|
+
expect(subject.user).to eq 'psegel'
|
296
|
+
end
|
297
|
+
end
|
298
|
+
context "the credentials hash" do
|
299
|
+
subject { last_request.env['omniauth.auth']['credentials'] }
|
300
|
+
it 'contains only the casticket' do
|
301
|
+
expect(subject.size).to eq 1
|
302
|
+
expect(subject.casticket).to eq '593af'
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
describe 'POST /auth/cas/callback' do
|
310
|
+
describe 'with a Single Sign-Out logoutRequest' do
|
311
|
+
let(:logoutRequest) do
|
312
|
+
%Q[
|
313
|
+
<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion\" ID="123abc-1234-ab12-cd34-1234abcd" Version="2.0" IssueInstant="#{Time.now.to_s}">
|
314
|
+
<saml:NameID>@NOT_USED@</saml:NameID>
|
315
|
+
<samlp:SessionIndex>ST-123456-123abc456def</samlp:SessionIndex>
|
316
|
+
</samlp:LogoutRequest>
|
317
|
+
]
|
318
|
+
end
|
319
|
+
|
320
|
+
let(:logout_request) { double('logout_request', call:[200,{},'OK']) }
|
321
|
+
|
322
|
+
subject do
|
323
|
+
post 'auth/cas/callback', logoutRequest:logoutRequest
|
324
|
+
end
|
325
|
+
|
326
|
+
before do
|
327
|
+
allow_any_instance_of(MyCasProvider)
|
328
|
+
.to receive(:logout_request_service)
|
329
|
+
.and_return double('LogoutRequest', new:logout_request)
|
330
|
+
|
331
|
+
subject
|
332
|
+
end
|
333
|
+
|
334
|
+
it 'initializes a LogoutRequest' do
|
335
|
+
expect(logout_request).to have_received :call
|
336
|
+
end
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|