doorkeeper 5.9.7 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -12
  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/lib/doorkeeper/client_authentication/credentials.rb +11 -0
  8. data/lib/doorkeeper/client_authentication/fallback_method.rb +19 -0
  9. data/lib/doorkeeper/client_authentication/legacy_callable.rb +46 -0
  10. data/lib/doorkeeper/client_authentication/method.rb +23 -0
  11. data/lib/doorkeeper/client_authentication/registry.rb +47 -0
  12. data/lib/doorkeeper/client_authentication.rb +93 -0
  13. data/lib/doorkeeper/config/option.rb +1 -1
  14. data/lib/doorkeeper/config/validations.rb +162 -1
  15. data/lib/doorkeeper/config.rb +113 -12
  16. data/lib/doorkeeper/errors.rb +1 -0
  17. data/lib/doorkeeper/models/access_token_mixin.rb +40 -5
  18. data/lib/doorkeeper/models/application_mixin.rb +14 -6
  19. data/lib/doorkeeper/models/concerns/secret_storable.rb +10 -1
  20. data/lib/doorkeeper/oauth/authorization/uri_builder.rb +11 -0
  21. data/lib/doorkeeper/oauth/authorization_code_request.rb +4 -1
  22. data/lib/doorkeeper/oauth/client.rb +18 -0
  23. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +53 -0
  24. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +29 -0
  25. data/lib/doorkeeper/oauth/client_authentication/none.rb +32 -0
  26. data/lib/doorkeeper/oauth/code_response.rb +16 -2
  27. data/lib/doorkeeper/oauth/error_response.rb +11 -2
  28. data/lib/doorkeeper/oauth/metadata_response.rb +157 -0
  29. data/lib/doorkeeper/oauth/pre_authorization.rb +12 -5
  30. data/lib/doorkeeper/oauth/token.rb +0 -76
  31. data/lib/doorkeeper/oauth/token_introspection.rb +27 -7
  32. data/lib/doorkeeper/orm/active_record/mixins/application.rb +2 -2
  33. data/lib/doorkeeper/rails/routes/mapping.rb +1 -0
  34. data/lib/doorkeeper/rails/routes.rb +6 -0
  35. data/lib/doorkeeper/request.rb +59 -0
  36. data/lib/doorkeeper/server.rb +5 -2
  37. data/lib/doorkeeper/version.rb +4 -4
  38. data/lib/doorkeeper.rb +8 -4
  39. data/lib/generators/doorkeeper/templates/initializer.rb +65 -13
  40. metadata +14 -7
  41. data/lib/doorkeeper/oauth/client/credentials.rb +0 -86
@@ -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)
@@ -341,6 +381,10 @@ module Doorkeeper
341
381
  #
342
382
  option :realm, default: "Doorkeeper"
343
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
+
344
388
  # Forces the usage of the HTTPS protocol in non-native redirect uris
345
389
  # (enabled by default in non-development environments). OAuth2
346
390
  # delegates security in communication to the HTTPS protocol so it is
@@ -417,6 +461,10 @@ module Doorkeeper
417
461
  option :application_class,
418
462
  default: "Doorkeeper::Application"
419
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
+
420
468
  # Allows to set blank redirect URIs for Applications in case
421
469
  # server configured to use URI-less grant flows.
422
470
  #
@@ -600,8 +648,57 @@ module Doorkeeper
600
648
  pkce_code_challenge_methods
601
649
  end
602
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.
603
692
  def client_credentials_methods
604
- @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)
605
702
  end
606
703
 
607
704
  def access_token_methods
@@ -669,13 +766,17 @@ module Doorkeeper
669
766
  def calculate_token_grant_types
670
767
  types = grant_flows - ["implicit"]
671
768
  types << "refresh_token" if refresh_token_enabled?
672
- types
769
+ types.uniq
673
770
  end
674
771
 
675
772
  # Calculates grant flows configured by the user in Doorkeeper
676
773
  # configuration considering registered aliases that is exposed
677
774
  # to single or multiple other flows.
678
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
+ #
679
780
  def calculate_grant_flows
680
781
  configured_flows = grant_flows.map(&:to_s)
681
782
  aliases = Doorkeeper::GrantFlow.aliases.keys.map(&:to_s)
@@ -687,6 +788,8 @@ module Doorkeeper
687
788
  flows.concat(Doorkeeper::GrantFlow.expand_alias(flow_alias))
688
789
  end
689
790
 
791
+ flows << "refresh_token" if refresh_token_enabled?
792
+
690
793
  flows.flatten.uniq
691
794
  end
692
795
 
@@ -717,9 +820,7 @@ module Doorkeeper
717
820
  end
718
821
 
719
822
  def calculate_token_grant_flows
720
- flows = enabled_grant_flows.select(&:handles_grant_type?)
721
- flows << Doorkeeper::GrantFlow.get("refresh_token") if refresh_token_enabled?
722
- flows
823
+ enabled_grant_flows.select(&:handles_grant_type?)
723
824
  end
724
825
  end
725
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
@@ -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
@@ -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
@@ -5,7 +5,7 @@ module Doorkeeper
5
5
  class ErrorResponse < BaseResponse
6
6
  include OAuth::Helpers
7
7
 
8
- NON_REDIRECTABLE_STATES = %i[invalid_redirect_uri invalid_client unauthorized_client].freeze
8
+ NON_REDIRECTABLE_STATES = %i[invalid_redirect_uri invalid_client].freeze
9
9
 
10
10
  def self.from_request(request, attributes = {})
11
11
  new(
@@ -38,6 +38,7 @@ module Doorkeeper
38
38
  @exception_class = attributes[:exception_class]
39
39
  @redirect_uri = attributes[:redirect_uri]
40
40
  @response_on_fragment = attributes[:response_on_fragment]
41
+ @issuer = attributes[:issuer]
41
42
  end
42
43
 
43
44
  def body
@@ -45,11 +46,19 @@ module Doorkeeper
45
46
  error: name,
46
47
  error_description: description,
47
48
  state: state,
49
+ # RFC 9207 scopes the issuer to authorization error responses sent to
50
+ # the client, i.e. the ones redirected back to its redirect_uri. It is
51
+ # supplied only by the authorization endpoint (never token,
52
+ # introspection or protected resource), and gated on redirectable? so
53
+ # non-redirectable errors (invalid_client, invalid_redirect_uri) and
54
+ # out-of-band flows - which render to the user instead of redirecting
55
+ # to the client - do not carry it. Blank values are dropped below.
56
+ iss: (@issuer if redirectable?),
48
57
  }.reject { |_, v| v.blank? }
49
58
  end
50
59
 
51
60
  def status
52
- if name == :invalid_client || name == :unauthorized_client
61
+ if name == :invalid_client
53
62
  :unauthorized
54
63
  else
55
64
  :bad_request