doorkeeper 5.9.9 → 6.0.0.beta1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -22
  3. data/README.md +1 -0
  4. data/app/controllers/doorkeeper/authorizations_controller.rb +3 -33
  5. data/app/controllers/doorkeeper/authorized_applications_controller.rb +0 -23
  6. data/app/controllers/doorkeeper/metadata_controller.rb +20 -0
  7. data/app/controllers/doorkeeper/tokens_controller.rb +10 -1
  8. data/app/views/doorkeeper/authorizations/new.html.erb +6 -0
  9. data/lib/doorkeeper/client_authentication/credentials.rb +11 -0
  10. data/lib/doorkeeper/client_authentication/fallback_method.rb +19 -0
  11. data/lib/doorkeeper/client_authentication/legacy_callable.rb +46 -0
  12. data/lib/doorkeeper/client_authentication/method.rb +23 -0
  13. data/lib/doorkeeper/client_authentication/registry.rb +47 -0
  14. data/lib/doorkeeper/client_authentication.rb +93 -0
  15. data/lib/doorkeeper/config/option.rb +1 -1
  16. data/lib/doorkeeper/config/validations.rb +162 -1
  17. data/lib/doorkeeper/config.rb +114 -15
  18. data/lib/doorkeeper/errors.rb +1 -0
  19. data/lib/doorkeeper/models/access_token_mixin.rb +40 -5
  20. data/lib/doorkeeper/models/application_mixin.rb +14 -6
  21. data/lib/doorkeeper/models/concerns/secret_storable.rb +10 -1
  22. data/lib/doorkeeper/oauth/authorization/uri_builder.rb +11 -0
  23. data/lib/doorkeeper/oauth/authorization_code_request.rb +4 -1
  24. data/lib/doorkeeper/oauth/client.rb +18 -0
  25. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +53 -0
  26. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +29 -0
  27. data/lib/doorkeeper/oauth/client_authentication/none.rb +32 -0
  28. data/lib/doorkeeper/oauth/code_response.rb +16 -2
  29. data/lib/doorkeeper/oauth/error_response.rb +11 -2
  30. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +0 -14
  31. data/lib/doorkeeper/oauth/metadata_response.rb +157 -0
  32. data/lib/doorkeeper/oauth/pre_authorization.rb +12 -32
  33. data/lib/doorkeeper/oauth/token.rb +2 -95
  34. data/lib/doorkeeper/oauth/token_introspection.rb +27 -7
  35. data/lib/doorkeeper/orm/active_record/mixins/application.rb +2 -2
  36. data/lib/doorkeeper/orm/active_record/redirect_uri_validator.rb +1 -5
  37. data/lib/doorkeeper/rails/routes/mapping.rb +1 -0
  38. data/lib/doorkeeper/rails/routes.rb +6 -0
  39. data/lib/doorkeeper/request.rb +65 -18
  40. data/lib/doorkeeper/server.rb +5 -2
  41. data/lib/doorkeeper/version.rb +4 -4
  42. data/lib/doorkeeper.rb +8 -4
  43. data/lib/generators/doorkeeper/templates/initializer.rb +67 -18
  44. metadata +14 -7
  45. data/lib/doorkeeper/oauth/client/credentials.rb +0 -86
@@ -8,14 +8,68 @@ module Doorkeeper
8
8
  # Validates configuration options to be set properly.
9
9
  #
10
10
  def validate!
11
+ validate_client_authentication_conflict
12
+ validate_client_authentication_registered
11
13
  validate_reuse_access_token_value
12
14
  validate_token_reuse_limit
13
15
  validate_secret_strategies
14
16
  validate_pkce_code_challenge_methods
17
+ validate_custom_metadata
18
+ validate_refresh_token_flow
19
+ validate_issuer_format
20
+ validate_issuer_metadata_discoverability
15
21
  end
16
22
 
17
23
  private
18
24
 
