doorkeeper 5.9.5 → 6.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -3
  3. data/README.md +204 -0
  4. data/app/controllers/doorkeeper/applications_controller.rb +38 -9
  5. data/app/controllers/doorkeeper/authorizations_controller.rb +17 -3
  6. data/app/controllers/doorkeeper/metadata_controller.rb +20 -0
  7. data/app/controllers/doorkeeper/tokens_controller.rb +15 -2
  8. data/app/views/doorkeeper/authorizations/new.html.erb +24 -0
  9. data/config/locales/en.yml +2 -0
  10. data/lib/doorkeeper/client_authentication/credentials.rb +11 -0
  11. data/lib/doorkeeper/client_authentication/fallback_method.rb +19 -0
  12. data/lib/doorkeeper/client_authentication/legacy_callable.rb +52 -0
  13. data/lib/doorkeeper/client_authentication/method.rb +40 -0
  14. data/lib/doorkeeper/client_authentication/registry.rb +47 -0
  15. data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
  16. data/lib/doorkeeper/client_authentication.rb +102 -0
  17. data/lib/doorkeeper/config/option.rb +1 -1
  18. data/lib/doorkeeper/config/validations.rb +162 -1
  19. data/lib/doorkeeper/config.rb +161 -9
  20. data/lib/doorkeeper/document_cache.rb +81 -0
  21. data/lib/doorkeeper/errors.rb +49 -0
  22. data/lib/doorkeeper/http_fetcher.rb +232 -0
  23. data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
  24. data/lib/doorkeeper/models/access_token_mixin.rb +81 -5
  25. data/lib/doorkeeper/models/application_mixin.rb +14 -6
  26. data/lib/doorkeeper/models/concerns/secret_storable.rb +10 -1
  27. data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
  28. data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
  29. data/lib/doorkeeper/oauth/authorization/uri_builder.rb +11 -0
  30. data/lib/doorkeeper/oauth/authorization_code_request.rb +127 -3
  31. data/lib/doorkeeper/oauth/base_request.rb +1 -2
  32. data/lib/doorkeeper/oauth/client.rb +24 -0
  33. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +57 -0
  34. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +33 -0
  35. data/lib/doorkeeper/oauth/client_authentication/none.rb +71 -0
  36. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
  37. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
  38. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
  39. data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
  40. data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
  41. data/lib/doorkeeper/oauth/code_response.rb +16 -2
  42. data/lib/doorkeeper/oauth/error_response.rb +11 -2
  43. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
  44. data/lib/doorkeeper/oauth/metadata_response.rb +165 -0
  45. data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
  46. data/lib/doorkeeper/oauth/pre_authorization.rb +63 -8
  47. data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
  48. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
  49. data/lib/doorkeeper/oauth/scopes.rb +25 -0
  50. data/lib/doorkeeper/oauth/token_introspection.rb +33 -7
  51. data/lib/doorkeeper/orm/active_record/mixins/application.rb +2 -2
  52. data/lib/doorkeeper/rails/routes/mapping.rb +1 -0
  53. data/lib/doorkeeper/rails/routes.rb +6 -0
  54. data/lib/doorkeeper/request.rb +59 -0
  55. data/lib/doorkeeper/server.rb +5 -2
  56. data/lib/doorkeeper/version.rb +4 -4
  57. data/lib/doorkeeper.rb +12 -4
  58. data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
  59. data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
  60. data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
  61. data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
  62. data/lib/generators/doorkeeper/templates/initializer.rb +131 -6
  63. data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
  64. metadata +52 -4
  65. data/lib/doorkeeper/oauth/client/credentials.rb +0 -71
  66. data/lib/doorkeeper/oauth/stateless_token.rb +0 -139
@@ -15,27 +15,42 @@ module Doorkeeper
15
15
  false
16
16
  end
17
17
 
