doorkeeper 5.9.3 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +1 -0
  4. data/app/controllers/doorkeeper/metadata_controller.rb +20 -0
  5. data/app/controllers/doorkeeper/tokens_controller.rb +10 -1
  6. data/app/views/doorkeeper/authorizations/new.html.erb +6 -0
  7. data/config/locales/en.yml +1 -0
  8. data/lib/doorkeeper/client_authentication/credentials.rb +11 -0
  9. data/lib/doorkeeper/client_authentication/fallback_method.rb +19 -0
  10. data/lib/doorkeeper/client_authentication/legacy_callable.rb +46 -0
  11. data/lib/doorkeeper/client_authentication/method.rb +23 -0
  12. data/lib/doorkeeper/client_authentication/registry.rb +47 -0
  13. data/lib/doorkeeper/client_authentication.rb +93 -0
  14. data/lib/doorkeeper/config/option.rb +1 -1
  15. data/lib/doorkeeper/config/validations.rb +162 -1
  16. data/lib/doorkeeper/config.rb +114 -9
  17. data/lib/doorkeeper/errors.rb +14 -0
  18. data/lib/doorkeeper/helpers/controller.rb +2 -1
  19. data/lib/doorkeeper/models/access_token_mixin.rb +48 -8
  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/client_credentials/creator.rb +4 -1
  29. data/lib/doorkeeper/oauth/code_response.rb +16 -2
  30. data/lib/doorkeeper/oauth/error_response.rb +11 -2
  31. data/lib/doorkeeper/oauth/metadata_response.rb +157 -0
  32. data/lib/doorkeeper/oauth/pre_authorization.rb +12 -5
  33. data/lib/doorkeeper/oauth/token_introspection.rb +27 -7
  34. data/lib/doorkeeper/orm/active_record/mixins/application.rb +2 -2
  35. data/lib/doorkeeper/rails/routes/mapping.rb +1 -0
  36. data/lib/doorkeeper/rails/routes.rb +6 -0
  37. data/lib/doorkeeper/request.rb +59 -0
  38. data/lib/doorkeeper/server.rb +5 -2
  39. data/lib/doorkeeper/version.rb +4 -4
  40. data/lib/doorkeeper.rb +8 -4
  41. data/lib/generators/doorkeeper/templates/initializer.rb +66 -6
  42. metadata +12 -2
  43. data/lib/doorkeeper/oauth/client/credentials.rb +0 -34
@@ -65,13 +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.
68
+ #
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.
71
78
  #
72
79
  # @param methods [Array] Define client credentials
73
80
  def client_credentials(*methods)
74
- @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)
75
112
  end
76
113
 
77
114
  # Change the way access token is authenticated from the request object.
@@ -202,6 +239,13 @@ module Doorkeeper
202
239
 
203
240
  private
204
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
+
205
249
  # Configure the secret storing functionality
206
250
  def configure_secrets_for(type, using:, fallback:)
207
251
  raise ArgumentError, "Invalid type #{type}" if %i[application token].exclude?(type)
@@ -337,6 +381,10 @@ module Doorkeeper
337
381
  #
338
382
  option :realm, default: "Doorkeeper"
339
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
+
340
388
  # Forces the usage of the HTTPS protocol in non-native redirect uris
341
389
  # (enabled by default in non-development environments). OAuth2
342
390
  # delegates security in communication to the HTTPS protocol so it is
@@ -413,6 +461,10 @@ module Doorkeeper
413
461
  option :application_class,
414
462
  default: "Doorkeeper::Application"
415
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
+
416
468
  # Allows to set blank redirect URIs for Applications in case
417
469
  # server configured to use URI-less grant flows.
418
470
  #
@@ -596,8 +648,57 @@ module Doorkeeper
596
648
  pkce_code_challenge_methods
597
649
  end
598
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.
599
692
  def client_credentials_methods
600
- @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)
601
702
  end
602
703
 
603
704
  def access_token_methods
@@ -665,13 +766,17 @@ module Doorkeeper
665
766
  def calculate_token_grant_types
666
767
  types = grant_flows - ["implicit"]
667
768
  types << "refresh_token" if refresh_token_enabled?
668
- types
769
+ types.uniq
669
770
  end
670
771
 
671
772
  # Calculates grant flows configured by the user in Doorkeeper
672
773
  # configuration considering registered aliases that is exposed
673
774
  # to single or multiple other flows.
674
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
+ #
675
780
  def calculate_grant_flows
676
781
  configured_flows = grant_flows.map(&:to_s)
677
782
  aliases = Doorkeeper::GrantFlow.aliases.keys.map(&:to_s)
@@ -683,6 +788,8 @@ module Doorkeeper
683
788
  flows.concat(Doorkeeper::GrantFlow.expand_alias(flow_alias))
