doorkeeper 5.9.5 → 6.0.0.beta2

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -3
  3. data/README.md +204 -0
  4. data/app/controllers/doorkeeper/applications_controller.rb +38 -9
  5. data/app/controllers/doorkeeper/authorizations_controller.rb +17 -3
  6. data/app/controllers/doorkeeper/metadata_controller.rb +20 -0
  7. data/app/controllers/doorkeeper/tokens_controller.rb +15 -2
  8. data/app/views/doorkeeper/authorizations/new.html.erb +24 -0
  9. data/config/locales/en.yml +2 -0
  10. data/lib/doorkeeper/client_authentication/credentials.rb +11 -0
  11. data/lib/doorkeeper/client_authentication/fallback_method.rb +19 -0
  12. data/lib/doorkeeper/client_authentication/legacy_callable.rb +52 -0
  13. data/lib/doorkeeper/client_authentication/method.rb +40 -0
  14. data/lib/doorkeeper/client_authentication/registry.rb +47 -0
  15. data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
  16. data/lib/doorkeeper/client_authentication.rb +102 -0
  17. data/lib/doorkeeper/config/option.rb +1 -1
  18. data/lib/doorkeeper/config/validations.rb +162 -1
  19. data/lib/doorkeeper/config.rb +161 -9
  20. data/lib/doorkeeper/document_cache.rb +81 -0
  21. data/lib/doorkeeper/errors.rb +49 -0
  22. data/lib/doorkeeper/http_fetcher.rb +232 -0
  23. data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
  24. data/lib/doorkeeper/models/access_token_mixin.rb +81 -5
  25. data/lib/doorkeeper/models/application_mixin.rb +14 -6
  26. data/lib/doorkeeper/models/concerns/secret_storable.rb +10 -1
  27. data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
  28. data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
  29. data/lib/doorkeeper/oauth/authorization/uri_builder.rb +11 -0
  30. data/lib/doorkeeper/oauth/authorization_code_request.rb +127 -3
  31. data/lib/doorkeeper/oauth/base_request.rb +1 -2
  32. data/lib/doorkeeper/oauth/client.rb +24 -0
  33. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +57 -0
  34. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +33 -0
  35. data/lib/doorkeeper/oauth/client_authentication/none.rb +71 -0
  36. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
  37. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
  38. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
  39. data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
  40. data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
  41. data/lib/doorkeeper/oauth/code_response.rb +16 -2
  42. data/lib/doorkeeper/oauth/error_response.rb +11 -2
  43. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
  44. data/lib/doorkeeper/oauth/metadata_response.rb +165 -0
  45. data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
  46. data/lib/doorkeeper/oauth/pre_authorization.rb +63 -8
  47. data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
  48. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
  49. data/lib/doorkeeper/oauth/scopes.rb +25 -0
  50. data/lib/doorkeeper/oauth/token_introspection.rb +33 -7
  51. data/lib/doorkeeper/orm/active_record/mixins/application.rb +2 -2
  52. data/lib/doorkeeper/rails/routes/mapping.rb +1 -0
  53. data/lib/doorkeeper/rails/routes.rb +6 -0
  54. data/lib/doorkeeper/request.rb +59 -0
  55. data/lib/doorkeeper/server.rb +5 -2
  56. data/lib/doorkeeper/version.rb +4 -4
  57. data/lib/doorkeeper.rb +12 -4
  58. data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
  59. data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
  60. data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
  61. data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
  62. data/lib/generators/doorkeeper/templates/initializer.rb +131 -6
  63. data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
  64. metadata +52 -4
  65. data/lib/doorkeeper/oauth/client/credentials.rb +0 -71
  66. data/lib/doorkeeper/oauth/stateless_token.rb +0 -139
@@ -92,13 +92,16 @@ module Doorkeeper
92
92
  # A nil value will ignore custom attributes, while an empty hash will
93
93
  # only match tokens that have no custom attributes set.
94
94
  #
95
+ # @yield [token] optional additional predicate a token must satisfy to
96
+ # count as a match (e.g. the refresh token requirement of the request).
97
+ #
95
98
  # @return [Doorkeeper::AccessToken, nil] Access Token instance or
