doorkeeper 6.0.0.beta2 → 6.0.0.rc2

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -2
  3. data/README.md +29 -0
  4. data/app/controllers/doorkeeper/authorizations_controller.rb +66 -3
  5. data/app/controllers/doorkeeper/authorized_applications_controller.rb +23 -0
  6. data/app/controllers/doorkeeper/token_info_controller.rb +13 -1
  7. data/config/locales/en.yml +1 -0
  8. data/lib/doorkeeper/config/validations.rb +91 -0
  9. data/lib/doorkeeper/config.rb +18 -2
  10. data/lib/doorkeeper/errors.rb +14 -0
  11. data/lib/doorkeeper/grape/helpers.rb +11 -1
  12. data/lib/doorkeeper/helpers/controller.rb +12 -0
  13. data/lib/doorkeeper/http_fetcher.rb +79 -19
  14. data/lib/doorkeeper/models/access_token_mixin.rb +120 -2
  15. data/lib/doorkeeper/models/concerns/secret_storable.rb +72 -6
  16. data/lib/doorkeeper/oauth/authorization/token.rb +87 -8
  17. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +13 -0
  18. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +27 -20
  19. data/lib/doorkeeper/oauth/client_credentials/creator.rb +17 -3
  20. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +14 -0
  21. data/lib/doorkeeper/oauth/metadata_response.rb +4 -3
  22. data/lib/doorkeeper/oauth/pre_authorization.rb +50 -0
  23. data/lib/doorkeeper/oauth/refresh_token_request.rb +81 -24
  24. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +10 -0
  25. data/lib/doorkeeper/oauth/token.rb +94 -2
  26. data/lib/doorkeeper/oauth/token_introspection.rb +14 -1
  27. data/lib/doorkeeper/orm/active_record/mixins/access_grant.rb +1 -0
  28. data/lib/doorkeeper/orm/active_record/mixins/access_token.rb +1 -0
  29. data/lib/doorkeeper/orm/active_record/mixins/application.rb +1 -0
  30. data/lib/doorkeeper/orm/active_record/mixins/secret_storable.rb +113 -0
  31. data/lib/doorkeeper/orm/active_record/redirect_uri_validator.rb +5 -1
  32. data/lib/doorkeeper/orm/active_record.rb +1 -0
  33. data/lib/doorkeeper/rails/helpers.rb +25 -2
  34. data/lib/doorkeeper/request.rb +19 -7
  35. data/lib/doorkeeper/version.rb +1 -1
  36. data/lib/doorkeeper.rb +11 -0
  37. data/lib/generators/doorkeeper/refresh_token_scopes_generator.rb +43 -0
  38. data/lib/generators/doorkeeper/templates/add_refresh_token_scopes_to_access_tokens.rb.erb +10 -0
  39. data/lib/generators/doorkeeper/templates/initializer.rb +94 -15
  40. data/lib/generators/doorkeeper/templates/migration.rb.erb +9 -0
  41. metadata +12 -12
@@ -13,9 +13,8 @@ module Doorkeeper
13
13
  existing_token = find_revocable_token_for(client, scopes, attributes)
14
14
 
15
15
  with_revocation(existing_token: existing_token) do