684
789
  end
685
790
 
791
+ flows << "refresh_token" if refresh_token_enabled?
792
+
686
793
  flows.flatten.uniq
687
794
  end
688
795
 
@@ -713,9 +820,7 @@ module Doorkeeper
713
820
  end
714
821
 
715
822
  def calculate_token_grant_flows
716
- flows = enabled_grant_flows.select(&:handles_grant_type?)
717
- flows << Doorkeeper::GrantFlow.get("refresh_token") if refresh_token_enabled?
718
- flows
823
+ enabled_grant_flows.select(&:handles_grant_type?)
719
824
  end
720
825
  end
721
826
  end
@@ -24,6 +24,19 @@ module Doorkeeper
24
24
  end
25
25
  end
26
26
 
27
+ # Raised when a request uses more than one client authentication method,
28
+ # which RFC 6749 §2.3 explicitly forbids ("The client MUST NOT use more
29
+ # than one authentication method in each request").
30
+ class MultipleClientAuthMethods < DoorkeeperError
31
+ def type
32
+ :invalid_request
33
+ end
34
+
35
+ def reason
36
+ :multiple_client_auth_methods
37
+ end
38
+ end
39
+
27
40
  class MissingRequiredParameter < DoorkeeperError
28
41
  attr_reader :missing_param
29
42
 
@@ -62,6 +75,7 @@ module Doorkeeper
62
75
  UnableToGenerateToken = Class.new(DoorkeeperError)
63
76
  TokenGeneratorNotFound = Class.new(DoorkeeperError)
64
77
  NoOrmCleaner = Class.new(DoorkeeperError)
78
+ MissingConfigurationBuilderClass = Class.new(DoorkeeperError)
65
79
 
66
80
  InvalidRequest = Class.new(BaseResponseError)
67
81
  InvalidToken = Class.new(BaseResponseError)
@@ -56,7 +56,8 @@ module Doorkeeper
56
56
  OAuth::InvalidRequestResponse.new(
57
57
  name: exception.type,
58
58
  state: params[:state],
59
- missing_param: exception.missing_param,
59
+ missing_param: exception.try(:missing_param),
60
+ reason: exception.try(:reason),
60
61
  )
61
62
  else
62
63
  OAuth::ErrorResponse.new(name: exception.type, state: params[:state])
@@ -89,15 +89,19 @@ module Doorkeeper
89
89
  # @param custom_attributes [Nilable Hash]
90
90
  # A nil value, or hash with keys corresponding to the custom attributes
91
91
  # configured with the `custom_access_token_attributes` config option.
92
- # A nil value will ignore custom attributes.
92
+ # A nil value will ignore custom attributes, while an empty hash will
93
+ # only match tokens that have no custom attributes set.
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).
93
97
  #
94
98
  # @return [Doorkeeper::AccessToken, nil] Access Token instance or
95
99
  # nil if matching record was not found
96
100
  #
97
- 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)
98
102
  tokens = authorized_tokens_for(application&.id, resource_owner)
99
103
  tokens = tokens.not_expired unless include_expired
100
- find_matching_token(tokens, application, custom_attributes, scopes)
104
+ find_matching_token(tokens, application, custom_attributes, scopes, &filter)
101
105
  end
102
106
 
103
107
  # Interface to enumerate access token records in batches in order not
@@ -124,12 +128,13 @@ module Doorkeeper
124
128
  # @param custom_attributes [Nilable Hash]
125
129
  # A nil value, or hash with keys corresponding to the custom attributes
126
130
  # configured with the `custom_access_token_attributes` config option.
127
- # A nil value will ignore custom attributes.
131
+ # A nil value will ignore custom attributes, while an empty hash will
132
+ # only match tokens that have no custom attributes set.
128
133
  #
129
134
  # @return [Doorkeeper::AccessToken, nil] Access Token instance or
130
135
  # nil if matching record was not found
131
136
  #
132
- def find_matching_token(relation, application, custom_attributes, scopes)
137
+ def find_matching_token(relation, application, custom_attributes, scopes, &filter)
133
138
  return nil unless relation
134
139
 
135
140
  matching_tokens = []
@@ -138,7 +143,8 @@ module Doorkeeper
138
143
  find_access_token_in_batches(relation, batch_size: batch_size) do |batch|
139
144
  tokens = batch.select do |token|
140
145
  scopes_match?(token.scopes, scopes, application&.scopes) &&
141
- custom_attributes_match?(token, custom_attributes)
146
+ custom_attributes_match?(token, custom_attributes) &&
147
+ (filter.nil? || filter.call(token))
142
148
  end
143
149
 
144
150
  matching_tokens.concat(tokens)
@@ -147,6 +153,32 @@ module Doorkeeper
147
153
  matching_tokens.max_by(&:created_at)
148
154
  end