96
99
  # nil if matching record was not found
97
100
  #
98
- def matching_token_for(application, resource_owner, scopes, custom_attributes: nil, include_expired: true)
101
+ def matching_token_for(application, resource_owner, scopes, custom_attributes: nil, include_expired: true, &filter)
99
102
  tokens = authorized_tokens_for(application&.id, resource_owner)
100
103
  tokens = tokens.not_expired unless include_expired
101
- find_matching_token(tokens, application, custom_attributes, scopes)
104
+ find_matching_token(tokens, application, custom_attributes, scopes, &filter)
102
105
  end
103
106
 
104
107
  # Interface to enumerate access token records in batches in order not
@@ -131,7 +134,7 @@ module Doorkeeper
131
134
  # @return [Doorkeeper::AccessToken, nil] Access Token instance or
132
135
  # nil if matching record was not found
133
136
  #
134
- def find_matching_token(relation, application, custom_attributes, scopes)
137
+ def find_matching_token(relation, application, custom_attributes, scopes, &filter)
135
138
  return nil unless relation
136
139
 
137
140
  matching_tokens = []
@@ -140,7 +143,8 @@ module Doorkeeper
140
143
  find_access_token_in_batches(relation, batch_size: batch_size) do |batch|
141
144
  tokens = batch.select do |token|
142
145
  scopes_match?(token.scopes, scopes, application&.scopes) &&
143
- custom_attributes_match?(token, custom_attributes)
146
+ custom_attributes_match?(token, custom_attributes) &&
147
+ (filter.nil? || filter.call(token))
144
148
  end
145
149
 
146
150
  matching_tokens.concat(tokens)
@@ -149,6 +153,32 @@ module Doorkeeper
149
153
  matching_tokens.max_by(&:created_at)
150
154
  end
151
155
 
156
+ # Checks whether a candidate token for reuse matches the refresh token
157
+ # requirement of the current request.
158
+ #
159
+ # The candidate's refresh token presence must match what the request asks
160
+ # for, in both directions. A request that expects a refresh token (e.g. an
161
+ # authorization_code or password grant while `use_refresh_token` is
162
+ # enabled) must not reuse a token issued without one, or the response would
163
+ # silently omit the refresh token. Conversely, a request that does not ask
164
+ # for a refresh token must not reuse a token that carries one, or the
165
+ # response would return a refresh token the request never requested (this
166
+ # is reachable when `refresh_token_enabled` is a per-request callable).
167
+ # When no matching token satisfies the requirement a fresh token is
168
+ # issued instead.
169
+ #
170
+ # @param access_token [Doorkeeper::AccessToken]
171
+ # the candidate token for reuse
172
+ # @param token_attributes [Hash]
173
+ # attributes for the token being requested
174
+ #
175
+ # @return [Boolean] true if the candidate token's refresh token presence
176
+ # matches the request's refresh token requirement, false otherwise
177
+ #
178
+ def refresh_token_matches?(access_token, token_attributes)
179
+ access_token.refresh_token.present? == !!token_attributes[:use_refresh_token]
180
+ end
181
+
152
182
  # Checks whether the token scopes match the scopes from the parameters
153
183
  #
154
184
  # @param token_scopes [#to_s]
@@ -199,6 +229,39 @@ module Doorkeeper
199
229
  end
200
230
  end
201
231
 
232
+ # RFC 8707: checks whether an existing token's audience matches the
233
+ # requested resource indicators. Used during token reuse to prevent
234
+ # returning a token audience-restricted to one resource for a request
235
+ # targeting a different resource.
236
+ #
237
+ # The comparison runs whenever either side carries a resource, even when
238
+ # no validator is configured: a grant bound to resources still restricts
239
+ # the token's audience (see AuthorizationCodeRequest), so reuse must not
240
+ # silently widen it by matching an unrestricted or differently-scoped
241
+ # token. When both sides are blank the tokens are unrestricted and match.
242
+ #
243
+ # @param token [Doorkeeper::AccessToken] existing token
244
+ # @param requested_resource [String, nil] space-delimited resource URIs
245
+ # @return [Boolean]
246
+ def resource_indicators_match?(token, requested_resource)
247
+ token_resource = token.try(:resource)
248
+
249
+ # Both blank — neither is audience-restricted, match.
250
+ return true if token_resource.blank? && requested_resource.blank?
251
+ # One blank, the other not — mismatch.
252
+ return false if token_resource.blank? || requested_resource.blank?
253
+
254
+ # Both present — compare as sorted sets.
255
+ token_resource.split.sort == requested_resource.split.sort
256
+ end
257
+
258
+ # RFC 8707: resource indicators are supported only when the
259
+ # `resource` column exists (added by the
260
+ # `doorkeeper:resource_indicators` generator).
261
+ def resource_indicators_supported?
262
+ column_names.include?("resource")
263
+ end
264
+
202
265
  # Looking for not expired AccessToken record with a matching set of