18
+ # RFC6749, Section 3.1.2.3 requires the requested redirect URI to be
19
+ # compared to the registered redirect URIs using the simple string
20
+ # comparison defined in RFC3986, Section 6.2.1.
21
+ # @see https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3
18
22
  def self.matches?(url, client_url)
23
+ return true if url == client_url
24
+
25
+ # RFC8252, Paragraph 7.3 allows the port of loopback interface
26
+ # redirect URIs to vary at runtime, so it is ignored when both
27
+ # URIs point to the loopback interface.
28
+ # @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
19
29
  url = as_uri(url)
20
30
  client_url = as_uri(client_url)
21
31
 
22
- unless client_url.query.nil?
23
- return false unless query_matches?(url.query, client_url.query)
24
-
25
- # Clear out queries so rest of URI can be tested. This allows query
26
- # params to be in the request but order not mattering.
27
- client_url.query = nil
28
- end
32
+ return false unless loopback_uri?(url) && loopback_uri?(client_url)
29
33
 
30
- # RFC8252, Paragraph 7.3
31
- # @see https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
32
- if loopback_uri?(url) && loopback_uri?(client_url)
33
- url.port = nil
34
- client_url.port = nil
35
- end
34
+ urls_match?(url, client_url)
35
+ rescue URI::InvalidURIError
36
+ false
37
+ end
36
38
 
37
- url.query = nil
38
- url == client_url
39
+ # RFC 8252 §7.3 lets only the PORT of a loopback redirect URI vary at
40
+ # runtime. Compare every other component explicitly instead of blanking
41
+ # the port and comparing the reassembled strings: `URI#port=` also
42
+ # clears the userinfo on Ruby >= 4.0, which would let
43
+ # `http://attacker@127.0.0.1/cb` match a registered
44
+ # `http://127.0.0.1/cb`. Comparing components (not `URI#==`) also keeps
45
+ # the exception from widening past the port — e.g. an empty path is not
46
+ # treated as equal to "/".
47
+ def self.urls_match?(url, client_url)
48
+ url.scheme == client_url.scheme &&
49
+ url.userinfo == client_url.userinfo &&
50
+ url.host == client_url.host &&
51
+ url.path == client_url.path &&
52
+ url.query == client_url.query &&
53
+ url.fragment == client_url.fragment
39
54
  end
40
55
 
41
56
  def self.loopback_uri?(uri)
@@ -45,6 +60,11 @@ module Doorkeeper
45
60
  end
46
61
 
47
62
  def self.valid_for_authorization?(url, client_url)
63
+ # client_url is blank when the application was registered without a
64
+ # redirect URI (allow_blank_redirect_uri) — no possible match for
65
+ # redirection based OAuth flows, not an error.
66
+ return false if client_url.blank?
67
+
48
68
  valid?(url) && client_url.split.any? { |other_url| matches?(url, other_url) }
49
69
  end
50
70
 
@@ -52,14 +72,6 @@ module Doorkeeper
52
72
  URI.parse(url)
53
73
  end
54
74
 
55
- def self.query_matches?(query, client_query)
56
- return true if client_query.blank? && query.blank?
57
- return false if client_query.nil? || query.nil?
58
-
59
- # Will return true independent of query order
60
- client_query.split("&").sort == query.split("&").sort
61
- end
62
-
63
75
  def self.valid_scheme?(uri)
64
76
  return false if uri.scheme.blank?
