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.
Files changed (47) hide show
  1. data/README.rdoc +394 -0
  2. data/example/README.rdoc +11 -0
  3. data/example/application.rb +159 -0
  4. data/example/config.ru +3 -0
  5. data/example/environment.rb +11 -0
  6. data/example/models/connection.rb +9 -0
  7. data/example/models/note.rb +4 -0
  8. data/example/models/user.rb +6 -0
  9. data/example/public/style.css +78 -0
  10. data/example/schema.rb +27 -0
  11. data/example/views/authorize.erb +28 -0
  12. data/example/views/create_user.erb +3 -0
  13. data/example/views/error.erb +6 -0
  14. data/example/views/home.erb +25 -0
  15. data/example/views/layout.erb +25 -0
  16. data/example/views/login.erb +20 -0
  17. data/example/views/new_client.erb +25 -0
  18. data/example/views/new_user.erb +22 -0
  19. data/example/views/show_client.erb +15 -0
  20. data/lib/songkick/oauth2/model.rb +20 -0
  21. data/lib/songkick/oauth2/model/authorization.rb +126 -0
  22. data/lib/songkick/oauth2/model/client.rb +61 -0
  23. data/lib/songkick/oauth2/model/client_owner.rb +15 -0
  24. data/lib/songkick/oauth2/model/hashing.rb +29 -0
  25. data/lib/songkick/oauth2/model/resource_owner.rb +54 -0
  26. data/lib/songkick/oauth2/provider.rb +122 -0
  27. data/lib/songkick/oauth2/provider/access_token.rb +68 -0
  28. data/lib/songkick/oauth2/provider/authorization.rb +190 -0
  29. data/lib/songkick/oauth2/provider/error.rb +22 -0
  30. data/lib/songkick/oauth2/provider/exchange.rb +227 -0
  31. data/lib/songkick/oauth2/router.rb +79 -0
  32. data/lib/songkick/oauth2/schema.rb +17 -0
  33. data/lib/songkick/oauth2/schema/20120828112156_songkick_oauth2_schema_original_schema.rb +36 -0
  34. data/spec/factories.rb +27 -0
  35. data/spec/request_helpers.rb +52 -0
  36. data/spec/songkick/oauth2/model/authorization_spec.rb +216 -0
  37. data/spec/songkick/oauth2/model/client_spec.rb +55 -0
  38. data/spec/songkick/oauth2/model/resource_owner_spec.rb +88 -0
  39. data/spec/songkick/oauth2/provider/access_token_spec.rb +125 -0
  40. data/spec/songkick/oauth2/provider/authorization_spec.rb +346 -0
  41. data/spec/songkick/oauth2/provider/exchange_spec.rb +353 -0
  42. data/spec/songkick/oauth2/provider_spec.rb +545 -0
  43. data/spec/spec_helper.rb +62 -0
  44. data/spec/test_app/helper.rb +33 -0
  45. data/spec/test_app/provider/application.rb +68 -0
  46. data/spec/test_app/provider/views/authorize.erb +19 -0
  47. metadata +273 -0