203
266
  # scopes that belongs to specific Application and Resource Owner.
204
267
  # If it doesn't exists - then creates it.
@@ -226,9 +289,22 @@ module Doorkeeper
226
289
  # attributes when matching, while an empty hash only matches tokens
227
290
  # that have no custom attributes set.
228
291
  custom_attributes = extract_custom_attributes(token_attributes)
292
+ # The refresh token requirement participates in the matching itself
293
+ # rather than being checked on its single newest result: the newest
294
+ # matching token may carry the wrong refresh token presence (e.g. it
295
+ # was issued through a grant with a different `use_refresh_token`)
296
+ # while an older token satisfies the request and can still be reused.
297
+ #
298
+ # RFC 8707: resource indicators must also match so that a token
299
+ # audience-restricted to one resource is never reused for another.
300
+ requested_resource = token_attributes[:resource]
301
+
229
302
  access_token = matching_token_for(
230
303
  application, resource_owner, scopes, custom_attributes: custom_attributes, include_expired: false,
231
- )
304
+ ) do |token|
305
+ refresh_token_matches?(token, token_attributes) &&
306
+ resource_indicators_match?(token, requested_resource)
307
+ end
232
308
 
233
309
  return access_token if access_token&.reusable?
234
310
  end
@@ -5,6 +5,7 @@ module Doorkeeper
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  include OAuth::Helpers
8
+ include Models::Concerns::WriteToPrimary
8
9
  include Models::Orderable
9
10
  include Models::SecretStorable
10
11
  include Models::Scopes
@@ -27,9 +28,8 @@ module Doorkeeper
27
28
  app = by_uid(uid)
28
29
  return unless app
29
30
  return app if secret.blank? && !app.confidential?
30
- return unless app.secret_matches?(secret)
31
31
 
32
- app
32
+ app if app.secret_matches?(secret)
33
33
  end
34
34
 
35
35
  # Returns an instance of the Doorkeeper::Application with specific UID.
@@ -76,18 +76,26 @@ module Doorkeeper
76
76
  # @return [Boolean] Whether the given secret matches the stored secret
77
77
  # of this application.
78
78
  #
79
+ # @note When the secret matches only via the fallback strategy, the stored
80
+ # secret is upgraded to the active strategy as a side-effect (mirrors
81
+ # the find_by_plaintext_token -> find_by_fallback_token pattern).
82
+ #
79
83
  def secret_matches?(input)
80
84
  # return false if either is nil, since secure_compare depends on strings
81
85
  # but Application secrets MAY be nil depending on confidentiality.
82
86
  return false if input.nil? || secret.nil?
83
87
 
88
+ input = input.to_s
89
+
84
90
  # When matching the secret by comparer function, all is well.
85
91
  return true if secret_strategy.secret_matches?(input, secret)
86
92
 
87
- # When fallback lookup is enabled, ensure applications
88
- # with plain secrets can still be found
89
- if fallback_secret_strategy
90
- fallback_secret_strategy.secret_matches?(input, secret)
93
+ # When fallback lookup is enabled, ensure applications with plain secrets
94
+ # can still be found, upgrading the stored secret to the active strategy
95
+ # on a successful match.
96
+ if fallback_secret_strategy&.secret_matches?(input, secret)
97
+ self.class.upgrade_fallback_value(self, :secret, input)
98
+ true
91
99
  else