25
+ # Warn once, at configuration time, when both the deprecated
26
+ # +client_credentials+ and the new +client_authentication+ options are
27
+ # set. +client_authentication+ takes precedence; the warning lives here
28
+ # (rather than in the memoised resolver) so it is not swallowed and
29
+ # surfaces during boot instead of on the first request.
30
+ def validate_client_authentication_conflict
31
+ return unless instance_variable_defined?(:@client_credentials_methods) &&
32
+ instance_variable_defined?(:@client_authentication)
33
+
34
+ ::Rails.logger.warn(
35
+ "[DOORKEEPER] Both client_credentials and client_authentication are set, " \
36
+ "using client_authentication",
37
+ )
38
+ end
39
+
40
+ # Warn about configured client authentication methods that are not
41
+ # registered (e.g. a typo, or an extension that failed to load). Such
42
+ # names are silently ignored when resolving the methods, which could
43
+ # otherwise leave the application with no usable authentication methods.
44
+ def validate_client_authentication_registered
45
+ # The deprecated client_credentials path already validates its own input.
46
+ return if instance_variable_defined?(:@client_credentials_methods) &&
47
+ !instance_variable_defined?(:@client_authentication)
48
+
49
+ configured = client_authentication
50
+ unknown = configured.reject { |name| Doorkeeper::ClientAuthentication.get(name) }
51
+
52
+ unless unknown.empty?
53
+ ::Rails.logger.warn(
54
+ "[DOORKEEPER] Unknown client authentication method(s) configured and will be ignored: " \
55
+ "#{unknown.map(&:inspect).join(", ")}. " \
56
+ "Ensure each method is registered (e.g. by the extension that provides it).",
57
+ )
58
+ end
59
+
60
+ # A configuration that resolves to zero usable methods fails every
61
+ # client authentication attempt (all requests fall back to no
62
+ # credentials), so make that loud rather than silent.
63
+ return unless (configured - unknown).empty?
64
+
65
+ ::Rails.logger.error(
66
+ "[DOORKEEPER] No usable client authentication methods are configured " \
67
+ "(client_authentication resolved to an empty set). All client authentication " \
68
+ "will fail. Configure at least one registered method, e.g. " \
69
+ "client_authentication #{Doorkeeper::ClientAuthentication::DEFAULT_METHODS.inspect}.",
70
+ )
71
+ end
72
+
19
73
  # Determine whether +reuse_access_token+ and a non-restorable
20
74
  # +token_secret_strategy+ have both been activated.
21
75
  #
@@ -51,7 +105,15 @@ module Doorkeeper
51
105
  end
52
106
 
53
107
  def validate_pkce_code_challenge_methods
54
- return if pkce_code_challenge_methods.all? { |method| method =~ /^plain$|^S256$/ }
108
+ methods = pkce_code_challenge_methods.map(&:to_s)
109
+ if methods.all? { |method| method.match?(/\A(?:plain|S256)\z/) }
110
+ # Persist the normalized (string) values only when the option was
111
+ # explicitly configured — the default is already normalized, and an
112
+ # unconfigured option should stay undefined rather than have
113
+ # validation flip its instance_variable_defined? signal.
114
+ @pkce_code_challenge_methods = methods if instance_variable_defined?(:@pkce_code_challenge_methods)
115
+ return
116
+ end
55
117
 
