ey_api_hmac 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in ey_api_hmac.gemspec
4
+ gemspec
5
+
6
+ gem 'rake'
7
+
8
+ group :test, :development do
9
+ gem 'halorgium-auth-hmac'
10
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,32 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ ey_api_hmac (0.0.1)
5
+ rack-client
6
+
7
+ GEM
8
+ remote: http://rubygems.org/
9
+ specs:
10
+ diff-lcs (1.1.2)
11
+ halorgium-auth-hmac (1.1.1.2010100601)
12
+ rack (1.3.2)
13
+ rack-client (0.4.0)
14
+ rack (>= 1.0.0)
15
+ rake (0.9.2)
16
+ rspec (2.6.0)
17
+ rspec-core (~> 2.6.0)
18
+ rspec-expectations (~> 2.6.0)
19
+ rspec-mocks (~> 2.6.0)
20
+ rspec-core (2.6.4)
21
+ rspec-expectations (2.6.0)
22
+ diff-lcs (~> 1.1.2)
23
+ rspec-mocks (2.6.0)
24
+
25
+ PLATFORMS
26
+ ruby
27
+
28
+ DEPENDENCIES
29
+ ey_api_hmac!
30
+ halorgium-auth-hmac
31
+ rake
32
+ rspec
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2011 Engine Yard
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ desc 'Default: run specs.'
5
+ task :default => :spec
6
+
7
+ desc "Run specs"
8
+ RSpec::Core::RakeTask.new do |t|
9
+ t.rspec_opts = '--format documentation --color'
10
+ end
@@ -0,0 +1,23 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "ey_api_hmac/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "ey_api_hmac"
7
+ s.version = EY::ApiHMAC::VERSION
8
+ s.authors = ["Jacob Burkhart & Thorben Schröder & David Calavera & others"]
9
+ s.email = ["jacob@engineyard.com"]
10
+ s.homepage = ""
11
+ s.summary = %q{HMAC Rack basic implementation for Engine Yard services}
12
+ s.description = %q{basic wrapper for rack-client + middlewares for HMAC auth + helpers for SSO auth}
13
+
14
+ s.rubyforge_project = "ey_api_hmac"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_dependency 'rack-client'
22
+ s.add_development_dependency "rspec"
23
+ end
@@ -0,0 +1,94 @@
1
+ require 'ey_api_hmac/base_connection'
2
+ require 'ey_api_hmac/api_auth'
3
+
4
+ module EY
5
+ module ApiHMAC
6
+ require 'openssl'
7
+
8
+ def self.sign_for_sso(url, parameters, auth_id, auth_key)
9
+ uri = URI.parse(url)
10
+ raise ArgumentError, "use parameters argument, got query: '#{uri.query}'" if uri.query
11
+ uri.query = parameters.sort.map {|e| e.map{|str| CGI.escape(str.to_s)}.join '='}.join '&'
12
+ signature = CGI.escape(base64digest(uri.query.to_s, auth_key))
13
+ uri.query += "&signature=#{signature}"
14
+ uri.to_s
15
+ end
16
+
17
+ def self.verify_for_sso(url, auth_id, auth_key)
18
+ uri = URI.parse(url)
19
+ signature = CGI.unescape(uri.query.match(/&signature=(.*)$/)[1])
20
+ signed_string = uri.query.gsub(/&signature=(.*)$/,"")
21
+ base64digest(signed_string.to_s, auth_key) == signature
22
+ end
23
+
24
+ def self.sign!(env, key_id, secret, strict = false)
25
+ env["HTTP_AUTHORIZATION"] = "AuthHMAC #{key_id}:#{signature(env, secret, strict)}"
26
+ end
27
+
28
+ def self.canonical_string(env, strict = false)
29
+ parts = []
30
+ adder = Proc.new do |var|
31
+ unless env[var]
32
+ raise HmacAuthFail, "'#{var}' header missing and required in #{env.inspect}"
33
+ end
34
+ parts << env[var]
35
+ end
36
+ adder["REQUEST_METHOD"]
37
+ adder["CONTENT_TYPE"]
38
+ if env["HTTP_CONTENT_MD5"]
39
+ adder["HTTP_CONTENT_MD5"]
40
+ else
41
+ parts << generated_md5(env)
42
+ end
43
+ adder["HTTP_DATE"]
44
+ adder["PATH_INFO"]
45
+ parts.join("\n")
46
+ end
47
+
48
+ def self.signature(env, secret, strict = false)
49
+ base64digest(canonical_string(env, strict), secret)
50
+ end
51
+
52
+ def self.base64digest(data,secret)
53
+ digest = OpenSSL::Digest::Digest.new('sha1')
54
+ [OpenSSL::HMAC.digest(digest, secret, data)].pack('m').strip
55
+ end
56
+
57
+ class HmacAuthFail < StandardError; end
58
+
59
+ def self.authenticate!(env, &lookup)
60
+ rx = Regexp.new("AuthHMAC ([^:]+):(.+)$")
61
+ if md = rx.match(env["HTTP_AUTHORIZATION"])
62
+ access_key_id = md[1]
63
+ hmac = md[2]
64
+ secret = lookup.call(access_key_id)
65
+ unless secret
66
+ raise HmacAuthFail, "couldn't find auth for #{access_key_id}"
67
+ end
68
+ unless hmac == signature(env, secret)
69
+ raise HmacAuthFail, "signature mismatch"
70
+ end
71
+ else
72
+ raise HmacAuthFail, "no authorization header"
73
+ end
74
+ end
75
+
76
+ def self.authenticated?(env, &lookup)
77
+ begin
78
+ authenticate!(env, &lookup)
79
+ true
80
+ rescue HmacAuthFail => e
81
+ false
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def self.generated_md5(env)
88
+ request_body = env["rack.input"].read
89
+ env["rack.input"].rewind
90
+ OpenSSL::Digest::MD5.hexdigest(request_body)
91
+ end
92
+
93
+ end
94
+ end
@@ -0,0 +1,49 @@
1
+ module EY
2
+ module ApiHMAC
3
+ module ApiAuth
4
+ CONSUMER = "ey_api_hmac.consumer_id"
5
+
6
+ #a Server middleware to validate requests, setup with a block to lookup the auth_key based on auth_id
7
+ class LookupServer
8
+ def initialize(app, &lookup)
9
+ @app, @lookup = app, lookup
10
+ end
11
+
12
+ #TODO: rescue HmacAuthFail and return 403?
13
+ def call(env)
14
+ ApiHMAC.authenticate!(env) do |auth_id|
15
+ @lookup.call(env, auth_id)
16
+ end
17
+ @app.call(env)
18
+ end
19
+ end
20
+
21
+ #a Server middleware to validate requests, setup with a class that responds_to :find_by_auth_id, :id, :auth_key
22
+ class Server < LookupServer
23
+ def initialize(app, klass)
24
+ lookup = Proc.new do |env, auth_id|
25
+ if consumer = klass.find_by_auth_id(auth_id)
26
+ env[CONSUMER] = consumer.id
27
+ consumer.auth_key
28
+ else
29
+ raise "no #{klass} consumer #{auth_id.inspect}"
30
+ end
31
+ end
32
+ super(app, &lookup)
33
+ end
34
+ end
35
+
36
+ #the Client middleware that's used to add authentication to requests
37
+ class Client
38
+ def initialize(app, auth_id, auth_key)
39
+ @app, @auth_id, @auth_key = app, auth_id, auth_key
40
+ end
41
+ def call(env)
42
+ ApiHMAC.sign!(env, @auth_id, @auth_key)
43
+ @app.call(env)
44
+ end
45
+ end
46
+
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,103 @@
1
+ require 'rack/client'
2
+ require 'json'
3
+
4
+ module EY
5
+ module ApiHMAC
6
+ class BaseConnection
7
+ attr_reader :auth_id, :auth_key
8
+
9
+ def initialize(auth_id, auth_key, user_agent = nil)
10
+ @auth_id = auth_id
11
+ @auth_key = auth_key
12
+ @standard_headers = {
13
+ 'CONTENT_TYPE' => 'application/json',
14
+ 'Accept'=> 'application/json',
15
+ "Date" => Time.now.to_s, #TODO: what is the right way to format this header?
16
+ 'USER_AGENT' => user_agent || default_user_agent
17
+ }
18
+ end
19
+
20
+ class NotFound < StandardError
21
+ def initialize(url)
22
+ super("#{url} not found")
23
+ end
24
+ end
25
+
26
+ class ValidationError < StandardError
27
+ attr_reader :error_messages
28
+
29
+ def initialize(response)
30
+ json_response = JSON.parse(response.body)
31
+ @error_messages = json_response["error_messages"]
32
+ super("error: #{@error_messages.join("\n")}")
33
+ rescue => e
34
+ @error_messages = []
35
+ super("error: #{response.body}")
36
+ end
37
+ end
38
+
39
+ class UnknownError < StandardError
40
+ def initialize(response)
41
+ super("unknown error(#{response.status}): #{response.body}")
42
+ end
43
+ end
44
+
45
+ attr_writer :backend
46
+ def backend
47
+ @backend ||= Rack::Client::Handler::NetHTTP
48
+ end
49
+
50
+ def post(url, body, &block)
51
+ request(:post, url, body, &block)
52
+ end
53
+
54
+ def put(url, body, &block)
55
+ request(:put, url, body, &block)
56
+ end
57
+
58
+ def delete(url, &block)
59
+ request(:delete, url, &block)
60
+ end
61
+
62
+ def get(url, &block)
63
+ request(:get, url, &block)
64
+ end
65
+
66
+ protected
67
+
68
+ def client
69
+ bak = self.backend
70
+ #damn you scope!
71
+ auth_id_arg = auth_id
72
+ auth_key_arg = auth_key
73
+ @client ||= Rack::Client.new do
74
+ use EY::ApiHMAC::ApiAuth::Client, auth_id_arg, auth_key_arg
75
+ run bak
76
+ end
77
+ end
78
+
79
+ def request(method, url, body = nil, &block)
80
+ if body
81
+ response = client.send(method, url, @standard_headers, body.to_json)
82
+ else
83
+ response = client.send(method, url, @standard_headers)
84
+ end
85
+ handle_response(url, response, &block)
86
+ end
87
+
88
+ def handle_response(url, response)
89
+ case response.status
90
+ when 200, 201
91
+ json_body = JSON.parse(response.body)
92
+ yield json_body, response["Location"] if block_given?
93
+ when 404
94
+ raise NotFound.new(url)
95
+ when 400
96
+ raise ValidationError.new(response)
97
+ else
98
+ raise UnknownError.new(response)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,5 @@
1
+ module EY
2
+ module ApiHMAC
3
+ VERSION = "0.0.1"
4
+ end
5
+ end
@@ -0,0 +1,131 @@
1
+ require 'ey_api_hmac'
2
+ require 'auth-hmac'
3
+
4
+ describe EY::ApiHMAC::ApiAuth do
5
+
6
+ #TODO: reject requests with old dates?
7
+
8
+ describe "AuthHMAC working" do
9
+
10
+ before(:each) do
11
+ @env = {'PATH_INFO' => "/path/to/put",
12
+ 'QUERY_STRING' => 'foo=bar&bar=foo',
13
+ 'CONTENT_TYPE' => 'text/plain',
14
+ 'HTTP_CONTENT_MD5' => 'blahblah',
15
+ 'REQUEST_METHOD' => "PUT",
16
+ 'HTTP_DATE' => "Thu, 10 Jul 2008 03:29:56 GMT",
17
+ "rack.input" => StringIO.new}
18
+ @request = Rack::Request.new(@env)
19
+ end
20
+
21
+ describe ".canonical_string" do
22
+ it "should generate a canonical string using default method" do
23
+ expected = "PUT\ntext/plain\nblahblah\nThu, 10 Jul 2008 03:29:56 GMT\n/path/to/put"
24
+ AuthHMAC.canonical_string(@request).should == expected
25
+ EY::ApiHMAC.canonical_string(@env).should == expected
26
+ end
27
+ end
28
+
29
+ describe ".signature" do
30
+ it "should generate a valid signature string for a secret" do
31
+ expected = "71wAJM4IIu/3o6lcqx/tw7XnAJs="
32
+ AuthHMAC.signature(@request, 'secret').should == expected
33
+ EY::ApiHMAC.signature(@env, 'secret').should == expected
34
+ end
35
+ end
36
+
37
+ describe "sign!" do
38
+ before do
39
+ @expected = "AuthHMAC my-key-id:71wAJM4IIu/3o6lcqx/tw7XnAJs="
40
+ end
41
+
42
+ it "signs as expected with AuthHMAC" do
43
+ AuthHMAC.sign!(@request, "my-key-id", "secret")
44
+ @request['Authorization'].should == @expected
45
+ end
46
+
47
+ it "signs as expected with ApiAuth" do
48
+ EY::ApiHMAC.sign!(@env, 'my-key-id', 'secret')
49
+ @env["HTTP_AUTHORIZATION"].should == @expected
50
+ end
51
+
52
+ end
53
+
54
+ describe "authenticated?" do
55
+ describe "request signed by AuthHMAC" do
56
+ before do
57
+ AuthHMAC.sign!(@request, 'access key 1', 'secret')
58
+ @env["HTTP_AUTHORIZATION"] = @request["Authorization"]
59
+ end
60
+
61
+ it "verifies by ApiAuth" do
62
+ @lookup = Proc.new{ |key| 'secret' if key == 'access key 1' }
63
+ EY::ApiHMAC.authenticated?(@env, &@lookup).should be_true
64
+ end
65
+
66
+ it "verifies by AuthHMAC" do
67
+ @authhmac = AuthHMAC.new({"access key 1" => 'secret'})
68
+ @authhmac.authenticated?(@request).should be_true
69
+ end
70
+ end
71
+ describe "request signed by ApiAuth" do
72
+ before do
73
+ EY::ApiHMAC.sign!(@env, 'access key 1', 'secret')
74
+ end
75
+
76
+ it "verifies by ApiAuth" do
77
+ @lookup = Proc.new{ |key| 'secret' if key == 'access key 1' }
78
+ EY::ApiHMAC.authenticated?(@env, &@lookup).should be_true
79
+ end
80
+
81
+ it "verifies by AuthHMAC" do
82
+ @authhmac = AuthHMAC.new({"access key 1" => 'secret'})
83
+ @authhmac.authenticated?(@request).should be_true
84
+ end
85
+ end
86
+ end
87
+
88
+ end
89
+
90
+ describe "without CONTENT_MD5" do
91
+ before do
92
+ @env = {'PATH_INFO' => "/path/to/put",
93
+ 'QUERY_STRING' => 'foo=bar&bar=foo',
94
+ 'CONTENT_TYPE' => 'text/plain',
95
+ 'REQUEST_METHOD' => "PUT",
96
+ 'HTTP_DATE' => "Thu, 10 Jul 2008 03:29:56 GMT",
97
+ "rack.input" => StringIO.new("something, something?")}
98
+ @request = Rack::Request.new(@env)
99
+ end
100
+
101
+ describe "sign!" do
102
+ before do
103
+ @expected = "AuthHMAC my-key-id:YzKgetuk8Tkz19c4eUqbfg4QrFg="
104
+ end
105
+
106
+ it "signs as expected with AuthHMAC" do
107
+ AuthHMAC.sign!(@request, "my-key-id", "secret")
108
+ @request['Authorization'].should == @expected
109
+ end
110
+
111
+ it "signs as expected with ApiAuth" do
112
+ EY::ApiHMAC.sign!(@env, 'my-key-id', 'secret')
113
+ @env["HTTP_AUTHORIZATION"].should == @expected
114
+ end
115
+
116
+ end
117
+
118
+ end
119
+
120
+ it "complains when there is no HTTP_DATE" do
121
+ env = {'PATH_INFO' => "/path/to/put",
122
+ 'QUERY_STRING' => 'foo=bar&bar=foo',
123
+ 'CONTENT_TYPE' => 'text/plain',
124
+ 'REQUEST_METHOD' => "PUT",
125
+ "rack.input" => StringIO.new}
126
+ lambda{
127
+ EY::ApiHMAC.sign!(env, 'my-key-id', 'secret', true)
128
+ }.should raise_error(/'HTTP_DATE' header missing and required/)
129
+ end
130
+
131
+ end
data/spec/sso_spec.rb ADDED
@@ -0,0 +1,62 @@
1
+ require 'ey_api_hmac'
2
+ require 'cgi'
3
+
4
+
5
+ describe EY::ApiHMAC do
6
+
7
+ describe "SSO" do
8
+ before do
9
+ @url = 'http://example.com/sign_test'
10
+ @parameters = {
11
+ "foo" => "bar",
12
+ "zarg" => "boot",
13
+ "xargs" => 5
14
+ }
15
+ @auth_id = "3243afed3242"
16
+ @auth_key = "987a87c98f78d9a8c798f7d89"
17
+ end
18
+
19
+ it "can sign sso calls" do
20
+ signed_url = EY::ApiHMAC.sign_for_sso(@url, @parameters, @auth_id, @auth_key)
21
+ uri = URI.parse(signed_url)
22
+
23
+ uri.scheme.should eq 'http'
24
+ uri.host.should eq 'example.com'
25
+ uri.path.should eq '/sign_test'
26
+
27
+ parameters = CGI::parse(uri.query)
28
+ parameters["signature"].first.should eq EY::ApiHMAC.base64digest("foo=bar&xargs=5&zarg=boot", @auth_key)
29
+ end
30
+
31
+ it "can verify signed requests" do
32
+ signed_url = EY::ApiHMAC.sign_for_sso(@url, @parameters, @auth_id, @auth_key)
33
+ EY::ApiHMAC.verify_for_sso(signed_url, @auth_id, @auth_key).should be_true
34
+ EY::ApiHMAC.verify_for_sso(signed_url + 'a', @auth_id, @auth_key).should be_false
35
+ end
36
+
37
+ #TODO: write a test that fails if we skip the CGI.unescape
38
+
39
+ #TODO: provide signature methods
40
+
41
+ #TODO: test that you get an error when you try to sign a url with any of the "Reserved" parameters (signature or timestamp)
42
+
43
+ #TODO: send the auth_id in the params too
44
+
45
+ #TODO: Rename "signature" to "ey_api_sso_hmac_signature"
46
+
47
+ #TODO: provide a time
48
+
49
+ #TODO: maybe an expiry time would be better
50
+
51
+ #TODO: should the other params be part of the gem?
52
+ # ey_user_id – the unique identifier for the user.
53
+ # ey_user_name – the full name of the user in plain text. Example: “John Doe”.
54
+ # access_level – either “owner” or “collaborator”.
55
+ # ey_return_to_url – the url to be used when sending the user back to EY.
56
+ # timestamp – time the signature was calculated, URL should be considered invalid is timestamp is more than 5 minutes off.
57
+ # signature_method – hash method used to generate the signature
58
+ # signature – HMAC digest of the other parameters and url (using the API secret)
59
+
60
+ end
61
+
62
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ey_api_hmac
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - "Jacob Burkhart & Thorben Schr\xC3\xB6der & David Calavera & others"
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-08-15 00:00:00 -07:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: rack-client
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 3
30
+ segments:
31
+ - 0
32
+ version: "0"
33
+ type: :runtime
34
+ version_requirements: *id001
35
+ - !ruby/object:Gem::Dependency
36
+ name: rspec
37
+ prerelease: false
38
+ requirement: &id002 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ hash: 3
44
+ segments:
45
+ - 0
46
+ version: "0"
47
+ type: :development
48
+ version_requirements: *id002
49
+ description: basic wrapper for rack-client + middlewares for HMAC auth + helpers for SSO auth
50
+ email:
51
+ - jacob@engineyard.com
52
+ executables: []
53
+
54
+ extensions: []
55
+
56
+ extra_rdoc_files: []
57
+
58
+ files:
59
+ - Gemfile
60
+ - Gemfile.lock
61
+ - LICENSE
62
+ - Rakefile
63
+ - ey_api_hmac.gemspec
64
+ - lib/ey_api_hmac.rb
65
+ - lib/ey_api_hmac/api_auth.rb
66
+ - lib/ey_api_hmac/base_connection.rb
67
+ - lib/ey_api_hmac/version.rb
68
+ - spec/api_auth_spec.rb
69
+ - spec/sso_spec.rb
70
+ has_rdoc: true
71
+ homepage: ""
72
+ licenses: []
73
+
74
+ post_install_message:
75
+ rdoc_options: []
76
+
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ none: false
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ hash: 3
85
+ segments:
86
+ - 0
87
+ version: "0"
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ hash: 3
94
+ segments:
95
+ - 0
96
+ version: "0"
97
+ requirements: []
98
+
99
+ rubyforge_project: ey_api_hmac
100
+ rubygems_version: 1.5.2
101
+ signing_key:
102
+ specification_version: 3
103
+ summary: HMAC Rack basic implementation for Engine Yard services
104
+ test_files:
105
+ - spec/api_auth_spec.rb
106
+ - spec/sso_spec.rb