92
100
  false
93
101
  end
@@ -84,7 +84,16 @@ module Doorkeeper
84
84
  #
85
85
  def upgrade_fallback_value(instance, attr, plain_secret)
86
86
  upgraded = secret_strategy.store_secret(instance, attr, plain_secret)
87
- instance.update(attr => upgraded)
87
+
88
+ # The upgrade is a write on what is otherwise a read path (finding a
89
+ # record by its secret), so it must reach the primary database when
90
+ # automatic role switching would route the surrounding request to a
91
+ # read replica.
92
+ if respond_to?(:with_primary_role)
93
+ with_primary_role { instance.update(attr => upgraded) }
94
+ else
95
+ instance.update(attr => upgraded)
96
+ end
88
97
  end
89
98
 
90
99
  ##
@@ -47,6 +47,16 @@ module Doorkeeper
47
47
  attributes[:resource_owner_id] = resource_owner.id
48
48
  end
49
49
 
50
+ # RFC 8707: persist resource indicators so they can be enforced at
51
+ # the token endpoint (subset validation) and carried to the token.
52
+ if pre_auth.respond_to?(:resource_indicators) && pre_auth.resource_indicators.present?
53
+ unless Doorkeeper.config.access_grant_model.resource_indicators_supported?
54
+ raise Doorkeeper::Errors::MissingResourceColumn, "oauth_access_grants"
55
+ end
56
+
57
+ attributes[:resource] = pre_auth.resource_indicators.join(" ")
58
+ end
59
+
50
60
  pkce_attributes.merge(attributes).merge(custom_attributes)
51
61
  end
52
62
 
@@ -59,13 +59,24 @@ module Doorkeeper
59
59
  resource_owner,
60
60
  )
61
61
 
62
- @token = Doorkeeper.config.access_token_model.find_or_create_for(
62
+ token_attributes = {
63
63
  application: application,
64
64
  resource_owner: resource_owner,
65
65
  scopes: pre_auth.scopes,
66
66
  expires_in: self.class.access_token_expires_in(Doorkeeper.config, context),
67
67
  use_refresh_token: false,
68
- )
68
+ }
69
+
70
+ # RFC 8707: carry resource indicators to the access token
71
+ if pre_auth.respond_to?(:resource_indicators) && pre_auth.resource_indicators.present?
72
+ unless Doorkeeper.config.access_token_model.resource_indicators_supported?
73
+ raise Doorkeeper::Errors::MissingResourceColumn, "oauth_access_tokens"
74
+ end
75
+
76
+ token_attributes[:resource] = pre_auth.resource_indicators.join(" ")
77
+ end
78
+
79
+ @token = Doorkeeper.config.access_token_model.find_or_create_for(**token_attributes)
69
80
  end
70
81
 
71
82
  def application
@@ -10,6 +10,17 @@ module Doorkeeper
10
10
  def uri_with_query(url, parameters = {})
11
11
  uri = URI.parse(url)
12
12
  original_query = Rack::Utils.parse_query(uri.query)
13
+ # `parse_query` yields string keys while `parameters` uses symbol
14
+ # keys, so a naive merge cannot dedupe a collision (e.g. a registered
15
+ # redirect_uri already carrying `state`) and would emit the param
16
+ # twice. Normalize keys so the response parameters win over any
17
+ # same-named query already present in the redirect_uri. Blank
18
+ # response parameters are dropped before the merge: they carry no
19
+ # value to respond with, and letting them clobber a same-named
20
+ # registered parameter would violate RFC 6749 §3.1.2 (the registered
21
+ # query component "MUST be retained when adding additional query
22
+ # parameters").
23
+ parameters = parameters.transform_keys(&:to_s).reject { |_, value| value.blank? }
13
24
  uri.query = build_query(original_query.merge(parameters))
14
25
  uri.to_s
15
26
  end
@@ -9,10 +9,17 @@ module Doorkeeper
9
9
  # @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
10
10
  validate :redirect_uri, error: Errors::InvalidGrant
11
11
  validate :code_verifier, error: Errors::InvalidGrant