56
118
  ::Rails.logger.warn(
57
119
  "[DOORKEEPER] You have configured an invalid value for pkce_code_challenge_methods option. " \
@@ -60,6 +122,105 @@ module Doorkeeper
60
122
 
61
123
  @pkce_code_challenge_methods = ["plain", "S256"]
62
124
  end
125
+
126
+ def validate_custom_metadata
127
+ return if custom_metadata.is_a? Hash
128
+
129
+ ::Rails.logger.warn(
130
+ "[DOORKEEPER] You have configured an invalid value for custom_metadata option. " \
131
+ "It must be a Hash, and will be overridden with an empty hash.",
132
+ )
133
+
134
+ @custom_metadata = {}
135
+ end
136
+
137
+ # Warn when the refresh_token grant flow is enabled but refresh tokens
138
+ # are never issued: refresh token requests would always fail because
139
+ # there are no tokens to refresh. The flow is enabled automatically
140
+ # when +use_refresh_token+ is configured, so it doesn't need to be
141
+ # listed in +grant_flows+ explicitly.
142
+ #
143
+ # +calculate_grant_flows+ is used (rather than the raw +grant_flows+) so
144
+ # that the flow is also detected when enabled through a registered
145
+ # grant-flow alias. When +use_refresh_token+ is not configured the
146
+ # refresh_token flow is not appended implicitly, so its presence there
147
+ # means it was requested explicitly (directly or via an alias).
148
+ def validate_refresh_token_flow
149
+ return if refresh_token_enabled?
150
+ return unless calculate_grant_flows.map(&:to_s).include?("refresh_token")
151
+
152
+ ::Rails.logger.warn(
153
+ "[DOORKEEPER] You have enabled the refresh_token grant flow without " \
154
+ "configuring use_refresh_token, so refresh tokens will not be issued. " \
155
+ "Configure use_refresh_token to issue refresh tokens (the refresh_token " \
156
+ "grant flow is then enabled automatically).",
157
+ )
158
+ end
159
+
160
+ # Warn when a configured issuer is not RFC-compliant. RFC 8414 (the
161
+ # metadata issuer) and RFC 9207 (the authorization response iss parameter)
162
+ # both require an https URL with a host and no query or fragment component.
163
+ # The value is still used as-is - this is a warning, not a hard failure, so
164
+ # local setups using e.g. http://localhost keep working - but a
165
+ # non-compliant issuer produces responses that strict clients may reject.
166
+ def validate_issuer_format
167
+ return if issuer.blank?
168
+
169
+ uri =
170
+ begin
171
+ URI.parse(issuer.to_s)
172
+ rescue URI::InvalidURIError
173
+ nil
174
+ end
175
+
176
+ return if uri.is_a?(URI::HTTPS) && uri.host.present? &&
177
+ uri.query.nil? && uri.fragment.nil?
178
+
179
+ ::Rails.logger.warn(
180
+ "[DOORKEEPER] issuer #{redacted_issuer.inspect} is not RFC-compliant: " \
181
+ "RFC 8414 and RFC 9207 require an https URL with a host and no query " \
182
+ "or fragment component. It is still advertised in the metadata and " \
183
+ "emitted as the iss parameter as-is, but strict clients may reject it.",
184
+ )
185
+ end
186
+
187
+ # Warn when a path-bearing issuer cannot be discovered. RFC 8414 clients
188
+ # build the metadata URL by inserting the well-known path into the issuer,
189
+ # so an issuer of https://host/tenant is looked up at
190
+ # https://host/.well-known/oauth-authorization-server/tenant. Doorkeeper
191
+ # only serves the document at the root well-known path, so such a value is
192
+ # not discoverable (and its advertised issuer would not match the lookup).
193
+ # This is a separate concern from validate_issuer_format: a path component
194
+ # is valid per RFC 8414, just unsupported by Doorkeeper's fixed route.
195
+ def validate_issuer_metadata_discoverability
196
+ return if issuer.blank?
197
+
198
+ uri =
199
+ begin
200
+ URI.parse(issuer.to_s)
201
+ rescue URI::InvalidURIError
202
+ nil
203
+ end
204
+
205
+ return if uri&.host.blank?
206
+ return if uri.path.blank? || uri.path == "/"
207
+
208
+ ::Rails.logger.warn(
209
+ "[DOORKEEPER] issuer #{redacted_issuer.inspect} has a path component, but " \
210
+ "Doorkeeper serves its RFC 8414 metadata only at the root " \
211
+ "/.well-known/oauth-authorization-server. RFC 8414 clients derive the " \
212
+ "metadata URL from the issuer path (…/.well-known/" \
213
+ "oauth-authorization-server#{uri.path}), so they will not discover the " \
214
+ "document. Use a host-only issuer, or route the derived well-known path " \
215
+ "to Doorkeeper.",
216
+ )
217
+ end
218
+
219
+ # Redact any userinfo (e.g. a misconfigured user:pass@host) before the
220
+ # issuer is written to the log, so credentials are not leaked there.
221
+ def redacted_issuer
222
+ issuer.to_s.sub(%r{//[^/@]*@}, "//***@")
223
+ end
63
224
  end
64
225
  end
65
226
  end
@@ -65,17 +65,50 @@ module Doorkeeper
65
65
  end
66
66
 
67
67
  # Change the way client credentials are retrieved from the request object.
68
- # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
69
- # falls back to the `:client_id` and `:client_secret` params from the
70
- # `params` object.
71
68
  #
72
- # NOTE: a list that includes a callable extractor opts out of the RFC 6749
73
- # §2.3 validation applied to the symbol extractors — see
74
- # +Doorkeeper::OAuth::Client::Credentials.from_request+.
69
+ # @deprecated Use the +client_authentication+ option instead. The legacy
70
+ # +:from_basic+ / +:from_params+ methods are automatically converted to
71
+ # the +:client_secret_basic+ / +:client_secret_post+ authentication
72
+ # methods. +:none+ (public client support) is appended only when
73
+ # +:from_params+ was configured, since that is the only legacy method
74
+ # that accepted a bare +client_id+ without a secret — +:from_basic+ on
75
+ # its own never did, so it is not broadened. Callable extractors are
76
+ # wrapped in a legacy adapter so they keep working during the
77
+ # deprecation window.
75
78
  #
76
79
  # @param methods [Array] Define client credentials
77
80
  def client_credentials(*methods)
78
- @config.instance_variable_set(:@client_credentials_methods, methods)
81
+ deprecated(
82
+ "client_credentials",
83
+ "Use the client_authentication option instead. Automatically converting to client_authentication",
84
+ )
85
+
86
+ client_authentication = Doorkeeper::ClientAuthentication.from_legacy_client_credentials(methods)
87
+
88
+ if client_authentication.empty?
89
+ Kernel.warn(
90
+ "[DOORKEEPER] No known client_credentials method detected, " \
91
+ "cannot automatically convert to client_authentication option",
92
+ )
93
+ else
94
+ @config.instance_variable_set(:@client_credentials_methods, client_authentication)
95
+ end
96
+ end
97
+
98
+ # Declare which client authentication methods (RFC 6749 §2.3) are
99
+ # accepted and the order in which they are tried. Accepts either an array
100
+ # or varargs, so both forms are honoured exactly as written:
101
+ #
102
+ # client_authentication %i[client_secret_basic client_secret_post none]
103
+ # client_authentication :client_secret_basic, :client_secret_post
104
+ #
105
+ # Unlike the deprecated +client_credentials+ option, the listed methods
106
+ # are used verbatim — nothing (in particular +:none+) is appended, so a
107
+ # restrictive configuration is never silently broadened.
108
+ #
109
+ # @param methods [Array<Symbol>] the client authentication method names
110
+ def client_authentication(*methods)
111
+ @config.instance_variable_set(:@client_authentication, methods.flatten)
79
112
  end
80
113
 
81
114
  # Change the way access token is authenticated from the request object.
@@ -206,6 +239,13 @@ module Doorkeeper
206
239
 
207
240
  private
208
241
 
242
+ def deprecated(name, message = nil)
243
+ warning = "[DOORKEEPER] #{name} has been deprecated and will soon be removed"
244
+ warning = "#{warning}\n#{message}" if message.present?
245
+
246
+ Kernel.warn(warning)
247
+ end
248
+
209
249
  # Configure the secret storing functionality
210
250
  def configure_secrets_for(type, using:, fallback:)
211
251
  raise ArgumentError, "Invalid type #{type}" if %i[application token].exclude?(type)
@@ -329,9 +369,7 @@ module Doorkeeper
329
369
  option :allow_grant_flow_for_client, default: ->(_grant_flow, _client) { true }
330
370
 
331
371
  # Allows to forbid specific Application redirect URI's by custom rules.
332
- # Doesn't forbid any URI by default. Redirect URIs with a script scheme
333
- # (`javascript`, `vbscript`, `data`) are always rejected, regardless of
334
- # this option.
372
+ # Doesn't forbid any URI by default.
335
373
  #
336
374
  # @param forbid_redirect_uri [Proc] Block or any object respond to #call
337
375
  #
@@ -343,6 +381,10 @@ module Doorkeeper
343
381
  #
344
382
  option :realm, default: "Doorkeeper"
345
383
 
384
+ # Issuer URL advertised in the OAuth 2.0 Authorization Server Metadata
385
+ # (RFC 8414). When nil, the request base URL is used instead.
386
+ option :issuer, default: nil
387
+
346
388
  # Forces the usage of the HTTPS protocol in non-native redirect uris
347
389
  # (enabled by default in non-development environments). OAuth2
348
390
  # delegates security in communication to the HTTPS protocol so it is
@@ -419,6 +461,10 @@ module Doorkeeper
419
461
  option :application_class,
420
462
  default: "Doorkeeper::Application"
421
463
 
464
+ # Allows setting a hash of custom data merged into the OAuth 2.0
465
+ # Authorization Server Metadata response (RFC 8414).
466
+ option :custom_metadata, default: {}
467
+
422
468
  # Allows to set blank redirect URIs for Applications in case
423
469
  # server configured to use URI-less grant flows.
424
470
  #
@@ -602,8 +648,57 @@ module Doorkeeper
602
648
  pkce_code_challenge_methods
603
649
  end
604
650
 
651
+ # Resolves the configured client authentication methods (RFC 6749 §2.3)
652
+ # into the registered +Doorkeeper::ClientAuthentication::Method+ objects.
653
+ #
654
+ # Honors the deprecated +client_credentials+ option for backwards
655
+ # compatibility: if it was used it provides the source of truth, unless
656
+ # +client_authentication+ was also set explicitly, in which case the
657
+ # latter wins.
658
+ def client_authentication_methods
659
+ return @client_authentication_methods if defined?(@client_authentication_methods)
660
+
661
+ # When both the deprecated +client_credentials+ and the new
662
+ # +client_authentication+ are set, +client_authentication+ wins. The
663
+ # conflict is warned about at validation time (see Validations), not here,
664
+ # so the message is not swallowed by this memoised resolver.
665
+ only_legacy = instance_variable_defined?(:@client_credentials_methods) &&
666
+ !instance_variable_defined?(:@client_authentication)
667
+ names = only_legacy ? @client_credentials_methods : client_authentication
668
+
669
+ # Names configured more than once resolve to the same registered Method
670
+ # instance, so identity-based #uniq drops the duplicates (which would
671
+ # otherwise be matched against requests twice and advertised twice in
672
+ # the server metadata) while distinct legacy callable adapters survive.
673
+ @client_authentication_methods = names.filter_map do |name|
674
+ # Legacy callables are already wrapped as Method adapters (see #client_credentials).
675
+ name.is_a?(Doorkeeper::ClientAuthentication::Method) ? name : Doorkeeper::ClientAuthentication.get(name)
676
+ end.uniq
677
+ end
678
+
679
+ # The configured client authentication method names (RFC 6749 §2.3),
680
+ # defaulting to the registry's DEFAULT_METHODS when not set.
681
+ def client_authentication
682
+ return Doorkeeper::ClientAuthentication::DEFAULT_METHODS.dup unless instance_variable_defined?(:@client_authentication)
683
+
684
+ @client_authentication
685
+ end
686
+
687
+ # @deprecated Renamed to +client_authentication_methods+. This alias keeps
688
+ # external callers (e.g. doorkeeper-openid_connect) working for one release
689
+ # and will be removed afterwards. It returns the legacy symbol names
690
+ # (e.g. +:from_basic+) rather than the internal Method objects so that
691
+ # consumers mapping from those symbols keep working unchanged.
605
692
  def client_credentials_methods
606
- @client_credentials_methods ||= %i[from_basic from_params]
693
+ unless defined?(@client_credentials_methods_rename_warned)
694
+ Kernel.warn(
695
+ "[DOORKEEPER] Doorkeeper.config.client_credentials_methods has been renamed to " \
696
+ "client_authentication_methods and will be removed in a future version.",
697
+ )
698
+ @client_credentials_methods_rename_warned = true
699
+ end
700
+
701
+ Doorkeeper::ClientAuthentication.to_legacy_client_credentials_names(client_authentication_methods)
607
702
  end
608
703
 
609
704
  def access_token_methods
@@ -671,13 +766,17 @@ module Doorkeeper
671
766
  def calculate_token_grant_types
672
767
  types = grant_flows - ["implicit"]
673
768
  types << "refresh_token" if refresh_token_enabled?
674
- types
769
+ types.uniq
675
770
  end
676
771
 
677
772
  # Calculates grant flows configured by the user in Doorkeeper
678
773
  # configuration considering registered aliases that is exposed
679
774
  # to single or multiple other flows.
680
775
  #
776
+ # The refresh_token flow is added implicitly when +use_refresh_token+
777
+ # is configured, so the result lists every enabled flow (useful for
778
+ # RFC 8414 authorization server metadata).
779
+ #
681
780
  def calculate_grant_flows
682
781
  configured_flows = grant_flows.map(&:to_s)
683
782
  aliases = Doorkeeper::GrantFlow.aliases.keys.map(&:to_s)
@@ -689,6 +788,8 @@ module Doorkeeper
689
788
  flows.concat(Doorkeeper::GrantFlow.expand_alias(flow_alias))
690
789
  end
691
790
 
791
+ flows << "refresh_token" if refresh_token_enabled?
792
+
692
793
  flows.flatten.uniq
693
794
  end
694
795
 
@@ -719,9 +820,7 @@ module Doorkeeper
719
820
  end
720
821
 
721
822
  def calculate_token_grant_flows
722
- flows = enabled_grant_flows.select(&:handles_grant_type?)
723
- flows << Doorkeeper::GrantFlow.get("refresh_token") if refresh_token_enabled?
724
- flows
823
+ enabled_grant_flows.select(&:handles_grant_type?)
725
824
  end
726
825
  end
727
826
  end
@@ -75,6 +75,7 @@ module Doorkeeper
75
75
  UnableToGenerateToken = Class.new(DoorkeeperError)
76
76
  TokenGeneratorNotFound = Class.new(DoorkeeperError)
77
77
  NoOrmCleaner = Class.new(DoorkeeperError)
78
+ MissingConfigurationBuilderClass = Class.new(DoorkeeperError)
78
79
 
79
80
  InvalidRequest = Class.new(BaseResponseError)
80
81
  InvalidToken = Class.new(BaseResponseError)
@@ -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]
@@ -226,9 +256,14 @@ module Doorkeeper
226
256
  # attributes when matching, while an empty hash only matches tokens
227
257
  # that have no custom attributes set.
228
258
  custom_attributes = extract_custom_attributes(token_attributes)
259
+ # The refresh token requirement participates in the matching itself
260
+ # rather than being checked on its single newest result: the newest
261
+ # matching token may carry the wrong refresh token presence (e.g. it
262
+ # was issued through a grant with a different `use_refresh_token`)
263
+ # while an older token satisfies the request and can still be reused.
229
264
  access_token = matching_token_for(
230
265
  application, resource_owner, scopes, custom_attributes: custom_attributes, include_expired: false,
231
- )
266
+ ) { |token| refresh_token_matches?(token, token_attributes) }
232
267
 
233
268
  return access_token if access_token&.reusable?
234
269
  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
  ##
@@ -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
@@ -13,6 +13,9 @@ module Doorkeeper
13
13
  attr_reader :grant, :client, :redirect_uri, :access_token, :code_verifier,
14
14
  :invalid_request_reason, :missing_param
15
15
 
16
+ # A scope parameter is deliberately not read here: RFC 6749 does not
17
+ # define one for the authorization_code token request (§4.1.3), so it
18
+ # is ignored and the access token inherits the scopes of the grant.
16
19
  def initialize(server, grant, client, parameters = {})
17
20
  super()
18
21
  @server = server
@@ -64,7 +67,7 @@ module Doorkeeper
64
67
  @missing_param =
65
68
  if grant&.uses_pkce? && code_verifier.blank?
66
69
  :code_verifier
67
- elsif client && !client.confidential && Doorkeeper.config.force_pkce? && code_verifier.blank?
70
+ elsif client && Doorkeeper.config.force_pkce? && code_verifier.blank?
68
71
  :code_verifier
69
72
  elsif redirect_uri.blank?
70
73
  :redirect_uri
@@ -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
@@ -0,0 +1,53 @@
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
+ # Match whenever the header decodes to a non-blank +client_id+ — i.e.
17
+ # whenever a Basic authentication *attempt* is present. The secret may
18
+ # be empty (public clients) or missing; those are still Basic auth
19
+ # attempts and must be claimed here so that invalid credentials fail
20
+ # with +invalid_client+ instead of silently falling through to another
21
+ # configured method or the fallback (which would downgrade a failed
22
+ # authentication attempt to "no authentication provided").
23
+ def self.matches_request?(request)
24
+ credentials_from(request).present?
25
+ end
26
+
27
+ def self.authenticate(request)
28
+ client_id, client_secret = credentials_from(request)
29
+ return unless client_id
30
+
31
+ Doorkeeper::ClientAuthentication::Credentials.new(client_id, client_secret)
32
+ end
33
+
34
+ # Returns the decoded [client_id, client_secret] pair, or nil when the
35
+ # request carries no HTTP Basic +client_id+. A header that merely
36
+ # starts with "Basic " but decodes to an empty/blank client_id (e.g.
37
+ # an empty payload or a leading ":") is not a usable attempt and does
38
+ # not match.
39
+ def self.credentials_from(request)
40
+ authorization = request.authorization.to_s
41
+ return unless authorization.downcase.start_with?("basic ")
42
+
43
+ value = authorization.split(" ", 2).last
44
+ client_id, client_secret = Base64.decode64(value.to_s).split(":", 2)
45
+ return if client_id.blank?
46
+
47
+ [client_id, client_secret]
48
+ end
49
+ private_class_method :credentials_from
50
+ end
51
+ end
52
+ end
53
+ end