65
77
 
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper
4
+ module OAuth
5
+ # OAuth 2.0 Authorization Server Metadata response as described in
6
+ # RFC 8414 (https://www.rfc-editor.org/rfc/rfc8414).
7
+ class MetadataResponse < BaseResponse
8
+ def initialize(base_url, url_builder)
9
+ super()
10
+ @base_url = base_url
11
+ @url_builder = url_builder
12
+ end
13
+
14
+ def body
15
+ @body ||= begin
16
+ data = {
17
+ # presence: a blank issuer (e.g. an unset env var) must fall back
18
+ # to base_url, matching the issuer.present? condition below so the
19
+ # two fields cannot disagree.
20
+ issuer: issuer.presence || @base_url,
21
+ # Only advertise endpoints whose controllers are installed. A
22
+ # controller disabled through skip_controllers has no routes mapping,
23
+ # so endpoint_for resolves it to nil and it is dropped below.
24
+ authorization_endpoint: authorization_endpoint,
25
+ token_endpoint: token_endpoint,
26
+ revocation_endpoint: revocation_endpoint,
27
+ introspection_endpoint: (introspection_endpoint if introspection_enabled?),
28
+ scopes_supported: scopes_supported,
29
+ response_types_supported: response_types_supported,
30
+ response_modes_supported: response_modes_supported,
31
+ grant_types_supported: grant_types_supported,
32
+ token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
33
+ code_challenge_methods_supported: code_challenge_methods_supported,
34
+ # RFC 9207: true only when an issuer is configured, matching the
35
+ # condition under which the iss parameter is emitted. false survives
36
+ # the compaction below, so it is advertised explicitly.
37
+ authorization_response_iss_parameter_supported: config.issuer.present?,
38
+ # RFC 8707: advertise resource indicator support when configured.
39
+ resource_indicators_supported: resource_indicators_supported?,
40
+ }
41
+ data.compact!
42
+
43
+ # userinfo_endpoint is intentionally advertised as null for backwards
44
+ # compatibility; it is meant to be populated through custom_metadata
45
+ # (e.g. by an OIDC extension), so it must survive the compaction above.
46
+ data[:userinfo_endpoint] = userinfo_endpoint
47
+
48
+ data.merge(custom_metadata)
49
+ end
50
+ end
51
+
52
+ def status
53
+ :ok
54
+ end
55
+
56
+ def headers
57
+ {
58
+ "Cache-Control" => "public",
59
+ "Content-Type" => "application/json; charset=utf-8",
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ def config
66
+ @config ||= Doorkeeper.configuration
67
+ end
68
+
69
+ def url_for(**args)
70
+ @url_builder.call(**args)
71
+ end
72
+
73
+ # Resolve the URL for the given route group, or nil when the group has no
74
+ # routes mapping (i.e. the controller was disabled via skip_controllers).
75
+ def endpoint_for(group, action:)
76
+ mapping = Doorkeeper::Rails::Routes.mapping[group]
77
+ return unless mapping
78
+
79
+ # The leading slash anchors the controller name: without it, Rails
80
+ # resolves the name relative to the current (doorkeeper/metadata)
81
+ # namespace whenever their segment depths differ, e.g. a configured
82
+ # `controllers tokens: "custom_tokens"` would be looked up as
83
+ # "doorkeeper/custom_tokens" and raise ActionController::UrlGenerationError.
84
+ url_for(controller: "/#{mapping[:controllers]}", action: action)
85
+ end
86
+
87
+ def custom_metadata
88
+ config.custom_metadata.symbolize_keys
89
+ end
90
+
91
+ def issuer
92
+ config.issuer
93
+ end
94
+
95
+ def authorization_endpoint
96
+ endpoint_for(:authorizations, action: "new")
97
+ end
98
+
99
+ def token_endpoint
100
+ endpoint_for(:tokens, action: "create")
101
+ end
102
+
103
+ def userinfo_endpoint
104
+ nil
105
+ end
106
+
107
+ def revocation_endpoint
108
+ endpoint_for(:tokens, action: "revoke")
109
+ end
110
+
111
+ def introspection_endpoint
112
+ endpoint_for(:tokens, action: "introspect")
113
+ end
114
+
115
+ def introspection_enabled?
116
+ Doorkeeper.configured? &&
117
+ !config.allow_token_introspection.is_a?(FalseClass)
118
+ end
119
+
120
+ def scopes_supported
121
+ config.scopes.to_a
122
+ end
123
+
124
+ def response_types_supported
125
+ config.authorization_response_types
126
+ end
127
+
128
+ def response_modes_supported
129
+ config.authorization_response_flows.flat_map(&:response_mode_matches).uniq
130
+ end
131
+
132
+ def grant_types_supported
133
+ # RFC 8414 lists token-endpoint grant type values (e.g.
134
+ # "authorization_code", "refresh_token"), so derive them from the
135
+ # flows that actually handle a grant_type and use their grant_type
136
+ # value rather than the configured flow name. This drops
137
+ # response-type-only flows such as :implicit and reports the real
138
+ # grant type for flows whose name differs from it (e.g. a URN).
139
+ config.token_grant_types
140
+ end
141
+
142
+ # The resolved methods (rather than the raw client_authentication names)
143
+ # reflect what the server actually accepts: unregistered names are
144
+ # dropped by the resolver, and a deprecated client_credentials-only
145
+ # configuration is honored as the source of truth. Legacy callable
146
+ # extractors have no registered method name a client could use, so they
147
+ # are not advertised.
148
+ def token_endpoint_auth_methods_supported
149
+ config.client_authentication_methods.filter_map do |method|
150
+ method.name.to_s if Doorkeeper::ClientAuthentication.get(method.name)
151
+ end
152
+ end
153
+
154
+ def code_challenge_methods_supported
155
+ config.pkce_code_challenge_methods_supported
156
+ end
157
+
158
+ def resource_indicators_supported?
159
+ config.resource_indicator_validator.present? &&
160
+ Doorkeeper.config.access_grant_model.resource_indicators_supported? &&
161
+ Doorkeeper.config.access_token_model.resource_indicators_supported?
162
+ end
163
+ end
164
+ end
165
+ end
@@ -9,10 +9,12 @@ module Doorkeeper
9
9
  validate :client_supports_grant_flow, error: Errors::UnauthorizedClient
10
10
  validate :resource_owner, error: Errors::InvalidGrant
11
11
  validate :scopes, error: Errors::InvalidScope
12
+ validate :resource_indicators, error: Errors::InvalidTarget
12
13
 
13
14
  attr_reader :client, :credentials, :resource_owner, :parameters, :access_token
14
15
 
15
16
  def initialize(server, client, credentials, resource_owner, parameters = {})
17
+ super()
16
18
  @server = server
17
19
  @resource_owner = resource_owner
18
20
  @client = client
@@ -20,12 +22,21 @@ module Doorkeeper
20
22
  @parameters = parameters
21
23
  @original_scopes = parameters[:scope]
22
24
  @grant_type = Doorkeeper::OAuth::PASSWORD
25
+ @raw_resource_indicators = parameters[:resource]
23
26
  end
24
27
 
25
28
  private
26
29
 
27
30
  def before_successful_response
28
- find_or_create_access_token(client, resource_owner, scopes, {}, server)
31
+ token_attributes = {}
32
+ if @resolved_resource_indicators.present?
33
+ unless Doorkeeper.config.access_token_model.resource_indicators_supported?
34
+ raise Errors::MissingResourceColumn, "oauth_access_tokens"
35
+ end
36
+
37
+ token_attributes[:resource] = @resolved_resource_indicators.join(" ")
38
+ end
39
+ find_or_create_access_token(client, resource_owner, scopes, token_attributes, server)
29
40
  super
30
41
  end
31
42
 
@@ -70,6 +81,21 @@ module Doorkeeper
70
81
  def validate_client_supports_grant_flow
71
82
  Doorkeeper.config.allow_grant_flow_for_client?(grant_type, client&.application)
72
83
  end
84
+
85
+ # RFC 8707: validate resource indicators against server policy.
86
+ def validate_resource_indicators
87
+ validator = Doorkeeper.config.resource_indicator_validator
88
+ return true unless validator
89
+
90
+ @resolved_resource_indicators = ResourceIndicatorValidator.validate!(
91
+ @raw_resource_indicators,
92
+ config_validator: validator,
93
+ client: client,
94
+ )
95
+ true
96
+ rescue Errors::InvalidTarget
97
+ false
98
+ end
73
99
  end
74
100
  end
75
101
  end
@@ -7,20 +7,21 @@ module Doorkeeper
7
7
 
8
8
  validate :client_id, error: Errors::InvalidRequest
9
9
  validate :client, error: Errors::InvalidClient
10
- validate :client_supports_grant_flow, error: Errors::UnauthorizedClient
11
- validate :resource_owner_authorize_for_client, error: Errors::InvalidClient
12
10
  validate :redirect_uri, error: Errors::InvalidRedirectUri
11
+ validate :client_supports_grant_flow, error: Errors::UnauthorizedClient
12
+ validate :resource_owner_authorize_for_client, error: Errors::AccessDenied
13
13
  validate :params, error: Errors::InvalidRequest
14
14
  validate :response_type, error: Errors::UnsupportedResponseType
15
15
  validate :response_mode, error: Errors::UnsupportedResponseMode
16
16
  validate :scopes, error: Errors::InvalidScope
17
17
  validate :code_challenge, error: Errors::InvalidRequest
18
18
  validate :code_challenge_method, error: Errors::InvalidCodeChallengeMethod
19
+ validate :resource_indicators, error: Errors::InvalidTarget
19
20
 
20
21
  attr_reader :client, :code_challenge, :code_challenge_method, :missing_param,
21
22
  :redirect_uri, :resource_owner, :response_type, :state,
22
23
  :authorization_response_flow, :response_mode, :custom_access_token_attributes,
23
- :invalid_request_reason
24
+ :invalid_request_reason, :resource_indicators
24
25
 
25
26
  def initialize(server, parameters = {}, resource_owner = nil)
26
27
  @server = server
@@ -34,6 +35,7 @@ module Doorkeeper
34
35
  @code_challenge_method = parameters[:code_challenge_method]
35
36
  @resource_owner = resource_owner
36
37
  @custom_access_token_attributes = parameters.slice(*Doorkeeper.config.custom_access_token_attributes).to_h
38
+ @raw_resource_indicators = parameters[:resource]
37
39
  end
38
40
 
39
41
  def authorizable?
@@ -49,13 +51,21 @@ module Doorkeeper
49
51
  end
50
52
 
51
53
  def error_response
54
+ # RFC 9207: authorization error responses advertise the issuer. Passing
55
+ # the configured issuer here (nil when unset) scopes the iss parameter
56
+ # to the authorization endpoint only.
52
57
  if error == Errors::InvalidRequest
53
58
  OAuth::InvalidRequestResponse.from_request(
54
59
  self,
55
60
  response_on_fragment: response_on_fragment?,
61
+ issuer: Doorkeeper.config.issuer,
56
62
  )
57
63
  else
58
- OAuth::ErrorResponse.from_request(self, response_on_fragment: response_on_fragment?)
64
+ OAuth::ErrorResponse.from_request(
65
+ self,
66
+ response_on_fragment: response_on_fragment?,
67
+ issuer: Doorkeeper.config.issuer,
68
+ )
59
69
  end
60
70
  end
61
71
 
@@ -76,7 +86,7 @@ module Doorkeeper
76
86
  if client_scopes.blank?
77
87
  server.default_scopes.to_s
78
88
  else
79
- server.default_scopes.allowed(client_scopes).to_s
89
+ server.default_scopes.common(client_scopes).to_s
80
90
  end
81
91
  end
82
92
 
@@ -115,7 +125,21 @@ module Doorkeeper
115
125
  :scope
116
126
  end
117
127
 
118
- @missing_param.nil?
128
+ return false unless @missing_param.nil?
129
+
130
+ # A structured `scope` (e.g. `scope[a]=b`, parsed by Rack into a Hash)
131
+ # is malformed (RFC 6749 §3.3). Reject it here — before validate_scopes
132
+ # reaches Scopes.from_string — so the authorization endpoint answers
133
+ # invalid_request instead of letting the raised error surface as a 500.
134
+ # Set a reason so the error_description is not translated from nil.
135
+ return true if scope_param_well_formed?
136
+
137
+ @invalid_request_reason = :unknown
138
+ false
139
+ end
140
+
141
+ def scope_param_well_formed?
142
+ @scope.nil? || @scope.is_a?(String)
119
143
  end
120
144
 
121
145
  def validate_response_type
@@ -147,7 +171,14 @@ module Doorkeeper
147
171
 
148
172
  def validate_code_challenge
149
173
  return true unless Doorkeeper.config.force_pkce?
150
- return true if client.confidential
174
+ # PKCE (RFC 7636) protects the exchange of an authorization code, so
175
+ # a code_challenge is only required from response types that issue one
176
+ # ("code" and code-carrying hybrid types like "code id_token"). For
177
+ # response types that never issue a code (e.g. "token" or an OIDC
178
+ # extension's "id_token"), there is no token-endpoint exchange where a
179
+ # verifier could ever be checked, so requiring a challenge would
180
+ # reject those requests over a parameter that cannot be validated.
181
+ return true unless code_issuing_response_type?
151
182
  return true if code_challenge.present?
152
183
 
153
184
  @invalid_request_reason = :invalid_code_challenge
@@ -161,6 +192,21 @@ module Doorkeeper
161
192
  (code_challenge_method.present? && Doorkeeper.config.pkce_code_challenge_methods_supported.include?(code_challenge_method))
162
193
  end
163
194
 
195
+ def validate_resource_indicators
196
+ validator = Doorkeeper.config.resource_indicator_validator
197
+ # When no validator is configured, resource indicators are ignored (feature disabled)
198
+ return true unless validator
199
+
200
+ @resource_indicators = ResourceIndicatorValidator.validate!(
201
+ @raw_resource_indicators,
202
+ config_validator: validator,
203
+ client: client,
204
+ )
205
+ true
206
+ rescue Errors::InvalidTarget
207
+ false
208
+ end
209
+
164
210
  def response_on_fragment?
165
211
  return response_type == "token" if response_mode.nil?
166
212
 
@@ -171,6 +217,15 @@ module Doorkeeper
171
217
  response_type == "code" ? AUTHORIZATION_CODE : IMPLICIT
172
218
  end
173
219
 
220
+ # Whether the requested response type issues an authorization code.
221
+ # Multi-valued response types (OIDC hybrid flows registered by
222
+ # extensions, e.g. "code id_token") are space-delimited per OAuth 2.0
223
+ # Multiple Response Type Encoding Practices, so a token-wise check also
224
+ # covers response types Doorkeeper itself does not ship.
225
+ def code_issuing_response_type?
226
+ response_type.to_s.split.include?("code")
227
+ end
228
+
174
229
  def pre_auth_hash
175
230
  {
176
231
  client_id: client.uid,
@@ -180,7 +235,7 @@ module Doorkeeper
180
235
  scope: scope,
181
236
  client_name: client.name,
182
237
  status: I18n.t("doorkeeper.pre_authorization.status"),
183
- }
238
+ }.reverse_merge(custom_access_token_attributes.symbolize_keys)
184
239
  end