12
+ # Runs last, so the single-use enforcement it performs only acts once
13
+ # the caller has proven possession of the code (redirect_uri + PKCE).
14
+ validate :grant_accessible, error: Errors::InvalidGrant
15
+ validate :resource_indicators, error: Errors::InvalidTarget
12
16
 
13
17
  attr_reader :grant, :client, :redirect_uri, :access_token, :code_verifier,
14
18
  :invalid_request_reason, :missing_param
15
19
 
20
+ # A scope parameter is deliberately not read here: RFC 6749 does not
21
+ # define one for the authorization_code token request (§4.1.3), so it
22
+ # is ignored and the access token inherits the scopes of the grant.
16
23
  def initialize(server, grant, client, parameters = {})
17
24
  super()
18
25
  @server = server
@@ -21,6 +28,7 @@ module Doorkeeper
21
28
  @grant_type = Doorkeeper::OAuth::AUTHORIZATION_CODE
22
29
  @redirect_uri = parameters[:redirect_uri]
23
30
  @code_verifier = parameters[:code_verifier]
31
+ @raw_resource_indicators = parameters[:resource]
24
32
  end
25
33
 
26
34
  private
@@ -36,16 +44,40 @@ module Doorkeeper
36
44
 
37
45
  grant.revoke
38
46
 
47
+ token_attributes = custom_token_attributes_with_data
48
+ # RFC 8707 §2.2: audience-restrict the access token to the resources
49
+ # bound to the grant. When the token request specifies a (valid)
50
+ # subset, use that subset; when it omits `resource`, inherit the
51
+ # grant's full resource set so the token is never issued without an
52
+ # audience restriction.
53
+ effective_resources = resolved_resource_indicators.presence || grant_resource_indicators
54
+ if effective_resources.present?
55
+ unless Doorkeeper.config.access_token_model.resource_indicators_supported?
56
+ raise Errors::MissingResourceColumn, "oauth_access_tokens"
57
+ end
58
+
59
+ token_attributes[:resource] = effective_resources.join(" ")
60
+ end
61
+
39
62
  find_or_create_access_token(
40
63
  client,
41
64
  resource_owner,
42
65
  grant.scopes,
43
- custom_token_attributes_with_data,
66
+ token_attributes,
44
67
  server,
45
68
  )
69
+
70
+ link_access_token_to_grant
46
71
  end
47
72
 
48
73
  super
74
+ rescue Errors::InvalidGrantReuse
75
+ # A concurrent exchange of the same code won the race: the raise
76
+ # rolled this transaction back, so the revocation must happen
77
+ # outside of it. `lock!` reloaded the grant after the winning
78
+ # exchange committed, so the token linkage is visible here.
79
+ revoke_token_issued_for_grant
80
+ raise
49
81
  end
50
82
 
51
83
  def resource_owner
@@ -64,7 +96,7 @@ module Doorkeeper
64
96
  @missing_param =
65
97
  if grant&.uses_pkce? && code_verifier.blank?
66
98
  :code_verifier
67
- elsif client && !client.confidential && Doorkeeper.config.force_pkce? && code_verifier.blank?
99
+ elsif client && Doorkeeper.config.force_pkce? && code_verifier.blank?
68
100
  :code_verifier
69
101
  elsif redirect_uri.blank?
70
102
  :redirect_uri
@@ -78,7 +110,16 @@ module Doorkeeper
78
110
  end
79
111
 
80
112
  def validate_grant
81
- return false unless grant && grant.application_id == client.id
113
+ grant && grant.application_id == client.id
114
+ end
115
+
116
+ # Checked after redirect_uri and PKCE so that a caller who cannot prove
117
+ # possession of the code never reaches the reuse handling below.
118
+ def validate_grant_accessible
119
+ # Authorization codes are single-use (RFC 6749 §4.1.2): observing a
120
+ # second exchange attempt denies the request and revokes the tokens
121
+ # already issued for the code (§10.5).
122
+ revoke_token_issued_for_grant if grant.revoked?
82
123
 
83
124
  grant.accessible?
84
125
  end
@@ -117,9 +158,92 @@ module Doorkeeper
117
158
  .symbolize_keys
118
159
  end
119
160
 