149
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
+
150
182
  # Checks whether the token scopes match the scopes from the parameters
151
183
  #
152
184
  # @param token_scopes [#to_s]
@@ -220,10 +252,18 @@ module Doorkeeper
220
252
  scopes = Doorkeeper::OAuth::Scopes.from_string(scopes) if scopes.is_a?(String)
221
253
 
222
254
  if Doorkeeper.config.reuse_access_token
223
- custom_attributes = extract_custom_attributes(token_attributes).presence
255
+ # An empty hash must stay distinct from nil here: nil ignores custom
256
+ # attributes when matching, while an empty hash only matches tokens
257
+ # that have no custom attributes set.
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.
224
264
  access_token = matching_token_for(
225
265
  application, resource_owner, scopes, custom_attributes: custom_attributes, include_expired: false,
226
- )
266
+ ) { |token| refresh_token_matches?(token, token_attributes) }
227
267
 
228
268
  return access_token if access_token&.reusable?
229
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
@@ -0,0 +1,29 @@
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.matches_request?(request)
11
+ params = request.request_parameters.with_indifferent_access
12
+
13
+ request.post? &&
14
+ params[:client_id].present? &&
15
+ params[:client_secret].present?
16
+ end
17
+
18
+ def self.authenticate(request)
19
+ params = request.request_parameters.with_indifferent_access
20
+
21
+ Doorkeeper::ClientAuthentication::Credentials.new(
22
+ params[:client_id],
23
+ params[:client_secret],
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
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
+ # Requires the Authorization header to be absent or blank: a request
10
+ # that carries a non-blank one (Basic, Bearer, or anything else) is
11
+ # attempting header-based authentication and must not be silently
12
+ # treated as an unauthenticated public client. This is narrower than
13
+ # the legacy +from_params+ extractor, which read the body +client_id+
14
+ # regardless of the Authorization header.
15
+ def self.matches_request?(request)
16
+ params = request.request_parameters.with_indifferent_access
17
+
18
+ request.post? &&
19
+ request.authorization.blank? &&
20
+ params[:client_id].present? &&
21
+ params[:client_secret].blank?
22
+ end
23
+
24
+ def self.authenticate(request)
25
+ params = request.request_parameters.with_indifferent_access
26
+
27
+ Doorkeeper::ClientAuthentication::Credentials.new(params[:client_id], nil)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -45,8 +45,11 @@ module Doorkeeper
45
45
  end
46
46
 
47
47
  def find_active_existing_token_for(client, scopes, attributes)
48
+ # An empty hash must stay distinct from nil here: nil ignores custom
49
+ # attributes when matching, while an empty hash only matches tokens
50
+ # that have no custom attributes set.
48
51
  custom_attributes = Doorkeeper.config.access_token_model
49
- .extract_custom_attributes(attributes).presence
52
+ .extract_custom_attributes(attributes)
50
53
  Doorkeeper.config.access_token_model.matching_token_for(
51
54
  client, nil, scopes, custom_attributes: custom_attributes, include_expired: false,
52
55
  )
@@ -28,17 +28,22 @@ module Doorkeeper
28
28
  token_type: auth.token.token_type,
29
29
  expires_in: auth.token.expires_in_seconds,
30
30
  state: pre_auth.state,
31
- }
31
+ }.merge(iss_parameter)
32
32
  elsif auth.try(:access_grant?)
33
33
  {
34
34
  code: auth.token.plaintext_token,
35
35
  state: pre_auth.state,
36
- }
36
+ }.merge(iss_parameter)
37
37
  end
38
38
  end
39
39
 
40
40
  def redirect_uri
41
41
  if URIChecker.oob_uri?(pre_auth.redirect_uri)
42
+ # Out-of-band "redirects" render the code/token on an authorization
43
+ # server page for the user to copy manually; there is no redirect
44
+ # back to the client. RFC 9207 scopes the iss parameter to the
45
+ # authorization response sent to the client, so - like state, which
46
+ # oob_redirect also omits - iss is intentionally not carried here.
42
47
  auth.oob_redirect
43
48
  elsif response_on_fragment
44
49
  Authorization::URIBuilder.uri_with_fragment(pre_auth.redirect_uri, body)
@@ -46,6 +51,15 @@ module Doorkeeper
46
51
  Authorization::URIBuilder.uri_with_query(pre_auth.redirect_uri, body)
47
52
  end
48
53
  end
54
+
55
+ private
56
+
57
+ # RFC 9207 Authorization Server Issuer Identification: advertise the
58
+ # issuer in the authorization response only when an issuer is configured.
59
+ def iss_parameter
60
+ issuer = Doorkeeper.config.issuer
61
+ issuer.present? ? { iss: issuer } : {}
62
+ end
49
63
  end
50
64
  end
51
65
  end