185
240
  end
186
241
  end
@@ -10,6 +10,7 @@ module Doorkeeper
10
10
  validate :client, error: Errors::InvalidClient
11
11
  validate :client_match, error: Errors::InvalidGrant
12
12
  validate :scope, error: Errors::InvalidScope
13
+ validate :resource_indicators, error: Errors::InvalidTarget
13
14
 
14
15
  attr_reader :access_token, :client, :credentials, :refresh_token
15
16
  attr_reader :missing_param
@@ -21,13 +22,21 @@ module Doorkeeper
21
22
  @grant_type = Doorkeeper::OAuth::REFRESH_TOKEN
22
23
  @original_scopes = parameters[:scope] || parameters[:scopes]
23
24
  @refresh_token_parameter = parameters[:refresh_token]
25
+ @raw_resource_indicators = parameters[:resource]
24
26
  @client = load_client(credentials) if credentials
25
27
  end
26
28
 
27
29
  private
28
30
 
31
+ # Resolved through +OAuth::Client+ rather than by looking the row up
32
+ # directly, so this grant applies the same rules the other token
33
+ # endpoint grants do: credentials that carry no secret because their
34
+ # authentication method already proved the client's identity
35
+ # (private_key_jwt) resolve by uid alone. The application record is
36
+ # what is returned, since that is what +#client+ has always exposed
37
+ # here.
29
38
  def load_client(credentials)