16
- application = client.is_a?(Doorkeeper.config.application_model) ? client : client&.application
17
16
  Doorkeeper.config.access_token_model.create_for(
18
- application: application,
17
+ application: application_for(client),
19
18
  resource_owner: nil,
20
19
  scopes: scopes,
21
20
  **attributes,
@@ -39,10 +38,25 @@ module Doorkeeper
39
38
  end
40
39
  end
41
40
 
41
+ # A token that would outlive +public_client_access_token_expires_in+
42
+ # is not reused, so the cap holds for a public client whatever tokens
43
+ # it was issued before; a new, capped token is issued instead.
44
+ #
45
+ # The cap is checked on the token found rather than inside the lookup
46
+ # +find_revocable_token_for+ shares: there, an over-cap token must
47
+ # still be found, so that +revoke_previous_client_credentials_token+
48
+ # revokes it when the capped token replaces it.
42
49
  def find_reusable_token_for(client, scopes, attributes)
43
50
  token = find_active_existing_token_for(client, scopes, attributes)
51
+ return unless token&.reusable?
44
52
 
45
- token if token&.reusable?
53
+ token if Authorization::Token.within_public_client_expires_in?(
54
+ Doorkeeper.config, application_for(client), token,
55
+ )
56
+ end
57
+
58
+ def application_for(client)
59
+ client.is_a?(Doorkeeper.config.application_model) ? client : client&.application
46
60
  end
47
61
 
48
62
  def find_revocable_token_for(client, scopes, attributes)
@@ -6,6 +6,15 @@ module Doorkeeper
6
6
  module OAuth
7
7
  module Helpers
8
8
  module URIChecker
9
+ # Schemes a user agent evaluates in the document that navigates to
10
+ # them instead of fetching a resource from somewhere else. With
11
+ # `response_mode=form_post` that document is the authorization
12
+ # server's own page, so a redirect URI with one of these schemes would
13
+ # run the client's script on the server's origin. None of them can be
14
+ # a legitimate redirection endpoint (RFC 6749 Section 3.1.2), so they
15
+ # are refused regardless of the `forbid_redirect_uri` configuration.
16
+ SCRIPT_SCHEMES = %w[javascript vbscript data].freeze
17
+
9
18
  def self.valid?(url)
10
19
  return true if oob_uri?(url)
11
20
 
@@ -74,10 +83,15 @@ module Doorkeeper
74
83
 
75
84
  def self.valid_scheme?(uri)
76
85
  return false if uri.scheme.blank?
86
+ return false if script_scheme?(uri)
77
87
 
78
88
  %w[localhost].exclude?(uri.scheme)
79
89
  end
80
90
 
91
+ def self.script_scheme?(uri)
92
+ SCRIPT_SCHEMES.include?(uri.scheme.to_s.downcase)
93
+ end
94
+
81
95
  def self.hypertext_scheme?(uri)
82
96
  %w[http https].include?(uri.scheme)
83
97
  end
@@ -155,10 +155,11 @@ module Doorkeeper
155
155
  config.pkce_code_challenge_methods_supported
156
156
  end
157
157
 
158
+ # The same predicate the endpoints gate on, so the document cannot
159
+ # advertise a `resource` parameter they would refuse, or stay silent
160
+ # about one they honour.
158
161
  def resource_indicators_supported?
159
- config.resource_indicator_validator.present? &&
160
- Doorkeeper.config.access_grant_model.resource_indicators_supported? &&
161
- Doorkeeper.config.access_token_model.resource_indicators_supported?
162
+ config.resource_indicator_validator.present? && ResourceIndicatorValidator.storage_ready?
162
163
  end
163
164
  end
164
165
  end
@@ -5,6 +5,16 @@ module Doorkeeper
5
5
  class PreAuthorization
6
6
  include Validations
7
7
 
8
+ # The validations that identify the client and its redirect URI. None of
9
+ # them depend on the resource owner, so the authorization endpoint can
10
+ # run them before authenticating anyone and spare the user a login that
11
+ # can only end on an error page. RFC 6749 Section 4.1.2.1 (Section 4.2.2.1
12
+ # for the implicit flow) asks for the resource owner to be informed when
13
+ # the client_id is missing or invalid, and Section 3.1.2.4 asks the same
14
+ # for the redirect URI; running these first is what lets the endpoint
15
+ # inform them without a detour through the login form.
16
+ CLIENT_VALIDATIONS = %i[client_id client redirect_uri].freeze
17
+
8
18
  validate :client_id, error: Errors::InvalidRequest
9
19
  validate :client, error: Errors::InvalidClient
10
20
  validate :redirect_uri, error: Errors::InvalidRedirectUri
@@ -17,6 +27,9 @@ module Doorkeeper
17
27
  validate :code_challenge, error: Errors::InvalidRequest
18
28
  validate :code_challenge_method, error: Errors::InvalidCodeChallengeMethod
19
29
  validate :resource_indicators, error: Errors::InvalidTarget
30
+ # Runs after :resource_indicators so a malformed target is still answered
31
+ # as the client's error rather than the server's.
32
+ validate :resource_indicator_storage, error: Errors::ServerError
20
33
 
21
34
  attr_reader :client, :code_challenge, :code_challenge_method, :missing_param,
22
35
  :redirect_uri, :resource_owner, :response_type, :state,
@@ -42,6 +55,24 @@ module Doorkeeper
42
55
  valid?
43
56
  end
44
57
 
58
+ # Runs only CLIENT_VALIDATIONS, in declared order, so a request
59
+ # from an unknown client or with an invalid redirect URI can be refused
60
+ # without a resource owner. Error precedence matches a full #validate
61
+ # run because these are the first validations declared.
62
+ def client_valid?
63
+ @error = nil
64
+ @missing_param = nil
65
+
66
+ self.class.validations.each do |validation|
67
+ next unless CLIENT_VALIDATIONS.include?(validation[:attribute])
68
+
69
+ @error = validation[:options][:error] unless send("validate_#{validation[:attribute]}")
70
+ break if @error
71
+ end
72
+
73
+ @error.nil?
74
+ end
75
+
45
76
  def scopes
46
77
  Scopes.from_string(scope)
47
78
  end
@@ -207,6 +238,25 @@ module Doorkeeper
207
238
  false
208
239
  end
209
240
 
241
+ # A client asking for a resource this server cannot record the audience
242
+ # of is a misconfiguration — resource_indicator_validator is set, but the
243
+ # `resource` column the doorkeeper:resource_indicators generator adds is
244
+ # not there. Issuing the grant would raise MissingResourceColumn out of
245
+ # Authorization::Code, which the authorization endpoint does not rescue,
246
+ # so the misconfiguration reached the client as a 500. Answering
247
+ # server_error here instead matches what the token endpoint already makes
248
+ # of the same exception, and RFC 6749 Section 4.1.2.1 lists server_error
249
+ # among the errors returned through the redirection URI.
250
+ #
251
+ # Only requests that actually ask for a resource are refused: without the
252
+ # parameter there is nothing to record, and a misconfigured server would
253
+ # otherwise stop authorizing anyone at all.
254
+ def validate_resource_indicator_storage
255
+ return true if @resource_indicators.blank?
256
+
257
+ ResourceIndicatorValidator.storage_ready?
258
+ end
259
+
210
260
  def response_on_fragment?
211
261
  return response_type == "token" if response_mode.nil?
212
262
 
@@ -65,20 +65,34 @@ module Doorkeeper
65
65
  Doorkeeper.config.access_token_model.refresh_token_revoked_on_use?
66
66
  end
67
67
 
68
+ # RFC 6749 §6: a `scope` parameter that is omitted "is treated as equal
69
+ # to the scope originally granted by the resource owner", and a
70
+ # requested scope must not exceed it. Both are the scope the presented
71
+ # refresh token carries, not the scope of the access token issued with
72
+ # it, which the client may have narrowed on an earlier refresh.
68
73
  def default_scopes
69
- refresh_token.scopes
74
+ granted_scopes
75
+ end
76
+
77
+ # Scope of the presented refresh token. Without the
78
+ # `refresh_token_scopes` column the model reports the access token
79
+ # scope here, which is the behavior Doorkeeper had before the column
80
+ # existed. An access token model that does not implement
81
+ # `refresh_token_scopes` at all (the Sequel and MongoDB adapters ship
82
+ # their own mixins) gets that same behavior.
83
+ def granted_scopes
84
+ @granted_scopes ||= refresh_token.try(:refresh_token_scopes) || refresh_token.scopes
85
+ end
86
+
87
+ # True when the access token model implements the granted-scope API
88
+ # and has the column to store it.
89
+ def refresh_token_scopes_supported?
90
+ Doorkeeper.config.access_token_model.try(:refresh_token_scopes_supported?)
70
91
  end
71
92
 
72
93
  def create_access_token
73
94
  attributes = {}.merge(custom_token_attributes_with_data)
74
95
 
75
- resource_owner =
76
- if Doorkeeper.config.polymorphic_resource_owner?
77
- refresh_token.resource_owner
78
- else
79
- refresh_token.resource_owner_id
80
- end
81
-
82
96
  if refresh_token_revoked_on_use?
83
97
  attributes[:previous_refresh_token] = refresh_token.refresh_token
84
98
  end
@@ -96,29 +110,69 @@ module Doorkeeper
96
110
  attributes[:resource] = refresh_token.resource
97
111
  end
98
112
 
99
- # RFC6749
100
- # 1.5. Refresh Token
101
- #
102
- # Refresh tokens are issued to the client by the authorization server and are
103
- # used to obtain a new access token when the current access token
104
- # becomes invalid or expires, or to obtain additional access tokens
105
- # with identical or narrower scope (access tokens may have a shorter
106
- # lifetime and fewer permissions than authorized by the resource
107
- # owner).
108
- #
109
- # Here we assume that TTL of the token received after refreshing should be
110
- # the same as that of the original token.
111
- #
113
+ # RFC 6749 §6: "If a new refresh token is issued, the refresh token
114
+ # scope MUST be identical to that of the refresh token included by
115
+ # the client in the request." Carried explicitly so that a narrowed
116
+ # access token does not narrow the refresh token issued with it.
117
+ attributes[:refresh_token_scopes] = granted_scopes.to_s if refresh_token_scopes_supported?
118
+
112
119
  @access_token = Doorkeeper.config.access_token_model.create_for(
113
120
  application: refresh_token.application,
114
121
  resource_owner: resource_owner,
115
122
  scopes: scopes,
116
- expires_in: refresh_token.expires_in,
123
+ expires_in: access_token_expires_in,
117
124
  use_refresh_token: true,
118
125
  **attributes,
119
126
  )
120
127
  end
121
128
 
129
+ def resource_owner
130
+ if Doorkeeper.config.polymorphic_resource_owner?
131
+ refresh_token.resource_owner
132
+ else
133
+ refresh_token.resource_owner_id
134
+ end
135
+ end
136
+
137
+ # RFC6749
138
+ # 1.5. Refresh Token
139
+ #
140
+ # Refresh tokens are issued to the client by the authorization server and are
141
+ # used to obtain a new access token when the current access token
142
+ # becomes invalid or expires, or to obtain additional access tokens
143
+ # with identical or narrower scope (access tokens may have a shorter
144
+ # lifetime and fewer permissions than authorized by the resource
145
+ # owner).
146
+ #
147
+ # The TTL of the refreshed token is therefore that of the original token,
148
+ # so that a lifetime given to the grant the token was first issued with
149
+ # survives refreshing (#1364). +custom_access_token_expires_in+ is still
150
+ # consulted, with +Doorkeeper::OAuth::REFRESH_TOKEN+ as the grant type,
151
+ # so a host can decide the TTL of refreshed tokens explicitly; the TTL
152
+ # of the original token is only inherited when the callable returns nil
153
+ # for this grant (or none is configured).
154
+ #
155
+ # The context carries the resource owner record only with a polymorphic
156
+ # resource owner; otherwise the refresh token stores its id alone, and
157
+ # +resource_owner+ is nil as in the client_credentials grant.
158
+ #
159
+ # The context is built directly rather than through
160
+ # +Authorization::Token.build_context+, which unwraps its argument
161
+ # through #application or #client: that suits the OAuth client the other
162
+ # grants pass, but would replace this application record with whatever
163
+ # an application model responding to either method returns (a
164
+ # +belongs_to :client+ association, say).
165
+ def access_token_expires_in
166
+ context = Authorization::Context.new(
167
+ client: refresh_token.application,
168
+ grant_type: grant_type,
169
+ scopes: scopes,
170
+ resource_owner: Doorkeeper.config.polymorphic_resource_owner? ? resource_owner : nil,
171
+ )
172
+
173
+ Authorization::Token.access_token_expires_in(server, context) { refresh_token.expires_in }
174
+ end
175
+
122
176
  def validate_token_presence
123
177
  @missing_param = :refresh_token if refresh_token.blank? && @refresh_token_parameter.blank?
124
178
 
@@ -147,7 +201,7 @@ module Doorkeeper
147
201
  if @original_scopes.present?
148
202
  ScopeChecker.valid?(
149
203
  scope_str: @original_scopes,
150
- server_scopes: refresh_token.scopes,
204
+ server_scopes: granted_scopes,
151
205
  )
152
206
  else
153
207
  true
@@ -171,10 +225,13 @@ module Doorkeeper
171
225
  # enforce against. Ignore the `resource` parameter.
172
226
  return true if validator.nil? && original_resources.blank?
173
227
 
228
+ # The validator receives the OAuth client, as it does at the
229
+ # authorization endpoint and for every other grant, rather than the
230
+ # application record #client holds here.
174
231
  @resolved_resource_indicators = ResourceIndicatorValidator.validate!(
175
232
  @raw_resource_indicators,
176
233
  config_validator: validator,
177
- client: client,
234
+ client: client && Doorkeeper::OAuth::Client.new(client),
178
235
  grant_resource_indicators: original_resources,
179
236
  )
180
237
  true
@@ -13,6 +13,16 @@ module Doorkeeper
13
13
  module ResourceIndicatorValidator
14
14
  module_function
15
15
 
16
+ # Whether there is anywhere to record the audience a token was restricted
17
+ # to: the `resource` column the doorkeeper:resource_indicators generator
18
+ # adds to both tables. RFC 8707 also needs a policy deciding which
19
+ # resources are acceptable (resource_indicator_validator), so callers
20
+ # asking whether the extension is usable check both.
21
+ def storage_ready?
22
+ Doorkeeper.config.access_grant_model.resource_indicators_supported? &&
23
+ Doorkeeper.config.access_token_model.resource_indicators_supported?
24
+ end
25
+
16
26
  # Validates and normalizes an array of resource indicator values.
17
27
  #
18
28
  # @param resource_indicators [Array<String>, String, nil] One or more resource URIs
@@ -3,8 +3,53 @@
3
3
  module Doorkeeper
4
4
  module OAuth
5
5
  class Token
6
+ # Built-in extractors that read a request parameter, and the parameter
7
+ # each of them reads. RFC 6750 treats the form-encoded body (§2.2) and
8
+ # the URI query string (§2.3) as two distinct transmission methods, but
9
+ # Rack and ActionDispatch both collapse them into a single parameter
10
+ # hash — ActionDispatch lets the query win, Rack lets the body win — so
11
+ # a request carrying the parameter in both would present a single value
12
+ # to the extractor and never be refused. The multi-method check reads
13
+ # the two sources separately for these extractors; selection keeps
14
+ # using the extractor, so which one wins is unchanged.
15
+ PARAMETER_EXTRACTORS = {
16
+ from_access_token_param: "access_token",
17
+ from_bearer_param: "bearer_token",
18
+ }.freeze
19
+
6
20
  class << self
21
+ # RFC 6750 §2: "Clients MUST NOT use more than one method to transmit
22
+ # the token in each request", and §3.1 lists using more than one method
23
+ # among the conditions an invalid_request answers. Returning the first
24
+ # method that yields a value would discard every other token presented
25
+ # in the same request with no error, warning or log entry, leaving
26
+ # which token authorizes the request to be decided by the configured
27
+ # order of +access_token_methods+ rather than by what the caller sent —
28
+ # so a layer in front of Doorkeeper that reads a different one of them
29
+ # can reach a different verdict about the very same request.
30
+ #
31
+ # What §2 forbids is using more than one method, so the check counts
32
+ # transmission methods rather than comparing the tokens they carry:
33
+ # the same value presented twice is still two methods, and is refused
34
+ # by raising Errors::MultipleAccessTokenMethods so callers can answer
35
+ # with the invalid_request (400) response §3.1 prescribes — the same
36
+ # shape Request.client_authentication_method gives the client
37
+ # authentication side with Errors::MultipleClientAuthMethods.
38
+ #
39
+ # Only the built-in extractors — symbols naming methods on this class,
40
+ # all of them side-effect-free reads of the request — take part in
41
+ # that check. A custom callable extractor is a configuration adapter
42
+ # rather than a transmission method: it keeps the historical
43
+ # first-wins selection and is never invoked more than once, the same
44
+ # exemption client authentication gives its legacy callable
45
+ # extractors (Request#validate_client_authentication!).
7
46
  def from_request(request, *methods)
47
+ used = methods.sum do |method|
48
+ method.is_a?(Symbol) ? transmission_methods_used(request, method) : 0
49
+ end
50
+
51
+ raise Errors::MultipleAccessTokenMethods if used > 1
52
+
8
53
  methods.inject(nil) do |_, method|
9
54
  method = self.method(method) if method.is_a?(Symbol)
10
55
  credentials = method.call(request)
@@ -23,11 +68,11 @@ module Doorkeeper
23
68
  end
24
69
 
25
70
  def from_access_token_param(request)
26
- request.parameters[:access_token]
71
+ parameters(request)[:access_token]
27
72
  end
28
73
 
29
74
  def from_bearer_param(request)
30
- request.parameters[:bearer_token]
75
+ parameters(request)[:bearer_token]
31
76
  end
32
77
 
33
78
  def from_bearer_authorization(request)
@@ -44,6 +89,53 @@ module Doorkeeper
44
89
 
45
90
  private
46
91
 
92
+ # How many transmission methods the given built-in extractor finds a
93
+ # token in. Usually one, or none — but for the parameter extractors
94
+ # above it is the body and the query counted separately, since the
95
+ # parameter hash collapsed them into the single value the extractor
96
+ # reads. That value is counted on its own only when neither raw source
97
+ # explains it, so a token reaching the extractor by some other route —
98
+ # an :access_token path segment, or a host that overrode the extractor
99
+ # — is still counted exactly once rather than twice.
100
+ def transmission_methods_used(request, method)
101
+ value = self.method(method).call(request).presence
102
+
103
+ parameter = PARAMETER_EXTRACTORS[method]
104
+ return value ? 1 : 0 unless parameter
105
+
106
+ sources = parameter_sources(request, parameter)
107
+ sources.size + (value && !sources.include?(value) ? 1 : 0)
108
+ end
109
+
110
+ # The form-encoded body (§2.2) and the URI query (§2.3) as Rack
111
+ # exposes them, for a request object that keeps the two apart.
112
+ def parameter_sources(request, parameter)
113
+ return [] unless request.respond_to?(:GET) && request.respond_to?(:POST)
114
+
115
+ query = request.GET
116
+ body = body_parameters(request)
117
+ return [] unless query.is_a?(Hash) && body.is_a?(Hash)
118
+
119
+ [query[parameter], body[parameter]].filter_map(&:presence)
120
+ end
121
+
122
+ # A body ActionDispatch cannot parse — malformed JSON under a JSON
123
+ # content type, say — carries no token. Reading it must not raise out
124
+ # of the extractors, which host apps call from places that run after
125
+ # their own ParseError handling (instrumentation, exception apps), the
126
+ # same way ActionDispatch's own #filtered_parameters treats that error.
127
+ def parameters(request)
128
+ request.parameters
129
+ rescue ActionDispatch::Http::Parameters::ParseError
130
+ {}
131
+ end
132
+
133
+ def body_parameters(request)
134
+ request.POST
135
+ rescue ActionDispatch::Http::Parameters::ParseError
136
+ {}
137
+ end
138
+
47
139
  def token_from_basic_header(header, pattern)
48
140
  encoded_header = token_from_header(header, pattern)
49
141
  decode_basic_credentials_token(encoded_header)
@@ -108,7 +108,7 @@ module Doorkeeper
108
108
  def success_response
109
109
  response = {
110
110
  active: true,
111
- scope: @token.scopes_string,
111
+ scope: scopes_string,
112
112
  client_id: @token.try(:application).try(:uid),
113
113
  iat: @token.created_at.to_i,
114
114
  }
@@ -207,6 +207,19 @@ module Doorkeeper
207
207
  @token_type == :refresh_token
208
208
  end
209
209
 
210
+ # RFC 7662 §2.2: `scope` describes the presented token. A refresh token
211
+ # carries the scope originally granted by the resource owner
212
+ # (RFC 6749 §6), which can be wider than the scope of the access token
213
+ # it was issued with when the client narrowed that one on a refresh.
214
+ # Models without a stored granted scope (no `refresh_token_scopes`
215
+ # column, or a row created before its migration) report the access
216
+ # token scope, as before.
217
+ def scopes_string
218
+ return @token.scopes_string unless refresh_token_presented?
219
+
220
+ @token.try(:refresh_token_scopes_string).presence || @token.scopes_string
221
+ end
222
+
210
223
  def valid_authorized_token?
211
224
  !authorized_token_matches_introspected? &&
212
225
  authorized_token.accessible? &&
@@ -9,6 +9,7 @@ module Doorkeeper::Orm::ActiveRecord::Mixins
9
9
  self.strict_loading_by_default = false if respond_to?(:strict_loading_by_default)
10
10
 
11
11
  include ::Doorkeeper::AccessGrantMixin
12
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::SecretStorable
12
13
  include ::Doorkeeper::Models::PolymorphicResourceOwner::ForAccessGrant
13
14
 
14
15
  belongs_to :application, class_name: Doorkeeper.config.application_class.to_s,
@@ -9,6 +9,7 @@ module Doorkeeper::Orm::ActiveRecord::Mixins
9
9
  self.strict_loading_by_default = false if respond_to?(:strict_loading_by_default)
10
10
 
11
11
  include ::Doorkeeper::AccessTokenMixin
12
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::SecretStorable
12
13
  include ::Doorkeeper::Models::PolymorphicResourceOwner::ForAccessToken
13
14
 
14
15
  belongs_to :application, class_name: Doorkeeper.config.application_class.to_s,
@@ -9,6 +9,7 @@ module Doorkeeper::Orm::ActiveRecord::Mixins
9
9
  self.strict_loading_by_default = false if respond_to?(:strict_loading_by_default)
10
10
 
11
11
  include ::Doorkeeper::ApplicationMixin
12
+ include ::Doorkeeper::Orm::ActiveRecord::Mixins::SecretStorable
12
13
  # `enable_application_owner?` is read once, at parent-class autoload
13
14
  # time (#1831): with the feature off the model exposes no `:owner`
14
15
  # association — avoiding a misleading reflection on schemas that lack
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper::Orm::ActiveRecord::Mixins
4
+ # Active Record implementation of the SecretStorable write hook: the
5
+ # fallback secret upgrade only writes while the column still holds the
6
+ # value the lookup matched, so losing a race against a concurrent write —
7
+ # another request renewing the secret, or the application replacing it —
8
+ # cannot put the superseded secret back.
9
+ #
10
+ # The write is a single conditional statement, so unlike the historical
11
+ # `#update` it runs no model callbacks: what it writes is the value the
12
+ # lookup already matched, re-encoded into the current storage format.
13
+ module SecretStorable
14
+ extend ActiveSupport::Concern
15
+
16
+ class_methods do
17
+ # Writes the upgraded secret over +matched+ on +instance+'s row,
18
+ # conditional on +attr+ still holding +matched+, and answers whether
19
+ # the row was written.
20
+ def write_upgraded_secret(instance, attr, matched, _upgraded)
21
+ # The value written is the one `store_secret` left on the instance:
22
+ # it assigns through the attribute writer, which `#update` then ran
23
+ # again on assignment, so a custom writer override reached storage.
24
+ # `update_all` does not go through writers, so writing the strategy's
25
+ # pre-writer return value would both skip the override and leave the
26
+ # instance out of step with its row.
27
+ upgraded = instance.read_attribute(attr)
28
+
29
+ # `update_all` does not maintain timestamps, which `#update` did —
30
+ # see `upgrade_timestamps` for the terms it did that on.
31
+ changes = { attr => upgraded }.merge!(upgrade_timestamps(instance))
32
+
33
+ scope = where(primary_key_conditions(instance).merge(attr => matched))
34
+
35
+ # Under optimistic locking, `update_all` bumps the lock column on
36
+ # its own, which would leave the instance stale and have its next
37
+ # `save` refused. Write the bump explicitly instead — `update_all`
38
+ # leaves the column alone when it is among the changes — so that the
39
+ # instance can be brought in step below, and make the write
40
+ # conditional on the version too: bumping from a version the row no
41
+ # longer holds would set it backwards.
42
+ if locking_enabled?
43
+ version = instance.public_send(locking_column) || 0
44
+ scope = scope.where(locking_column => version)
45
+ changes[locking_column] = version + 1
46
+ end
47
+
48
+ # The write must reach the primary database when automatic role
49
+ # switching would route the surrounding request to a read replica.
50
+ written = with_primary_role { scope.update_all(changes) }
51
+ return false if written.zero?
52
+
53
+ sync_upgraded_instance(instance, changes)
54
+ true
55
+ end
56
+
57
+ # Restores +matched+ on the attribute directly, past any custom
58
+ # writer: re-running a writer that transforms its input would leave
59
+ # the attribute dirty with a value the row never held, and a later
60
+ # save would write that over whatever replaced the matched secret.
61
+ # The change is then cleared — the attribute holds what it held when
62
+ # the row was read, so there is nothing left to save.
63
+ def restore_matched_secret(instance, attr, matched)
64
+ instance.write_attribute(attr, matched)
65
+ instance.clear_attribute_changes([attr])
66
+ end
67
+
68
+ private
69
+
70
+ # The columns identifying +instance+'s row and the values it holds in
71
+ # them, taken from the configured primary key: it may be named
72
+ # something other than `id`, or be composed of several columns. A
73
+ # model with no primary key at all is left to the value condition
74
+ # alone, which reaches every row still holding the matched secret.
75
+ def primary_key_conditions(instance)
76
+ # Spelled without `index_with`, which the gemspec's Rails floor
77
+ # predates.
78
+ Array(primary_key).to_h { |key| [key, instance.public_send(key)] } # rubocop:disable Rails/IndexWith
79
+ end
80
+
81
+ # The timestamps `#update` maintained for this write, derived on its
82
+ # terms through Active Record's own helpers: the model's actual update
83
+ # timestamp columns (`updated_on` and aliased names as well as
84
+ # `updated_at`), the connection's timezone, and `record_timestamps`
85
+ # read on the instance, where a model that stamps its own timestamps
86
+ # can turn it off per record. A timestamp the caller had already
87
+ # changed is left out entirely, as `#update` leaves it to the caller —
88
+ # stamping it and then marking it clean below would silently drop that
89
+ # pending change.
90
+ def upgrade_timestamps(instance)
91
+ return {} unless instance.record_timestamps
92
+
93
+ touch_attributes_with_time.reject do |column, _time|
94
+ instance.will_save_change_to_attribute?(column)
95
+ end
96
+ end
97
+
98
+ # The row was written past the instance, so bring it back in step:
99
+ # left as it is, a caller that later saves or locks the record is
100
+ # refused over a change that is already persisted. Only what was
101
+ # written is marked clean — a `reload` would also discard every
102
+ # unrelated unsaved change the caller had on the instance, and this is
103
+ # nominally a read path.
104
+ def sync_upgraded_instance(instance, changes)
105
+ timestamp_attributes_for_update_in_model.each do |column|
106
+ instance.public_send(:"#{column}=", changes[column]) if changes.key?(column)
107
+ end
108
+ instance.public_send(:"#{locking_column}=", changes[locking_column]) if locking_enabled?
109
+ instance.clear_attribute_changes(changes.keys)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -34,8 +34,12 @@ module Doorkeeper
34
34
  Doorkeeper::OAuth::NonStandard::IETF_WG_OAUTH2_OOB_METHODS.include?(uri)
35
35
  end
36
36
 
37
+ # Script schemes are refused before the host application's rule is
38
+ # consulted, so a `forbid_redirect_uri` that rejects them too does not
39
+ # report the error twice.
37
40
  def forbidden_uri?(uri)
38
- Doorkeeper.config.forbid_redirect_uri.call(uri)
41
+ Doorkeeper::OAuth::Helpers::URIChecker.script_scheme?(uri) ||
42
+ Doorkeeper.config.forbid_redirect_uri.call(uri)
39
43
  end
40
44
 
41
45
  def unspecified_scheme?(uri)
@@ -26,6 +26,7 @@ module Doorkeeper
26
26
  autoload :AccessGrant, "doorkeeper/orm/active_record/mixins/access_grant"
27
27
  autoload :AccessToken, "doorkeeper/orm/active_record/mixins/access_token"
28
28
  autoload :Application, "doorkeeper/orm/active_record/mixins/application"
29
+ autoload :SecretStorable, "doorkeeper/orm/active_record/mixins/secret_storable"
29
30
  end
30
31
 
31
32
  # Kept as a no-op so `Doorkeeper.run_orm_hooks` (and any plugin that