161
+ # RFC 8707: validate resource indicators on the token request.
162
+ # If the grant carries resource indicators, the token request's resource
163
+ # parameter must be a subset. If no grant resource is present, the
164
+ # validator checks the request resource against server policy.
165
+ #
166
+ # Subset and syntax enforcement run even when no validator is configured
167
+ # as long as the grant is already audience-restricted: a grant bound to
168
+ # resources must never be exchanged for a token whose audience widens
169
+ # beyond it. Only when the feature is disabled AND the grant has no
170
+ # stored resources is the `resource` parameter ignored entirely.
171
+ def validate_resource_indicators
172
+ @grant_resource_indicators = grant&.try(:resource)&.split
173
+
174
+ validator = Doorkeeper.config.resource_indicator_validator
175
+
176
+ # Feature effectively off: no validator and nothing already bound to
177
+ # enforce against. Ignore the `resource` parameter.
178
+ return true if validator.nil? && @grant_resource_indicators.blank?
179
+
180
+ @resolved_resource_indicators = ResourceIndicatorValidator.validate!(
181
+ @raw_resource_indicators,
182
+ config_validator: validator,
183
+ client: client,
184
+ grant_resource_indicators: @grant_resource_indicators,
185
+ )
186
+ true
187
+ rescue Errors::InvalidTarget
188
+ false
189
+ end
190
+
191
+ def resolved_resource_indicators
192
+ @resolved_resource_indicators || []
193
+ end
194
+
195
+ def grant_resource_indicators
196
+ @grant_resource_indicators || []
197
+ end
198
+
120
199
  def revoke_previous_tokens(application, resource_owner)
121
200
  Doorkeeper.config.access_token_model.revoke_all_for(application.id, resource_owner)
122
201
  end
202
+
203
+ def link_access_token_to_grant
204
+ return unless grant.class.access_token_revoked_on_reuse?
205
+
206
+ grant.class.with_primary_role do
207
+ grant.update_column(:access_token_id, access_token.id)
208
+ end
209
+ end
210
+
211
+ def revoke_token_issued_for_grant
212
+ return unless grant.class.access_token_revoked_on_reuse?
213
+ return if grant.access_token_id.blank?
214
+
215
+ # Look the token up on the primary too: a lagging read replica may not
216
+ # have it yet, which would silently skip the revocation.
217
+ Doorkeeper.config.access_token_model.with_primary_role do
218
+ token = Doorkeeper.config.access_token_model.find_by(id: grant.access_token_id)
219
+ next if token.nil?
220
+
221
+ # With `reuse_access_token` the same token can back several grants
222
+ # (find_or_create returns a shared one). Revoking it on a replay of
223
+ # this grant's code would take down another valid session that still
224
+ # holds it. Only revoke when no other grant references the token, so
225
+ # the single-use revocation reaches a token unique to the replayed
226
+ # code and never collaterally revokes a reused, shared one.
227
+ next if token_shared_with_other_grant?(token)
228
+
229
+ token.revoke
230
+ end
231
+ end
232
+
233
+ # Reads the grant -> token link, which lives in the optional
234
+ # `oauth_access_grants.access_token_id` column: new installs get it from
235
+ # the generated migration, existing apps add it with the
236
+ # `doorkeeper:grant_reuse_revocation` generator. Callers must therefore
237
+ # guard with `access_token_revoked_on_reuse?` (as
238
+ # `link_access_token_to_grant` and `revoke_token_issued_for_grant` do),
239
+ # so an app that never ran the generator returns early and never queries
240
+ # a column it does not have.
241
+ def token_shared_with_other_grant?(token)
242
+ grant.class
243
+ .where(access_token_id: token.id)
244
+ .where.not(id: grant.id)
245
+ .exists?
246
+ end
123
247
  end
124
248
  end
125
249
  end
@@ -59,8 +59,7 @@ module Doorkeeper
59
59
  client_scopes = @client&.scopes
60
60
  return default_scopes if client_scopes.blank?
61
61
 
62
- # Avoid using Scope#& for dynamic scopes
63
- client_scopes.allowed(default_scopes)
62
+ client_scopes.common(default_scopes)
64
63
  end
65
64
  end