30
- Doorkeeper.config.application_model.by_uid_and_secret(credentials.uid, credentials.secret)
39
+ Doorkeeper::OAuth::Client.authenticate(credentials)&.application
31
40
  end
32
41
 
33
42
  def before_successful_response
@@ -74,6 +83,19 @@ module Doorkeeper
74
83
  attributes[:previous_refresh_token] = refresh_token.refresh_token
75
84
  end
76
85
 
86
+ # RFC 8707: carry resource indicators to the new access token.
87
+ # If the refresh request specified a (subset of) resource(s), use those;
88
+ # otherwise inherit from the refresh token itself.
89
+ if @resolved_resource_indicators.present?
90
+ unless Doorkeeper.config.access_token_model.resource_indicators_supported?
91
+ raise Errors::MissingResourceColumn, "oauth_access_tokens"
92
+ end
93
+
94
+ attributes[:resource] = @resolved_resource_indicators.join(" ")
95
+ elsif refresh_token.try(:resource).present?
96
+ attributes[:resource] = refresh_token.resource
97
+ end
98
+
77
99
  # RFC6749
78
100
  # 1.5. Refresh Token
79
101
  #
@@ -132,6 +154,34 @@ module Doorkeeper
132
154
  end
133
155
  end
134
156
 
157
+ # RFC 8707: resource indicators on refresh must be a subset of those
158
+ # bound to the original refresh token (which inherited from the grant).
159
+ #
160
+ # Subset and syntax enforcement run even when no validator is configured
161
+ # as long as the original token is already audience-restricted: a refresh
162
+ # must never widen the audience beyond what the original token carried.
163
+ # Only when the feature is disabled AND the original token has no stored
164
+ # resources is the `resource` parameter ignored entirely.
165
+ def validate_resource_indicators
166
+ original_resources = refresh_token.try(:resource)&.split
167
+
168
+ validator = Doorkeeper.config.resource_indicator_validator
169
+
170
+ # Feature effectively off: no validator and nothing already bound to
171
+ # enforce against. Ignore the `resource` parameter.
172
+ return true if validator.nil? && original_resources.blank?
173
+
174
+ @resolved_resource_indicators = ResourceIndicatorValidator.validate!(
175
+ @raw_resource_indicators,
176
+ config_validator: validator,
177
+ client: client,
178
+ grant_resource_indicators: original_resources,
179
+ )
180
+ true
181
+ rescue Errors::InvalidTarget
182
+ false
183
+ end
184
+
135
185
  def custom_token_attributes_with_data
