songkick-oauth2-provider 0.10.0
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.
- data/README.rdoc +394 -0
- data/example/README.rdoc +11 -0
- data/example/application.rb +159 -0
- data/example/config.ru +3 -0
- data/example/environment.rb +11 -0
- data/example/models/connection.rb +9 -0
- data/example/models/note.rb +4 -0
- data/example/models/user.rb +6 -0
- data/example/public/style.css +78 -0
- data/example/schema.rb +27 -0
- data/example/views/authorize.erb +28 -0
- data/example/views/create_user.erb +3 -0
- data/example/views/error.erb +6 -0
- data/example/views/home.erb +25 -0
- data/example/views/layout.erb +25 -0
- data/example/views/login.erb +20 -0
- data/example/views/new_client.erb +25 -0
- data/example/views/new_user.erb +22 -0
- data/example/views/show_client.erb +15 -0
- data/lib/songkick/oauth2/model.rb +20 -0
- data/lib/songkick/oauth2/model/authorization.rb +126 -0
- data/lib/songkick/oauth2/model/client.rb +61 -0
- data/lib/songkick/oauth2/model/client_owner.rb +15 -0
- data/lib/songkick/oauth2/model/hashing.rb +29 -0
- data/lib/songkick/oauth2/model/resource_owner.rb +54 -0
- data/lib/songkick/oauth2/provider.rb +122 -0
- data/lib/songkick/oauth2/provider/access_token.rb +68 -0
- data/lib/songkick/oauth2/provider/authorization.rb +190 -0
- data/lib/songkick/oauth2/provider/error.rb +22 -0
- data/lib/songkick/oauth2/provider/exchange.rb +227 -0
- data/lib/songkick/oauth2/router.rb +79 -0
- data/lib/songkick/oauth2/schema.rb +17 -0
- data/lib/songkick/oauth2/schema/20120828112156_songkick_oauth2_schema_original_schema.rb +36 -0
- data/spec/factories.rb +27 -0
- data/spec/request_helpers.rb +52 -0
- data/spec/songkick/oauth2/model/authorization_spec.rb +216 -0
- data/spec/songkick/oauth2/model/client_spec.rb +55 -0
- data/spec/songkick/oauth2/model/resource_owner_spec.rb +88 -0
- data/spec/songkick/oauth2/provider/access_token_spec.rb +125 -0
- data/spec/songkick/oauth2/provider/authorization_spec.rb +346 -0
- data/spec/songkick/oauth2/provider/exchange_spec.rb +353 -0
- data/spec/songkick/oauth2/provider_spec.rb +545 -0
- data/spec/spec_helper.rb +62 -0
- data/spec/test_app/helper.rb +33 -0
- data/spec/test_app/provider/application.rb +68 -0
- data/spec/test_app/provider/views/authorize.erb +19 -0
- metadata +273 -0
@@ -0,0 +1,22 @@
|
|
1
|
+
module Songkick
|
2
|
+
module OAuth2
|
3
|
+
class Provider
|
4
|
+
|
5
|
+
class Error
|
6
|
+
def initialize(message = nil)
|
7
|
+
@message = message
|
8
|
+
end
|
9
|
+
|
10
|
+
def error
|
11
|
+
INVALID_REQUEST
|
12
|
+
end
|
13
|
+
|
14
|
+
def error_description
|
15
|
+
'Bad request' + (@message ? ": #{@message}" : '')
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
@@ -0,0 +1,227 @@
|
|
1
|
+
module Songkick
|
2
|
+
module OAuth2
|
3
|
+
class Provider
|
4
|
+
|
5
|
+
class Exchange
|
6
|
+
attr_reader :client, :error, :error_description
|
7
|
+
|
8
|
+
REQUIRED_PARAMS = [CLIENT_ID, CLIENT_SECRET, GRANT_TYPE]
|
9
|
+
VALID_GRANT_TYPES = [AUTHORIZATION_CODE, PASSWORD, ASSERTION, REFRESH_TOKEN]
|
10
|
+
|
11
|
+
REQUIRED_PASSWORD_PARAMS = [USERNAME, PASSWORD]
|
12
|
+
REQUIRED_ASSERTION_PARAMS = [ASSERTION_TYPE, ASSERTION]
|
13
|
+
|
14
|
+
RESPONSE_HEADERS = {
|
15
|
+
'Cache-Control' => 'no-store',
|
16
|
+
'Content-Type' => 'application/json'
|
17
|
+
}
|
18
|
+
|
19
|
+
def initialize(resource_owner, params, transport_error = nil)
|
20
|
+
@params = params
|
21
|
+
@scope = params[SCOPE]
|
22
|
+
@grant_type = @params[GRANT_TYPE]
|
23
|
+
|
24
|
+
@transport_error = transport_error
|
25
|
+
|
26
|
+
validate!
|
27
|
+
end
|
28
|
+
|
29
|
+
def owner
|
30
|
+
@authorization && @authorization.owner
|
31
|
+
end
|
32
|
+
|
33
|
+
def redirect?
|
34
|
+
false
|
35
|
+
end
|
36
|
+
|
37
|
+
def response_body
|
38
|
+
return jsonize(ERROR, ERROR_DESCRIPTION) unless valid?
|
39
|
+
update_authorization
|
40
|
+
|
41
|
+
response = {}
|
42
|
+
[ACCESS_TOKEN, REFRESH_TOKEN, SCOPE].each do |key|
|
43
|
+
value = @authorization.__send__(key)
|
44
|
+
response[key] = value if value
|
45
|
+
end
|
46
|
+
if expiry = @authorization.expires_in
|
47
|
+
response[EXPIRES_IN] = expiry
|
48
|
+
end
|
49
|
+
|
50
|
+
JSON.unparse(response)
|
51
|
+
end
|
52
|
+
|
53
|
+
def response_headers
|
54
|
+
RESPONSE_HEADERS
|
55
|
+
end
|
56
|
+
|
57
|
+
def response_status
|
58
|
+
valid? ? 200 : 400
|
59
|
+
end
|
60
|
+
|
61
|
+
def scopes
|
62
|
+
scopes = @scope ? @scope.split(/\s+/).delete_if { |s| s.empty? } : []
|
63
|
+
Set.new(scopes)
|
64
|
+
end
|
65
|
+
|
66
|
+
def update_authorization
|
67
|
+
return if not valid? or @already_updated
|
68
|
+
@authorization.exchange!
|
69
|
+
@already_updated = true
|
70
|
+
end
|
71
|
+
|
72
|
+
def valid?
|
73
|
+
@error.nil?
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def jsonize(*ivars)
|
79
|
+
hash = {}
|
80
|
+
ivars.each { |key| hash[key] = instance_variable_get("@#{key}") }
|
81
|
+
JSON.unparse(hash)
|
82
|
+
end
|
83
|
+
|
84
|
+
def validate!
|
85
|
+
if @transport_error
|
86
|
+
@error = @transport_error.error
|
87
|
+
@error_description = @transport_error.error_description
|
88
|
+
return
|
89
|
+
end
|
90
|
+
|
91
|
+
validate_required_params
|
92
|
+
|
93
|
+
return if @error
|
94
|
+
validate_client
|
95
|
+
|
96
|
+
unless VALID_GRANT_TYPES.include?(@grant_type)
|
97
|
+
@error = UNSUPPORTED_GRANT_TYPE
|
98
|
+
@error_description = "The grant type #{@grant_type} is not recognized"
|
99
|
+
end
|
100
|
+
return if @error
|
101
|
+
|
102
|
+
__send__("validate_#{@grant_type}")
|
103
|
+
validate_scope
|
104
|
+
end
|
105
|
+
|
106
|
+
def validate_required_params
|
107
|
+
REQUIRED_PARAMS.each do |param|
|
108
|
+
next if @params.has_key?(param)
|
109
|
+
@error = INVALID_REQUEST
|
110
|
+
@error_description = "Missing required parameter #{param}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def validate_client
|
115
|
+
@client = Model::Client.find_by_client_id(@params[CLIENT_ID])
|
116
|
+
unless @client
|
117
|
+
@error = INVALID_CLIENT
|
118
|
+
@error_description = "Unknown client ID #{@params[CLIENT_ID]}"
|
119
|
+
end
|
120
|
+
|
121
|
+
if @client and not @client.valid_client_secret?(@params[CLIENT_SECRET])
|
122
|
+
@error = INVALID_CLIENT
|
123
|
+
@error_description = 'Parameter client_secret does not match'
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def validate_scope
|
128
|
+
if @authorization and not @authorization.in_scope?(scopes)
|
129
|
+
@error = INVALID_SCOPE
|
130
|
+
@error_description = 'The request scope was never granted by the user'
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def validate_authorization_code
|
135
|
+
unless @params[CODE]
|
136
|
+
@error = INVALID_REQUEST
|
137
|
+
@error_description = "Missing required parameter code"
|
138
|
+
end
|
139
|
+
|
140
|
+
if @client.redirect_uri and @client.redirect_uri != @params[REDIRECT_URI]
|
141
|
+
@error = REDIRECT_MISMATCH
|
142
|
+
@error_description = "Parameter redirect_uri does not match registered URI"
|
143
|
+
end
|
144
|
+
|
145
|
+
unless @params.has_key?(REDIRECT_URI)
|
146
|
+
@error = INVALID_REQUEST
|
147
|
+
@error_description = "Missing required parameter redirect_uri"
|
148
|
+
end
|
149
|
+
|
150
|
+
return if @error
|
151
|
+
|
152
|
+
@authorization = @client.authorizations.find_by_code(@params[CODE])
|
153
|
+
validate_authorization
|
154
|
+
end
|
155
|
+
|
156
|
+
def validate_password
|
157
|
+
REQUIRED_PASSWORD_PARAMS.each do |param|
|
158
|
+
next if @params.has_key?(param)
|
159
|
+
@error = INVALID_REQUEST
|
160
|
+
@error_description = "Missing required parameter #{param}"
|
161
|
+
end
|
162
|
+
|
163
|
+
return if @error
|
164
|
+
|
165
|
+
@authorization = Provider.handle_password(@client, @params[USERNAME], @params[PASSWORD], scopes)
|
166
|
+
return validate_authorization if @authorization
|
167
|
+
|
168
|
+
@error = INVALID_GRANT
|
169
|
+
@error_description = 'The access grant you supplied is invalid'
|
170
|
+
end
|
171
|
+
|
172
|
+
def validate_assertion
|
173
|
+
REQUIRED_ASSERTION_PARAMS.each do |param|
|
174
|
+
next if @params.has_key?(param)
|
175
|
+
@error = INVALID_REQUEST
|
176
|
+
@error_description = "Missing required parameter #{param}"
|
177
|
+
end
|
178
|
+
|
179
|
+
if @params[ASSERTION_TYPE]
|
180
|
+
uri = URI.parse(@params[ASSERTION_TYPE]) rescue nil
|
181
|
+
unless uri and uri.absolute?
|
182
|
+
@error = INVALID_REQUEST
|
183
|
+
@error_description = 'Parameter assertion_type must be an absolute URI'
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
return if @error
|
188
|
+
|
189
|
+
assertion = Assertion.new(@params)
|
190
|
+
@authorization = Provider.handle_assertion(@client, assertion, scopes)
|
191
|
+
return validate_authorization if @authorization
|
192
|
+
|
193
|
+
@error = UNAUTHORIZED_CLIENT
|
194
|
+
@error_description = 'Client cannot use the given assertion type'
|
195
|
+
end
|
196
|
+
|
197
|
+
def validate_refresh_token
|
198
|
+
refresh_token_hash = Songkick::OAuth2.hashify(@params[REFRESH_TOKEN])
|
199
|
+
@authorization = @client.authorizations.find_by_refresh_token_hash(refresh_token_hash)
|
200
|
+
validate_authorization
|
201
|
+
end
|
202
|
+
|
203
|
+
def validate_authorization
|
204
|
+
unless @authorization
|
205
|
+
@error = INVALID_GRANT
|
206
|
+
@error_description = 'The access grant you supplied is invalid'
|
207
|
+
end
|
208
|
+
|
209
|
+
if @authorization and @authorization.expired?
|
210
|
+
@error = INVALID_GRANT
|
211
|
+
@error_description = 'The access grant you supplied is invalid'
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
class Assertion
|
217
|
+
attr_reader :type, :value
|
218
|
+
def initialize(params)
|
219
|
+
@type = params[ASSERTION_TYPE]
|
220
|
+
@value = params[ASSERTION]
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'rack'
|
3
|
+
|
4
|
+
module Songkick
|
5
|
+
module OAuth2
|
6
|
+
class Router
|
7
|
+
|
8
|
+
# Public methods in the namespace take either Rack env objects, or Request
|
9
|
+
# objects from Rails/Sinatra and an optional params hash which it then
|
10
|
+
# coerces to Rack requests. This is for backward compatibility; originally
|
11
|
+
# it only took request objects.
|
12
|
+
|
13
|
+
class << self
|
14
|
+
def parse(resource_owner, env)
|
15
|
+
error = detect_transport_error(env)
|
16
|
+
request = request_from(env)
|
17
|
+
params = request.params
|
18
|
+
auth = auth_params(env)
|
19
|
+
|
20
|
+
if auth[CLIENT_ID] and auth[CLIENT_ID] != params[CLIENT_ID]
|
21
|
+
error ||= Provider::Error.new("#{CLIENT_ID} from Basic Auth and request body do not match")
|
22
|
+
end
|
23
|
+
|
24
|
+
params = params.merge(auth)
|
25
|
+
|
26
|
+
if params[GRANT_TYPE]
|
27
|
+
error ||= Provider::Error.new('must be a POST request') unless request.post?
|
28
|
+
Provider::Exchange.new(resource_owner, params, error)
|
29
|
+
else
|
30
|
+
Provider::Authorization.new(resource_owner, params, error)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def access_token(resource_owner, scopes, env)
|
35
|
+
access_token = access_token_from_request(env)
|
36
|
+
Provider::AccessToken.new(resource_owner,
|
37
|
+
scopes,
|
38
|
+
access_token,
|
39
|
+
detect_transport_error(env))
|
40
|
+
end
|
41
|
+
|
42
|
+
def access_token_from_request(env)
|
43
|
+
request = request_from(env)
|
44
|
+
params = request.params
|
45
|
+
header = request.env['HTTP_AUTHORIZATION']
|
46
|
+
|
47
|
+
header && header =~ /^OAuth\s+/ ?
|
48
|
+
header.gsub(/^OAuth\s+/, '') :
|
49
|
+
params[OAUTH_TOKEN]
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def request_from(env_or_request)
|
55
|
+
env = env_or_request.respond_to?(:env) ? env_or_request.env : env_or_request
|
56
|
+
env = Rack::MockRequest.env_for(env['REQUEST_URI'] || '', :input => env['RAW_POST_DATA']).merge(env)
|
57
|
+
Rack::Request.new(env)
|
58
|
+
end
|
59
|
+
|
60
|
+
def auth_params(env)
|
61
|
+
return {} unless basic = env['HTTP_AUTHORIZATION']
|
62
|
+
parts = basic.split(/\s+/)
|
63
|
+
username, password = Base64.decode64(parts.last).split(':')
|
64
|
+
{CLIENT_ID => username, CLIENT_SECRET => password}
|
65
|
+
end
|
66
|
+
|
67
|
+
def detect_transport_error(env)
|
68
|
+
request = request_from(env)
|
69
|
+
|
70
|
+
if Provider.enforce_ssl and not request.ssl?
|
71
|
+
Provider::Error.new("must make requests using HTTPS")
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Songkick
|
2
|
+
module OAuth2
|
3
|
+
|
4
|
+
class Schema
|
5
|
+
def self.up
|
6
|
+
ActiveRecord::Base.logger ||= Logger.new(StringIO.new)
|
7
|
+
ActiveRecord::Migrator.up(migrations_path)
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.migrations_path
|
11
|
+
File.expand_path('../schema', __FILE__)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
@@ -0,0 +1,36 @@
|
|
1
|
+
class SongkickOauth2SchemaOriginalSchema < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :oauth2_clients do |t|
|
4
|
+
t.timestamps
|
5
|
+
t.string :oauth2_client_owner_type
|
6
|
+
t.integer :oauth2_client_owner_id
|
7
|
+
t.string :name
|
8
|
+
t.string :client_id
|
9
|
+
t.string :client_secret_hash
|
10
|
+
t.string :redirect_uri
|
11
|
+
end
|
12
|
+
add_index :oauth2_clients, :client_id
|
13
|
+
|
14
|
+
create_table :oauth2_authorizations do |t|
|
15
|
+
t.timestamps
|
16
|
+
t.string :oauth2_resource_owner_type
|
17
|
+
t.integer :oauth2_resource_owner_id
|
18
|
+
t.belongs_to :client
|
19
|
+
t.string :scope
|
20
|
+
t.string :code, :limit => 40
|
21
|
+
t.string :access_token_hash, :limit => 40
|
22
|
+
t.string :refresh_token_hash, :limit => 40
|
23
|
+
t.datetime :expires_at
|
24
|
+
end
|
25
|
+
add_index :oauth2_authorizations, [:client_id, :code]
|
26
|
+
add_index :oauth2_authorizations, [:access_token_hash]
|
27
|
+
add_index :oauth2_authorizations, [:client_id, :access_token_hash]
|
28
|
+
add_index :oauth2_authorizations, [:client_id, :refresh_token_hash]
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.down
|
32
|
+
drop_table :oauth2_clients
|
33
|
+
drop_table :oauth2_authorizations
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
data/spec/factories.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'factory_girl'
|
2
|
+
|
3
|
+
Factory.sequence :client_name do |n|
|
4
|
+
"Client ##{n}"
|
5
|
+
end
|
6
|
+
|
7
|
+
Factory.sequence :user_name do |n|
|
8
|
+
"User ##{n}"
|
9
|
+
end
|
10
|
+
|
11
|
+
Factory.define :owner, :class => TestApp::User do |u|
|
12
|
+
u.name { Factory.next :user_name }
|
13
|
+
end
|
14
|
+
|
15
|
+
Factory.define :client, :class => Songkick::OAuth2::Model::Client do |c|
|
16
|
+
c.client_id { Songkick::OAuth2.random_string }
|
17
|
+
c.client_secret { Songkick::OAuth2.random_string }
|
18
|
+
c.name { Factory.next :client_name }
|
19
|
+
c.redirect_uri 'https://client.example.com/cb'
|
20
|
+
end
|
21
|
+
|
22
|
+
Factory.define :authorization, :class => Songkick::OAuth2::Model::Authorization do |ac|
|
23
|
+
ac.client Factory(:client)
|
24
|
+
ac.code { Songkick::OAuth2.random_string }
|
25
|
+
ac.expires_at nil
|
26
|
+
end
|
27
|
+
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module RequestHelpers
|
2
|
+
require 'net/http'
|
3
|
+
|
4
|
+
def get(query_params)
|
5
|
+
qs = params.map { |k,v| "#{ CGI.escape k.to_s }=#{ CGI.escape v.to_s }" }.join('&')
|
6
|
+
uri = URI.parse('http://localhost:8000/authorize?' + qs)
|
7
|
+
Net::HTTP.get_response(uri)
|
8
|
+
end
|
9
|
+
|
10
|
+
def allow_or_deny(query_params)
|
11
|
+
Net::HTTP.post_form(URI.parse('http://localhost:8000/allow'), query_params)
|
12
|
+
end
|
13
|
+
|
14
|
+
def post_basic_auth(auth_params, query_params)
|
15
|
+
url = "http://#{ auth_params['client_id'] }:#{ auth_params['client_secret'] }@localhost:8000/authorize"
|
16
|
+
Net::HTTP.post_form(URI.parse(url), query_params)
|
17
|
+
end
|
18
|
+
|
19
|
+
def post(query_params)
|
20
|
+
Net::HTTP.post_form(URI.parse('http://localhost:8000/authorize'), query_params)
|
21
|
+
end
|
22
|
+
|
23
|
+
def validate_response(response, status, body)
|
24
|
+
response.code.to_i.should == status
|
25
|
+
response.body.should == body
|
26
|
+
response['Cache-Control'].should == 'no-store'
|
27
|
+
end
|
28
|
+
|
29
|
+
def validate_json_response(response, status, body)
|
30
|
+
response.code.to_i.should == status
|
31
|
+
JSON.parse(response.body).should == body
|
32
|
+
response['Content-Type'].should == 'application/json'
|
33
|
+
response['Cache-Control'].should == 'no-store'
|
34
|
+
end
|
35
|
+
|
36
|
+
def mock_request(request_class, stubs = {})
|
37
|
+
mock_request = mock(request_class)
|
38
|
+
method_stubs = {
|
39
|
+
:redirect? => false,
|
40
|
+
:response_body => nil,
|
41
|
+
:response_headers => {},
|
42
|
+
:response_status => 200
|
43
|
+
}.merge(stubs)
|
44
|
+
|
45
|
+
method_stubs.each do |method, value|
|
46
|
+
mock_request.should_receive(method).and_return(value)
|
47
|
+
end
|
48
|
+
|
49
|
+
mock_request
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|