66
65
  end
@@ -1,8 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The full registry rather than just credentials: requiring only
4
+ # client_authentication/credentials fires the ClientAuthentication autoload
5
+ # mid-load, which re-requires the in-progress file and warns under -w
6
+ # ("circular require considered harmful").
7
+ require "doorkeeper/client_authentication"
8
+
3
9
  module Doorkeeper
4
10
  module OAuth
5
11
  class Client
12
+ # @deprecated Moved to +Doorkeeper::ClientAuthentication::Credentials+.
13
+ # This alias keeps the long-standing +Doorkeeper::OAuth::Client::Credentials+
14
+ # constant resolvable for one release so referencing code does not raise
15
+ # +NameError+; update references to the new constant. Note the legacy
16
+ # +.from_request+/+.from_basic+/+.from_params+ class methods are gone —
17
+ # client credential extraction now goes through the client authentication
18
+ # registry (RFC 6749 §2.3). Marked with +deprecate_constant+, so Ruby
19
+ # warns on access when deprecation warnings are enabled
20
+ # (+Warning[:deprecated] = true+ or +-W:deprecated+).
21
+ Credentials = Doorkeeper::ClientAuthentication::Credentials
22
+ deprecate_constant :Credentials
23
+
6
24
  attr_reader :application
7
25
 
8
26
  delegate :id, :name, :uid, :redirect_uri, :scopes, :confidential, to: :@application
@@ -19,6 +37,12 @@ module Doorkeeper
19
37
 
20
38
  def self.authenticate(credentials, method = Doorkeeper.config.application_model.method(:by_uid_and_secret))
21
39
  return if credentials.blank?
40
+
41
+ # Credentials that were fully authenticated by their client
42
+ # authentication method (e.g. a verified private_key_jwt assertion)
43
+ # carry no secret to compare — resolve the client by uid alone.
44
+ return find(credentials.uid) if credentials.respond_to?(:pre_authenticated?) && credentials.pre_authenticated?
45
+
22
46
  return unless (application = method.call(credentials.uid, credentials.secret))
23
47
 
24
48
  new(application)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper
