songkick-oauth2-provider 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
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
+