136
186
  refresh_token
137
187
  .attributes
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Doorkeeper
6
+ module OAuth
7
+ # Validates the `resource` parameter per RFC 8707.
8
+ #
9
+ # The `resource` value MUST be an absolute URI without a fragment component.
10
+ # Multiple `resource` parameters MAY be present.
11
+ #
12
+ # @see https://datatracker.ietf.org/doc/html/rfc8707#section-2
13
+ module ResourceIndicatorValidator
14
+ module_function
15
+
16
+ # Validates and normalizes an array of resource indicator values.
17
+ #
18
+ # @param resource_indicators [Array<String>, String, nil] One or more resource URIs
19
+ # @param config_validator [Proc, nil] Custom validator from configuration
20
+ # @param client [Doorkeeper::OAuth::Client, nil] The requesting client
21
+ # @param grant_resource_indicators [Array<String>, nil] Resource indicators from the
22
+ # original authorization grant (for subset enforcement on token requests)
23
+ #
24
+ # @return [Array<String>] Validated resource indicator URIs
25
+ # @raise [Doorkeeper::Errors::InvalidTarget] When validation fails
26
+ def validate!(resource_indicators, config_validator: nil, client: nil, grant_resource_indicators: nil)
27
+ return [] if resource_indicators.blank?
28
+
29
+ indicators = Array.wrap(resource_indicators).reject(&:blank?).uniq
30
+ return [] if indicators.empty?
31
+
32
+ indicators.each { |uri| validate_uri!(uri) }
33
+
34
+ # If the token request specifies resources, they must be a subset of those
35
+ # originally granted (RFC 8707 §2.2).
36
+ raise Errors::InvalidTarget if grant_resource_indicators.present? && (indicators - grant_resource_indicators).any?
37
+
38
+ # Custom server policy validation
39
+ raise Errors::InvalidTarget if config_validator && !config_validator.call(indicators, client)
40
+
41
+ indicators
42
+ end
43
+
44
+ # @return [Boolean] true when the values are syntactically valid
45
+ def valid?(resource_indicators)
46
+ return true if resource_indicators.blank?
47
+
48
+ Array(resource_indicators).reject(&:blank?).all? { |uri| valid_uri?(uri) }
49
+ end
50
+
51
+ def valid_uri?(uri)
52
+ return false unless uri.is_a?(String)
53
+
54
+ parsed = URI.parse(uri)
55
+ # MUST be absolute URI
56
+ return false unless parsed.absolute?
57
+ # MUST NOT include a fragment
58
+ return false if parsed.fragment
59
+
60
+ true
61
+ rescue URI::InvalidURIError, TypeError
62
+ false
63
+ end
64
+
65
+ def validate_uri!(uri)
66
+ raise Errors::InvalidTarget unless valid_uri?(uri)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -10,6 +10,14 @@ module Doorkeeper
10
10
 