4
+ module OAuth
5
+ module ClientAuthentication
6
+ # RFC 6749 §2.3.1 "client_secret_basic": client credentials are sent
7
+ # using HTTP Basic authentication.
8
+ #
9
+ # Known deviation: §2.3.1 also requires the client_id and client_secret
10
+ # to be form-urlencoded before being placed into the Basic header.
11
+ # Doorkeeper has never URL-decoded them (like much of the ecosystem),
12
+ # and this strategy deliberately keeps that behaviour — adding the
13
+ # decoding now would break every existing client whose credentials
14
+ # contain URL-encodable characters.
15
+ class ClientSecretBasic
16
+ def self.uses_shared_secret?
17
+ true
18
+ end
19
+
20
+ # Match whenever the header decodes to a non-blank +client_id+ — i.e.
21
+ # whenever a Basic authentication *attempt* is present. The secret may
22
+ # be empty (public clients) or missing; those are still Basic auth
23
+ # attempts and must be claimed here so that invalid credentials fail
24
+ # with +invalid_client+ instead of silently falling through to another
25
+ # configured method or the fallback (which would downgrade a failed
26
+ # authentication attempt to "no authentication provided").
27
+ def self.matches_request?(request)
28
+ credentials_from(request).present?
29
+ end
30
+
31
+ def self.authenticate(request)
32
+ client_id, client_secret = credentials_from(request)
33
+ return unless client_id
34
+
35
+ Doorkeeper::ClientAuthentication::Credentials.new(client_id, client_secret)
36
+ end
37
+
38
+ # Returns the decoded [client_id, client_secret] pair, or nil when the
39
+ # request carries no HTTP Basic +client_id+. A header that merely
40
+ # starts with "Basic " but decodes to an empty/blank client_id (e.g.
41
+ # an empty payload or a leading ":") is not a usable attempt and does
42
+ # not match.
43
+ def self.credentials_from(request)
44
+ authorization = request.authorization.to_s
45
+ return unless authorization.downcase.start_with?("basic ")
46
+
47
+ value = authorization.split(" ", 2).last
48
+ client_id, client_secret = Base64.decode64(value.to_s).split(":", 2)
49
+ return if client_id.blank?
50
+
51
+ [client_id, client_secret]
52
+ end
53
+ private_class_method :credentials_from
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper
4
+ module OAuth
5
+ module ClientAuthentication
6
+ # RFC 6749 §2.3.1 "client_secret_post": client credentials are sent in
7
+ # the request body. The query string is intentionally ignored so that
8
+ # credentials must be supplied in the body as the spec requires.
9
+ class ClientSecretPost
10
+ def self.uses_shared_secret?
11
+ true
12
+ end
13
+
14
+ def self.matches_request?(request)
15
+ params = request.request_parameters.with_indifferent_access
16
+
17
+ request.post? &&
18
+ params[:client_id].present? &&
19
+ params[:client_secret].present?
20
+ end
21
+
22
+ def self.authenticate(request)
23
+ params = request.request_parameters.with_indifferent_access
24
+
25
+ Doorkeeper::ClientAuthentication::Credentials.new(
26
+ params[:client_id],
27
+ params[:client_secret],
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper
4
+ module OAuth
5
+ module ClientAuthentication
6
+ # RFC 6749 §2.3 "none": a public client that authenticates with only a
7
+ # client_id and no secret (in the request body, not the query string).
8
+ class None
9
+ # The absence of client authentication involves no secret at all.
10
+ def self.uses_shared_secret?
11
+ false
12
+ end
13
+
14
+ # Rejects a request that carries header-based client authentication (a
15
+ # +Basic+ credential, or any non-blank Authorization header that is not
16
+ # a bearer token): such a request must not be silently treated as an
17
+ # unauthenticated public client. This is narrower than the legacy
18
+ # +from_params+ extractor, which read the body +client_id+ regardless
19
+ # of the Authorization header.
20
+ #
21
+ # A +Bearer+ Authorization header is the exception: it authorizes
22
+ # access to the endpoint itself (e.g. a bearer-protected introspection
23
+ # endpoint, RFC 7662 §2.1, or a revocation request) rather than
24
+ # authenticating the client, so it must not suppress the +none+
25
+ # strategy for a public client that identifies itself with a body
26
+ # +client_id+.
27
+ #
28
+ # A request carrying a client_assertion is likewise attempting real
29
+ # client authentication (RFC 7521 allows a bare client_id next to the
30
+ # assertion), so it must not be picked up as an unauthenticated
31
+ # public client no matter where this method sits in the configured
32
+ # order.
33
+ def self.matches_request?(request)
34
+ params = request.request_parameters.with_indifferent_access
35
+
36
+ request.post? &&
37
+ !client_authentication_header?(request) &&
38
+ params[:client_id].present? &&
39
+ params[:client_secret].blank? &&
40
+ params[:client_assertion].blank?
41
+ end
42
+
43
+ # A blank Authorization header carries no client authentication; a
44
+ # bearer token authorizes the endpoint rather than the client. Every
45
+ # other non-blank scheme (Basic, etc.) is header-based client
46
+ # authentication. The pattern tolerates the optional whitespace HTTP
47
+ # allows before the scheme and requires whitespace between the scheme
48
+ # and the token, accepting in both places only the spaces and tabs
49
+ # that OWS is made of (RFC 9110 §5.6.3): a well-formed Bearer header
50
+ # is therefore not misclassified, while a value that relies on any
51
+ # other character in those positions is not taken for a bearer
52
+ # credential. A bearer credential also carries at least one token
53
+ # character after the scheme (RFC 6750 §2.1), so a scheme-only value
54
+ # such as "Bearer " is not exempted either.
55
+ def self.client_authentication_header?(request)
56
+ header = request.authorization
57
+ return false if header.blank?
58
+
59
+ !header.match?(/\A[ \t]*Bearer[ \t]+\S/i)
60
+ end
61
+ private_class_method :client_authentication_header?
62
+
63
+ def self.authenticate(request)
64
+ params = request.request_parameters.with_indifferent_access
65
+
66
+ Doorkeeper::ClientAuthentication::Credentials.new(params[:client_id], nil)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end