@@ -0,0 +1,61 @@
1
+ require 'bcrypt'
2
+
3
+ module Songkick
4
+ module OAuth2
5
+ module Model
6
+
7
+ class Client < ActiveRecord::Base
8
+ self.table_name = :oauth2_clients
9
+
10
+ belongs_to :oauth2_client_owner, :polymorphic => true
11
+ alias :owner :oauth2_client_owner
12
+ alias :owner= :oauth2_client_owner=
13
+
14
+ has_many :authorizations, :class_name => 'Songkick::OAuth2::Model::Authorization', :dependent => :destroy
15
+
16
+ validates_uniqueness_of :client_id
17
+ validates_presence_of :name, :redirect_uri
18
+ validate :check_format_of_redirect_uri
19
+
20
+ attr_accessible :name, :redirect_uri
21
+
22
+ before_create :generate_credentials
23
+
24
+ def self.create_client_id
25
+ Songkick::OAuth2.generate_id do |client_id|
26
+ count(:conditions => {:client_id => client_id}).zero?
27
+ end
28
+ end
29
+
30
+ attr_reader :client_secret
31
+
32
+ def client_secret=(secret)
33
+ @client_secret = secret
34
+ hash = BCrypt::Password.create(secret)
35
+ hash.force_encoding('UTF-8') if hash.respond_to?(:force_encoding)
36
+ self.client_secret_hash = hash
37
+ end
38
+
39
+ def valid_client_secret?(secret)
40
+ BCrypt::Password.new(client_secret_hash) == secret
41
+ end
42
+
43
+ private
44
+
45
+ def check_format_of_redirect_uri
46
+ uri = URI.parse(redirect_uri)
47
+ errors.add(:redirect_uri, 'must be an absolute URI') unless uri.absolute?
48
+ rescue
49
+ errors.add(:redirect_uri, 'must be a URI')
50
+ end
51
+
52
+ def generate_credentials
53
+ self.client_id = self.class.create_client_id
54
+ self.client_secret = Songkick::OAuth2.random_string
55
+ end
56
+ end
57
+
58
+ end
59
+ end
60
+ end
61
+
@@ -0,0 +1,15 @@
1
+ module Songkick
2
+ module OAuth2
3
+ module Model
4
+
5
+ module ClientOwner
6
+ def self.included(klass)
7
+ klass.has_many :oauth2_clients,
8
+ :class_name => 'Songkick::OAuth2::Model::Client',
9
+ :as => :oauth2_client_owner
10
+ end
11
+ end
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,29 @@
1
+ module Songkick
2
+ module OAuth2
3
+ module Model
4
+
5
+ module Hashing
6
+ def hashes_attributes(*attributes)
7
+ attributes.each do |attribute|
8
+ define_method("#{attribute}=") do |value|
9
+ instance_variable_set("@#{attribute}", value)
10
+ __send__("#{attribute}_hash=", value && Songkick::OAuth2.hashify(value))
11
+ end
12
+ attr_reader attribute
13
+ end
14
+
15
+ class_eval <<-RUBY
16
+ def reload(*args)
17
+ super
18
+ #{ attributes.inspect }.each do |attribute|
19
+ instance_variable_set('@' + attribute.to_s, nil)
20
+ end
21
+ end
22
+ RUBY
23
+ end
24
+ end
25
+
26
+ end
27
+ end
28
+ end
29
+
@@ -0,0 +1,54 @@
1
+ module Songkick
2
+ module OAuth2
3
+ module Model
4
+
5
+ module AuthorizationAssociation
6
+ def find_or_create_for_client(client)
7
+ unless client.is_a?(Client)
8
+ raise ArgumentError, "The argument should be a #{Client}, instead it was a #{client.class}"
9
+ end
10
+
11
+ # find_or_create_by_client_id does not work across AR versions
12
+ authorization = find_by_client_id(client.id) || build
13
+ authorization.client = client
14
+ authorization.owner = owner
15
+ authorization.save
16
+ authorization
17
+ end
18
+
19
+ private
20
+
21
+ def owner
22
+ respond_to?(:proxy_association) ? proxy_association.owner : proxy_owner
23
+ end
24
+ end
25
+
26
+ module ResourceOwner
27
+ def self.included(klass)
28
+ klass.has_many :oauth2_authorizations,
29
+ :class_name => 'Songkick::OAuth2::Model::Authorization',
30
+ :as => :oauth2_resource_owner,
31
+ :dependent => :destroy,
32
+ :extend => AuthorizationAssociation
33
+ end
34
+
35
+ def grant_access!(client, options = {})
36
+ authorization = oauth2_authorizations.find_or_create_for_client(client)
37
+
38
+ if scopes = options[:scopes]
39
+ scopes = authorization.scopes + scopes
40
+ authorization.scope = scopes.entries.join(' ')
41
+ end
42
+
43
+ if duration = options[:duration]
44
+ authorization.expires_at = Time.now + duration.to_i
45
+ end
46
+
47
+ authorization.save! if authorization.changed?
48
+ authorization
49
+ end
50
+ end
51
+
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,122 @@
1
+ require 'digest/sha1'
2
+ require 'json'
3
+ require 'logger'
4
+
5
+ module Songkick
6
+ module OAuth2
7
+ ROOT = File.expand_path(File.dirname(__FILE__) + '/..')
8
+ TOKEN_SIZE = 160
9
+
10
+ autoload :Model, ROOT + '/oauth2/model'
11
+ autoload :Router, ROOT + '/oauth2/router'
12
+ autoload :Schema, ROOT + '/oauth2/schema'
13
+
14
+ def self.random_string
15
+ rand(2 ** TOKEN_SIZE).to_s(36)
16
+ end
17
+
18
+ def self.generate_id(&predicate)
19
+ id = random_string
20
+ id = random_string until predicate.call(id)
21
+ id
22
+ end
23
+
24
+ def self.hashify(token)
25
+ return nil unless String === token
26
+ Digest::SHA1.hexdigest(token)
27
+ end
28
+
29
+ ACCESS_TOKEN = 'access_token'
30
+ ASSERTION = 'assertion'
31
+ ASSERTION_TYPE = 'assertion_type'
32
+ AUTHORIZATION_CODE = 'authorization_code'
33
+ CLIENT_ID = 'client_id'
34
+ CLIENT_SECRET = 'client_secret'
35
+ CODE = 'code'
36
+ CODE_AND_TOKEN = 'code_and_token'
37
+ DURATION = 'duration'
38
+ ERROR = 'error'
39
+ ERROR_DESCRIPTION = 'error_description'
40
+ EXPIRES_IN = 'expires_in'
41
+ GRANT_TYPE = 'grant_type'
42
+ OAUTH_TOKEN = 'oauth_token'
43
+ PASSWORD = 'password'
44
+ REDIRECT_URI = 'redirect_uri'
45
+ REFRESH_TOKEN = 'refresh_token'
46
+ RESPONSE_TYPE = 'response_type'
47
+ SCOPE = 'scope'
48
+ STATE = 'state'
49
+ TOKEN = 'token'
50
+ USERNAME = 'username'
51
+
52
+ INVALID_REQUEST = 'invalid_request'
53
+ UNSUPPORTED_RESPONSE = 'unsupported_response_type'
54
+ REDIRECT_MISMATCH = 'redirect_uri_mismatch'
55
+ UNSUPPORTED_GRANT_TYPE = 'unsupported_grant_type'
56
+ INVALID_GRANT = 'invalid_grant'
57
+ INVALID_CLIENT = 'invalid_client'
58
+ UNAUTHORIZED_CLIENT = 'unauthorized_client'
59
+ INVALID_SCOPE = 'invalid_scope'
60
+ INVALID_TOKEN = 'invalid_token'
61
+ EXPIRED_TOKEN = 'expired_token'
62
+ INSUFFICIENT_SCOPE = 'insufficient_scope'
63
+ ACCESS_DENIED = 'access_denied'
64
+
65
+ class Provider
66
+ class << self
67
+ attr_accessor :realm, :enforce_ssl
68
+ end
69
+
70
+ def self.clear_assertion_handlers!
71
+ @password_handler = nil
72
+ @assertion_handlers = {}
73
+ @assertion_filters = []
74
+ end
75
+
76
+ clear_assertion_handlers!
77
+
78
+ def self.handle_passwords(&block)
79
+ @password_handler = block
80
+ end
81
+
82
+ def self.handle_password(client, username, password, scopes)
83
+ return nil unless @password_handler
84
+ @password_handler.call(client, username, password, scopes)
85
+ end
86
+
87
+ def self.filter_assertions(&filter)
88
+ @assertion_filters.push(filter)
89
+ end
90
+
91
+ def self.handle_assertions(assertion_type, &handler)
92
+ @assertion_handlers[assertion_type] = handler
93
+ end
94
+
95
+ def self.handle_assertion(client, assertion, scopes)
96
+ return nil unless @assertion_filters.all? { |f| f.call(client) }
97
+ handler = @assertion_handlers[assertion.type]
98
+ handler ? handler.call(client, assertion.value, scopes) : nil
99
+ end
100
+
101
+ def self.parse(*args)
102
+ Router.parse(*args)
103
+ end
104
+
105
+ def self.access_token(*args)
106
+ Router.access_token(*args)
107
+ end
108
+
109
+ def self.access_token_from_request(*args)
110
+ Router.access_token_from_request(*args)
111
+ end
112
+
113
+ EXPIRY_TIME = 3600
114
+
115
+ autoload :Authorization, ROOT + '/oauth2/provider/authorization'
116
+ autoload :Exchange, ROOT + '/oauth2/provider/exchange'
117
+ autoload :AccessToken, ROOT + '/oauth2/provider/access_token'
118
+ autoload :Error, ROOT + '/oauth2/provider/error'
119
+ end
120
+ end
121
+ end
122
+
@@ -0,0 +1,68 @@
1
+ module Songkick
2
+ module OAuth2
3
+ class Provider
4
+
5
+ class AccessToken
6
+ attr_reader :authorization
7
+
8
+ def initialize(resource_owner = nil, scopes = [], access_token = nil, error = nil)
9
+ @resource_owner = resource_owner
10
+ @scopes = scopes
11
+ @access_token = access_token
12
+ @error = error && INVALID_REQUEST
13
+
14
+ authorize!(access_token, error)
15
+ validate!
16
+ end
17
+
18
+ def client
19
+ valid? ? @authorization.client : nil
20
+ end
21
+
22
+ def owner
23
+ valid? ? @authorization.owner : nil
24
+ end
25
+
26
+ def response_headers
27
+ return {} if valid?
28
+ error_message = "OAuth realm='#{ Provider.realm }'"
29
+ error_message << ", error='#{ @error }'" unless @error == ''
30
+ {'WWW-Authenticate' => error_message}
31
+ end
32
+
33
+ def response_status
34
+ case @error
35
+ when INVALID_REQUEST, INVALID_TOKEN, EXPIRED_TOKEN then 401
36
+ when INSUFFICIENT_SCOPE then 403
37
+ when '' then 401
38
+ else 200
39
+ end
40
+ end
41
+
42
+ def valid?
43
+ @error.nil?
44
+ end
45
+
46
+ private
47
+
48
+ def authorize!(access_token, error)
49
+ return unless @authorization = Model.find_access_token(access_token)
50
+ @authorization.update_attribute(:access_token, nil) if error
51
+ end
52
+
53
+ def validate!
54
+ return @error = '' unless @access_token
55
+ return @error = INVALID_TOKEN unless @authorization
56
+ return @error = EXPIRED_TOKEN if @authorization.expired?
57
+ return @error = INSUFFICIENT_SCOPE unless @authorization.in_scope?(@scopes)
58
+
59
+ if @resource_owner and @authorization.owner != @resource_owner
60
+ @error = INSUFFICIENT_SCOPE
61
+ end
62
+ end
63
+ end
64
+
65
+ end
66
+ end
67
+ end
68
+
@@ -0,0 +1,190 @@
1
+ require 'cgi'
2
+
3
+ module Songkick
4
+ module OAuth2
5
+ class Provider
6
+
7
+ class Authorization
8
+ attr_reader :owner, :client,
9
+ :code, :access_token,
10
+ :expires_in, :refresh_token,
11
+ :error, :error_description
12
+
13
+ REQUIRED_PARAMS = [RESPONSE_TYPE, CLIENT_ID, REDIRECT_URI]
14
+ VALID_PARAMS = REQUIRED_PARAMS + [SCOPE, STATE]
15
+ VALID_RESPONSES = [CODE, TOKEN, CODE_AND_TOKEN]
16
+
17
+ def initialize(resource_owner, params, transport_error = nil)
18
+ @owner = resource_owner
19
+ @params = params
20
+ @scope = params[SCOPE]
21
+ @state = params[STATE]
22
+
23
+ @transport_error = transport_error
24
+
25
+ validate!
26
+
27
+ return unless @owner and not @error
28
+
29
+ @model = Model::Authorization.for(@owner, @client)
30
+ return unless @model and @model.in_scope?(scopes) and not @model.expired?
31
+
32
+ @authorized = true
33
+
34
+ if @params[RESPONSE_TYPE] =~ /code/
35
+ @code = @model.generate_code
36
+ end
37
+
38
+ if @params[RESPONSE_TYPE] =~ /token/
39
+ @access_token = @model.generate_access_token
40
+ end
41
+ end
42
+
43
+ def scopes
44
+ scopes = @scope ? @scope.split(/\s+/).delete_if { |s| s.empty? } : []
45
+ Set.new(scopes)
46
+ end
47
+
48
+ def unauthorized_scopes
49
+ @model ? scopes.select { |s| not @model.in_scope?(s) } : scopes
50
+ end
51
+
52
+ def grant_access!(options = {})
53
+ @model = Model::Authorization.for_response_type(@params[RESPONSE_TYPE],
54
+ :owner => @owner,
55
+ :client => @client,
56
+ :scope => @scope,
57
+ :duration => options[:duration])
58
+
59
+ @code = @model.code
60
+ @access_token = @model.access_token
61
+ @refresh_token = @model.refresh_token
62
+ @expires_in = @model.expires_in
63
+
64
+ unless @params[RESPONSE_TYPE] == CODE
65
+ @expires_in = @model.expires_in
66
+ end
67
+
68
+ @authorized = true
69
+ end
70
+
71
+ def deny_access!
72
+ @code = @access_token = @refresh_token = nil
73
+ @error = ACCESS_DENIED
74
+ @error_description = "The user denied you access"
75
+ end
76
+
77
+ def params
78
+ params = {}
79
+ VALID_PARAMS.each { |key| params[key] = @params[key] if @params.has_key?(key) }
80
+ params
81
+ end
82
+
83
+ def redirect?
84
+ @client and (@authorized or not valid?)
85
+ end
86
+
87
+ def redirect_uri
88
+ return nil unless @client
89
+ base_redirect_uri = @client.redirect_uri
90
+
91
+ if not valid?
92
+ query = to_query_string(ERROR, ERROR_DESCRIPTION, STATE)
93
+ "#{ base_redirect_uri }?#{ query }"
94
+
95
+ elsif @params[RESPONSE_TYPE] == CODE_AND_TOKEN
96
+ query = to_query_string(CODE, STATE)
97
+ fragment = to_query_string(ACCESS_TOKEN, EXPIRES_IN, SCOPE)
98
+ "#{ base_redirect_uri }#{ query.empty? ? '' : '?' + query }##{ fragment }"
99
+
100
+ elsif @params[RESPONSE_TYPE] == 'token'
101
+ fragment = to_query_string(ACCESS_TOKEN, EXPIRES_IN, SCOPE, STATE)
102
+ "#{ base_redirect_uri }##{ fragment }"
103
+
104
+ else
105
+ query = to_query_string(CODE, SCOPE, STATE)
106
+ "#{ base_redirect_uri }?#{ query }"
107
+ end
108
+ end
109
+
110
+ def response_body
111
+ warn "Songkick::OAuth2::Provider::Authorization no longer returns a response body "+
112
+ "when the request is invalid. You should call valid? to determine "+
113
+ "whether to render your login page or an error page."
114
+ nil
115
+ end
116
+
117
+ def response_headers
118
+ redirect? ? {} : {'Cache-Control' => 'no-store'}
119
+ end
120
+
121
+ def response_status
122
+ return 302 if redirect?
123
+ return 200 if valid?
124
+ @client ? 302 : 400
125
+ end
126
+
127
+ def valid?
128
+ @error.nil?
129
+ end
130
+
131
+ private
132
+
133
+ def validate!
134
+ if @transport_error
135
+ @error = @transport_error.error
136
+ @error_description = @transport_error.error_description
137
+ return
138
+ end
139
+
140
+ @client = @params[CLIENT_ID] && Model::Client.find_by_client_id(@params[CLIENT_ID])
141
+ unless @client
142
+ @error = INVALID_CLIENT
143
+ @error_description = "Unknown client ID #{@params[CLIENT_ID]}"
144
+ end
145
+
146
+ REQUIRED_PARAMS.each do |param|
147
+ next if @params.has_key?(param)
148
+ @error = INVALID_REQUEST
149
+ @error_description = "Missing required parameter #{param}"
150
+ end
151
+ return if @error
152
+
153
+ [SCOPE, STATE].each do |param|
154
+ next unless @params.has_key?(param)
155
+ if @params[param] =~ /\r\n/
156
+ @error = INVALID_REQUEST
157
+ @error_description = "Illegal value for #{param} parameter"
158
+ end
159
+ end
160
+
161
+ unless VALID_RESPONSES.include?(@params[RESPONSE_TYPE])
162
+ @error = UNSUPPORTED_RESPONSE
163
+ @error_description = "Response type #{@params[RESPONSE_TYPE]} is not supported"
164
+ end
165
+
166
+ @client = Model::Client.find_by_client_id(@params[CLIENT_ID])
167
+ unless @client
168
+ @error = INVALID_CLIENT
169
+ @error_description = "Unknown client ID #{@params[CLIENT_ID]}"
170
+ end
171
+
172
+ if @client and @client.redirect_uri and @client.redirect_uri != @params[REDIRECT_URI]
173
+ @error = REDIRECT_MISMATCH
174
+ @error_description = "Parameter #{REDIRECT_URI} does not match registered URI"
175
+ end
176
+ end
177
+
178
+ def to_query_string(*ivars)
179
+ ivars.map { |key|
180
+ value = instance_variable_get("@#{key}")
181
+ value = value.join(' ') if Array === value
182
+ value ? "#{ key }=#{ CGI.escape(value.to_s) }" : nil
183
+ }.compact.join('&')
184
+ end
185
+ end
186
+
187
+ end
188
+ end
189
+ end
190
+