11
11
  def self.from_string(string)
12
12
  string ||= ""
13
+
14
+ # A non-string scope (e.g. `scope[a]=b`, parsed by Rack into a Hash)
15
+ # has no octets to split into tokens. Reject it as a malformed request
16
+ # (RFC 6749 §3.3); at the token endpoint TokensController's rescue_from
17
+ # turns this into invalid_request. Callers that always pass a string
18
+ # (the model layer) never hit this.
19
+ raise Errors::InvalidScopeParameter, "scope must be a String" unless string.is_a?(String)
20
+
13
21
  new.tap do |scope|
14
22
  scope.add(*string.split)
15
23
  end
@@ -92,6 +100,23 @@ module Doorkeeper
92
100
  self.class.from_array(filtered_scopes)
93
101
  end
94
102
 
103
+ # Returns the scopes present in both sets, taking dynamic scopes
104
+ # into account: a dynamic scope pattern (e.g. `user:*`) on either
105
+ # side matches the concrete scope (e.g. `user:1`) on the other,
106
+ # and the concrete scope is what ends up in the result. Unlike
107
+ # #allowed, the same scopes are returned regardless of which set is
108
+ # the receiver, though the order may differ: scopes matched from the
109
+ # argument come first, followed by any scope only the receiver
110
+ # contributes (a concrete scope matched by a pattern in the argument).
111
+ #
112
+ # @param other The set of scopes to intersect with
113
+ def common(other)
114
+ other = self.class.from_array(to_array(other))
115
+ matched = other.select { |scope| exists?(scope) } +
116
+ select { |scope| other.exists?(scope) }
117
+ self.class.from_array(matched)
118
+ end
119
+
95
120
  private
96
121
 
97
122
  def dynamic_scopes_enabled?