doorkeeper 6.0.0.beta1 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/README.md +203 -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/tokens_controller.rb +5 -1
  7. data/app/views/doorkeeper/authorizations/new.html.erb +18 -0
  8. data/config/locales/en.yml +2 -0
  9. data/lib/doorkeeper/client_authentication/legacy_callable.rb +6 -0
  10. data/lib/doorkeeper/client_authentication/method.rb +17 -0
  11. data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
  12. data/lib/doorkeeper/client_authentication.rb +9 -0
  13. data/lib/doorkeeper/config.rb +47 -0
  14. data/lib/doorkeeper/document_cache.rb +81 -0
  15. data/lib/doorkeeper/errors.rb +48 -0
  16. data/lib/doorkeeper/http_fetcher.rb +232 -0
  17. data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
  18. data/lib/doorkeeper/models/access_token_mixin.rb +42 -1
  19. data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
  20. data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
  21. data/lib/doorkeeper/oauth/authorization_code_request.rb +123 -2
  22. data/lib/doorkeeper/oauth/base_request.rb +1 -2
  23. data/lib/doorkeeper/oauth/client.rb +6 -0
  24. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +4 -0
  25. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +4 -0
  26. data/lib/doorkeeper/oauth/client_authentication/none.rb +47 -8
  27. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
  28. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
  29. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
  30. data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
  31. data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
  32. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
  33. data/lib/doorkeeper/oauth/metadata_response.rb +8 -0
  34. data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
  35. data/lib/doorkeeper/oauth/pre_authorization.rb +51 -3
  36. data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
  37. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
  38. data/lib/doorkeeper/oauth/scopes.rb +25 -0
  39. data/lib/doorkeeper/oauth/token_introspection.rb +6 -0
  40. data/lib/doorkeeper/version.rb +1 -1
  41. data/lib/doorkeeper.rb +4 -0
  42. data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
  43. data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
  44. data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
  45. data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
  46. data/lib/generators/doorkeeper/templates/initializer.rb +65 -0
  47. data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
  48. metadata +45 -3
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doorkeeper
4
+ # A small thread-safe, fixed-TTL, in-memory memo keyed by URL. It exists
5
+ # so one authorization flow (authorize GET, consent POST, token exchange)
6
+ # does not refetch the same URL several times within a few seconds; it
7
+ # deliberately implements no HTTP caching semantics.
8
+ #
9
+ # Only successfully fetched and validated values may be stored — an error
10
+ # response or a malformed document must never be cached — which is
11
+ # guaranteed by callers never yielding anything but a validated value.
12
+ class DocumentCache
13
+ DEFAULT_TTL = 60
14
+ MAX_ENTRIES = 500
15
+
16
+ def initialize(ttl: DEFAULT_TTL)
17
+ @ttl = ttl
18
+ @mutex = Mutex.new
19
+ @store = {}
20
+ end
21
+
22
+ # Returns the cached document for the URL, or stores and returns the
23
+ # block's result. The block's failures (raises, nil) are not cached.
24
+ def fetch(url)
25
+ cached = read(url)
26
+ return cached if cached
27
+
28
+ document = yield
29
+ write(url, document) if document
30
+ document
31
+ end
32
+
33
+ def clear
34
+ @mutex.synchronize { @store.clear }
35
+ end
36
+
37
+ private
38
+
39
+ def read(url)
40
+ @mutex.synchronize do
41
+ entry = @store[url]
42
+ next nil unless entry
43
+
44
+ if entry[:expires_at] <= monotonic_now
45
+ @store.delete(url)
46
+ next nil
47
+ end
48
+
49
+ entry[:document]
50
+ end
51
+ end
52
+
53
+ def write(url, document)
54
+ @mutex.synchronize do
55
+ # Deleted first so a rewritten entry moves to the end of the hash's
56
+ # insertion order, which is the end #prune evicts from. #read
57
+ # already drops an entry when it finds it expired, so this only
58
+ # matters when two threads resolve the same URL at once.
59
+ @store.delete(url)
60
+ prune
61
+ @store[url] = { document: document, expires_at: monotonic_now + @ttl }
62
+ end
63
+ end
64
+
65
+ # Drop expired entries; if the store is still full, drop the oldest
66
+ # entries so a burst of unique URLs cannot grow the memo unbounded.
67
+ def prune
68
+ now = monotonic_now
69
+ @store.delete_if { |_url, entry| entry[:expires_at] <= now }
70
+
71
+ overflow = @store.size - (MAX_ENTRIES - 1)
72
+ return if overflow <= 0
73
+
74
+ @store.keys.first(overflow).each { |url| @store.delete(url) }
75
+ end
76
+
77
+ def monotonic_now
78
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+ end
80
+ end
81
+ end
@@ -50,6 +50,23 @@ module Doorkeeper
50
50
  end
51
51
  end
52
52
 
53
+ # Raised when the `scope` parameter is present but not a string — e.g.
54
+ # `scope[a]=b`, which Rack parses into a Hash. Its octets cannot be split
55
+ # into scope tokens, so the request is malformed (RFC 6749 §3.3) and must
56
+ # be answered with `invalid_request` rather than an unhandled 500.
57
+ class InvalidScopeParameter < DoorkeeperError
58
+ def type
59
+ :invalid_request
60
+ end
61
+
62
+ # Maps to `invalid_request.unknown` ("... or is otherwise malformed").
63
+ # Without a reason the token endpoint would translate `nil` and return a
64
+ # blank error_description.
65
+ def reason
66
+ :unknown
67
+ end
68
+ end
69
+
53
70
  class BaseResponseError < DoorkeeperError
54
71
  attr_reader :response
55
72
 
@@ -77,12 +94,43 @@ module Doorkeeper
77
94
  NoOrmCleaner = Class.new(DoorkeeperError)
78
95
  MissingConfigurationBuilderClass = Class.new(DoorkeeperError)
79
96
 
97
+ # Raised when resource_indicator_validator is configured but the required
98
+ # `resource` column has not been added to the database. Provides an
99
+ # actionable message pointing to the generator.
100
+ #
101
+ # `#type` returns `:server_error` so the token endpoint (which rescues
102
+ # DoorkeeperError and builds an OAuth error response from `#type`) emits a
103
+ # spec-compliant error code; the actionable message is retained on the
104
+ # exception for logs rather than being sent as the `error` value.
105
+ class MissingResourceColumn < DoorkeeperError
106
+ def initialize(table)
107
+ super(
108
+ "resource_indicator_validator is configured but the `resource` column is missing from " \
109
+ "the #{table} table. Run `rails generate doorkeeper:resource_indicators` and apply the migration.",
110
+ )
111
+ end
112
+
113
+ def type
114
+ :server_error
115
+ end
116
+ end
117
+
80
118
  InvalidRequest = Class.new(BaseResponseError)
81
119
  InvalidToken = Class.new(BaseResponseError)
82
120
  InvalidClient = Class.new(BaseResponseError)
83
121
  InvalidScope = Class.new(BaseResponseError)
84
122
  InvalidRedirectUri = Class.new(BaseResponseError)
85
123
  InvalidGrant = Class.new(BaseResponseError)
124
+ # RFC 8707 error: the requested resource is invalid, missing, unknown, or malformed.
125
+ # Raised bare (no arguments) as a signal inside ResourceIndicatorValidator,
126
+ # then rescued and surfaced through the validation framework. Also raised
127
+ # with a response by ErrorResponse#raise_exception! so that controller
128
+ # rescue handlers can extract #response for translated error descriptions.
129
+ class InvalidTarget < BaseResponseError
130
+ def initialize(response = nil)
131
+ super
132
+ end
133
+ end
86
134
 
87
135
  UnauthorizedClient = Class.new(BaseResponseError)
88
136
  UnsupportedResponseType = Class.new(BaseResponseError)
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "net/http"
5
+ require "resolv"
6
+ require "uri"
7
+
8
+ module Doorkeeper
9
+ # Fetches a small operator-untrusted JSON document — a client's jwks_uri
10
+ # today — over HTTPS: redirects are never followed and any status other
11
+ # than 200 OK is an error.
12
+ #
13
+ # SSRF hardening: the host is resolved up front and the request is refused
14
+ # when any resolved address falls into an RFC 6890 special-use range
15
+ # (loopback, private-use, link-local, ...). The connection is then pinned
16
+ # to the vetted address via Net::HTTP#ipaddr= so a second, post-check DNS
17
+ # resolution (DNS rebinding) cannot redirect the request; TLS is still
18
+ # negotiated and verified against the original hostname. An exception for
19
+ # authorization servers themselves running on a loopback interface is
20
+ # intentionally not implemented. These rules follow the fetch hardening of
21
+ # draft-ietf-oauth-client-id-metadata-document (Sections 6.5 / 6.6), which
22
+ # fetches documents from the same kind of client-chosen URL.
23
+ #
24
+ # The response body is bounded and so is the total time spent reading it:
25
+ # a per-read timeout alone does not stop a server that dribbles bytes out
26
+ # indefinitely.
27
+ #
28
+ # Everything about the response is chosen by whoever hosts the document —
29
+ # which is whoever supplied the URL — so no failure mode here may escape
30
+ # as anything other than a FetchError.
31
+ class HttpFetcher
32
+ OPEN_TIMEOUT = 5
33
+ READ_TIMEOUT = 5
34
+
35
+ # draft-ietf-oauth-client-id-metadata-document Section 6.6 recommends a
36
+ # maximum response size of 5 kilobytes for a document like this.
37
+ MAX_RESPONSE_SIZE = 5 * 1024
38
+
39
+ # Ceiling on the whole exchange, so a body delivered one byte per
40
+ # READ_TIMEOUT cannot hold the connection (and the thread) for hours.
41
+ MAX_TOTAL_TIME = 10
42
+
43
+ # The document is served as JSON, either "application/json" or an
44
+ # "application/<more specific>+json" variant. A response declaring
45
+ # anything else plainly serves something other than the document sought
46
+ # and is refused without being parsed. A response declaring no media type
47
+ # at all is tolerated — the check is there to catch such a URL early, not
48
+ # as a security control, since the body still has to parse and validate
49
+ # in the caller.
50
+ JSON_MEDIA_TYPE = %r{\Aapplication/([\w.+-]+\+)?json\z}i
51
+
52
+ # RFC 6890 special-purpose IPv4/IPv6 registries, plus multicast ranges
53
+ # (224.0.0.0/4, ff00::/8), which are equally unfit as a document origin.
54
+ SPECIAL_USE_RANGES = [
55
+ "0.0.0.0/8", # "this host on this network"
56
+ "10.0.0.0/8", # private-use
57
+ "100.64.0.0/10", # shared address space (CGN)
58
+ "127.0.0.0/8", # loopback
59
+ "169.254.0.0/16", # link-local
60
+ "172.16.0.0/12", # private-use
61
+ "192.0.0.0/24", # IETF protocol assignments
62
+ "192.0.2.0/24", # documentation (TEST-NET-1)
63
+ "192.88.99.0/24", # 6to4 relay anycast
64
+ "192.168.0.0/16", # private-use
65
+ "198.18.0.0/15", # benchmarking
66
+ "198.51.100.0/24", # documentation (TEST-NET-2)
67
+ "203.0.113.0/24", # documentation (TEST-NET-3)
68
+ "224.0.0.0/4", # multicast
69
+ "240.0.0.0/4", # reserved (includes limited broadcast)
70
+ "::/128", # unspecified
71
+ "::1/128", # loopback
72
+ # IPv4-compatible addresses (::a.b.c.d), deprecated by RFC 4291
73
+ # Section 2.5.5.1. Unlike the IPv4-mapped form handled in
74
+ # .special_use? these carry no ::ffff: marker, so they are refused
75
+ # wholesale rather than delegated to the embedded IPv4 address. The
76
+ # range also covers the two entries above.
77
+ "::/96",
78
+ "64:ff9b::/96", # IPv4-IPv6 translation
79
+ "100::/64", # discard-only
80
+ "2001::/23", # IETF protocol assignments (TEREDO, ORCHID, ...)
81
+ "2001:db8::/32", # documentation
82
+ "2002::/16", # 6to4
83
+ "fc00::/7", # unique-local
84
+ "fe80::/10", # link-local
85
+ "ff00::/8", # multicast
86
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
87
+
88
+ FetchError = Class.new(StandardError)
89
+
90
+ # Everything a host can fail at while answering, so that it surfaces as
91
+ # a rejected client rather than an exception out of the endpoint.
92
+ #
93
+ # Net::HTTPBadResponse and Net::HTTPHeaderSyntaxError are listed
94
+ # explicitly because they descend straight from StandardError, *not*
95
+ # from Net::ProtocolError: a host answering with a mangled status line
96
+ # or header field raises them out of Net::HTTP.
97
+ TRANSPORT_ERRORS = [
98
+ Timeout::Error,
99
+ SystemCallError,
100
+ SocketError,
101
+ IOError,
102
+ OpenSSL::SSL::SSLError,
103
+ Net::ProtocolError,
104
+ Net::HTTPBadResponse,
105
+ Net::HTTPHeaderSyntaxError,
106
+ Resolv::ResolvError,
107
+ # Only reachable if a body is decompressed despite the identity
108
+ # Accept-Encoding requested below. Ruby can be built without zlib.
109
+ (Zlib::Error if defined?(::Zlib::Error)),
110
+ ].compact.freeze
111
+
112
+ def initialize(resolver: Resolv)
113
+ @resolver = resolver
114
+ end
115
+
116
+ # @param url [String] an already validated https:// URL
117
+ # @return [String] the response body
118
+ # @raise [FetchError] on resolution, transport or non-200 failures
119
+ def fetch(url)
120
+ uri = URI.parse(url)
121
+ # URI.parse("https:foo") yields a URI::HTTPS whose host is nil, so a
122
+ # caller's is_a?(URI::HTTPS) validation does not guarantee a host —
123
+ # and Resolv raises ArgumentError, not ResolvError, when handed nil.
124
+ raise FetchError, "#{url.inspect} has no host" if uri.host.blank?
125
+
126
+ address = vetted_address_for(uri.host)
127
+
128
+ perform_request(uri, address)
129
+ rescue *TRANSPORT_ERRORS => e
130
+ raise FetchError, "#{e.class}: #{e.message}"
131
+ end
132
+
133
+ def self.special_use?(address)
134
+ ip = address.is_a?(IPAddr) ? address : IPAddr.new(address.to_s)
135
+ # An IPv4-mapped IPv6 address is exactly as special-use as its
136
+ # embedded IPv4 address: ::ffff:127.0.0.1 must be refused while a
137
+ # mapped form of a public address stays reachable.
138
+ return special_use?(ip.native) if ip.ipv4_mapped?
139
+
140
+ SPECIAL_USE_RANGES.any? { |range| range.include?(ip) }
141
+ rescue IPAddr::InvalidAddressError
142
+ true
143
+ end
144
+
145
+ private
146
+
147
+ def vetted_address_for(host)
148
+ addresses = @resolver.getaddresses(host)
149
+ raise FetchError, "could not resolve #{host}" if addresses.empty?
150
+
151
+ # Every resolved address must be acceptable: pinning to one vetted
152
+ # address below keeps the connection off the others, but a host that
153
+ # mixes public and special-use records is treated as hostile.
154
+ if addresses.any? { |address| self.class.special_use?(address) }
155
+ raise FetchError, "#{host} resolves to a special-use address (RFC 6890)"
156
+ end
157
+
158
+ addresses.first.to_s
159
+ end
160
+
161
+ def perform_request(uri, address)
162
+ http = Net::HTTP.new(uri.host, uri.port)
163
+ http.use_ssl = true
164
+ http.ipaddr = address
165
+ http.open_timeout = OPEN_TIMEOUT
166
+ http.read_timeout = READ_TIMEOUT
167
+
168
+ request = Net::HTTP::Get.new(
169
+ uri.request_uri,
170
+ # Without an explicit Accept-Encoding, Net::HTTP negotiates gzip and
171
+ # inflates the body itself, which would both feed attacker-chosen
172
+ # bytes to zlib and turn the Content-Length check below into a check
173
+ # on the compressed size. A 5 kilobyte document does not need it.
174
+ { "Accept" => "application/json", "Accept-Encoding" => "identity" },
175
+ )
176
+ deadline = monotonic_now + MAX_TOTAL_TIME
177
+ body = nil
178
+
179
+ http.start do |connection|
180
+ # Net::HTTP never follows redirects on its own; a 3xx just fails
181
+ # the status check below.
182
+ connection.request(request) do |response|
183
+ raise FetchError, "expected 200 OK from #{uri.host}, got #{response.code}" unless response.is_a?(Net::HTTPOK)
184
+
185
+ verify_media_type!(response, uri.host)
186
+ body = bounded_body(response, uri.host, deadline)
187
+ end
188
+ end
189
+
190
+ body
191
+ end
192
+
193
+ def verify_media_type!(response, host)
194
+ declared = response["Content-Type"]
195
+ return if declared.blank?
196
+
197
+ media_type = declared.split(";").first.to_s.strip
198
+ return if JSON_MEDIA_TYPE.match?(media_type)
199
+
200
+ raise FetchError, "#{host} served #{media_type.inspect}, which is not a JSON media type"
201
+ end
202
+
203
+ # Reads the response in chunks so an oversized (or endlessly dribbled)
204
+ # body is abandoned instead of buffered in full. Raising here unwinds
205
+ # out of Net::HTTP#start, which closes the connection.
206
+ def bounded_body(response, host, deadline)
207
+ declared = response["Content-Length"]
208
+ if declared && declared.to_i > MAX_RESPONSE_SIZE
209
+ raise FetchError, "#{host} declares a #{declared} byte document, over the " \
210
+ "#{MAX_RESPONSE_SIZE} byte limit"
211
+ end
212
+
213
+ body = +""
214
+
215
+ response.read_body do |chunk|
216
+ body << chunk
217
+
218
+ if body.bytesize > MAX_RESPONSE_SIZE
219
+ raise FetchError, "the document from #{host} exceeds #{MAX_RESPONSE_SIZE} bytes"
220
+ elsif monotonic_now > deadline
221
+ raise FetchError, "reading the document from #{host} took too long"
222
+ end
223
+ end
224
+
225
+ body
226
+ end
227
+
228
+ def monotonic_now
229
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
+ end
231
+ end
232
+ end
@@ -100,6 +100,22 @@ module Doorkeeper
100
100
  column_names.include?("code_challenge")
101
101
  end
102
102
 
103
+ # RFC 8707: resource indicators are supported only when the
104
+ # `resource` column exists (added by the
105
+ # `doorkeeper:resource_indicators` generator).
106
+ def resource_indicators_supported?
107
+ column_names.include?("resource")
108
+ end
109
+
110
+ # Replay protection for authorization codes (RFC 6749 §4.1.2, §10.5)
111
+ # is active only when the `access_token_id` column exists (added by
112
+ # the `doorkeeper:grant_reuse_revocation` generator): the column
113
+ # records the access token issued when the code was exchanged, so a
114
+ # second exchange attempt can revoke it.
115
+ def access_token_revoked_on_reuse?
116
+ column_names.include?("access_token_id")
117
+ end
118
+
103
119
  ##
104
120
  # Determines the secret storing transformer
105
121
  # Unless configured otherwise, uses the plain secret strategy
@@ -229,6 +229,39 @@ module Doorkeeper
229
229
  end
230
230
  end
231
231
 
232
+ # RFC 8707: checks whether an existing token's audience matches the
233
+ # requested resource indicators. Used during token reuse to prevent
234
+ # returning a token audience-restricted to one resource for a request
235
+ # targeting a different resource.
236
+ #
237
+ # The comparison runs whenever either side carries a resource, even when
238
+ # no validator is configured: a grant bound to resources still restricts
239
+ # the token's audience (see AuthorizationCodeRequest), so reuse must not
240
+ # silently widen it by matching an unrestricted or differently-scoped
241
+ # token. When both sides are blank the tokens are unrestricted and match.
242
+ #
243
+ # @param token [Doorkeeper::AccessToken] existing token
244
+ # @param requested_resource [String, nil] space-delimited resource URIs
245
+ # @return [Boolean]
246
+ def resource_indicators_match?(token, requested_resource)
247
+ token_resource = token.try(:resource)
248
+
249
+ # Both blank — neither is audience-restricted, match.
250
+ return true if token_resource.blank? && requested_resource.blank?
251
+ # One blank, the other not — mismatch.
252
+ return false if token_resource.blank? || requested_resource.blank?
253
+
254
+ # Both present — compare as sorted sets.
255
+ token_resource.split.sort == requested_resource.split.sort
256
+ end
257
+
258
+ # RFC 8707: resource indicators are supported only when the
259
+ # `resource` column exists (added by the
260
+ # `doorkeeper:resource_indicators` generator).
261
+ def resource_indicators_supported?
262
+ column_names.include?("resource")
263
+ end
264
+
232
265
  # Looking for not expired AccessToken record with a matching set of
233
266
  # scopes that belongs to specific Application and Resource Owner.
234
267
  # If it doesn't exists - then creates it.
@@ -261,9 +294,17 @@ module Doorkeeper
261
294
  # matching token may carry the wrong refresh token presence (e.g. it
262
295
  # was issued through a grant with a different `use_refresh_token`)
263
296
  # while an older token satisfies the request and can still be reused.
297
+ #
298
+ # RFC 8707: resource indicators must also match so that a token
299
+ # audience-restricted to one resource is never reused for another.
300
+ requested_resource = token_attributes[:resource]
301
+
264
302
  access_token = matching_token_for(
265
303
  application, resource_owner, scopes, custom_attributes: custom_attributes, include_expired: false,
266
- ) { |token| refresh_token_matches?(token, token_attributes) }
304
+ ) do |token|
305
+ refresh_token_matches?(token, token_attributes) &&
306
+ resource_indicators_match?(token, requested_resource)
307
+ end
267
308
 
268
309
  return access_token if access_token&.reusable?
269
310
  end
@@ -47,6 +47,16 @@ module Doorkeeper
47
47
  attributes[:resource_owner_id] = resource_owner.id
48
48
  end
49
49
 
50
+ # RFC 8707: persist resource indicators so they can be enforced at
51
+ # the token endpoint (subset validation) and carried to the token.
52
+ if pre_auth.respond_to?(:resource_indicators) && pre_auth.resource_indicators.present?
53
+ unless Doorkeeper.config.access_grant_model.resource_indicators_supported?
54
+ raise Doorkeeper::Errors::MissingResourceColumn, "oauth_access_grants"
55
+ end
56
+
57
+ attributes[:resource] = pre_auth.resource_indicators.join(" ")
58
+ end
59
+
50
60
  pkce_attributes.merge(attributes).merge(custom_attributes)
51
61
  end
52
62
 
@@ -59,13 +59,24 @@ module Doorkeeper
59
59
  resource_owner,
60
60
  )
61
61
 
62
- @token = Doorkeeper.config.access_token_model.find_or_create_for(
62
+ token_attributes = {
63
63
  application: application,
64
64
  resource_owner: resource_owner,
65
65
  scopes: pre_auth.scopes,
66
66
  expires_in: self.class.access_token_expires_in(Doorkeeper.config, context),
67
67
  use_refresh_token: false,
68
- )
68
+ }
69
+
70
+ # RFC 8707: carry resource indicators to the access token
71
+ if pre_auth.respond_to?(:resource_indicators) && pre_auth.resource_indicators.present?
72
+ unless Doorkeeper.config.access_token_model.resource_indicators_supported?
73
+ raise Doorkeeper::Errors::MissingResourceColumn, "oauth_access_tokens"
74
+ end
75
+
76
+ token_attributes[:resource] = pre_auth.resource_indicators.join(" ")
77
+ end
78
+
79
+ @token = Doorkeeper.config.access_token_model.find_or_create_for(**token_attributes)
69
80
  end
70
81
 
71
82
  def application
@@ -9,6 +9,10 @@ module Doorkeeper
9
9
  # @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
10
10
  validate :redirect_uri, error: Errors::InvalidGrant
11
11
  validate :code_verifier, error: Errors::InvalidGrant
12
+ # Runs last, so the single-use enforcement it performs only acts once
13
+ # the caller has proven possession of the code (redirect_uri + PKCE).
14
+ validate :grant_accessible, error: Errors::InvalidGrant
15
+ validate :resource_indicators, error: Errors::InvalidTarget
12
16
 
13
17
  attr_reader :grant, :client, :redirect_uri, :access_token, :code_verifier,
14
18
  :invalid_request_reason, :missing_param
@@ -24,6 +28,7 @@ module Doorkeeper
24
28
  @grant_type = Doorkeeper::OAuth::AUTHORIZATION_CODE
25
29
  @redirect_uri = parameters[:redirect_uri]
26
30
  @code_verifier = parameters[:code_verifier]
31
+ @raw_resource_indicators = parameters[:resource]
27
32
  end
28
33
 
29
34
  private
@@ -39,16 +44,40 @@ module Doorkeeper
39
44
 
40
45
  grant.revoke
41
46
 
47
+ token_attributes = custom_token_attributes_with_data
48
+ # RFC 8707 §2.2: audience-restrict the access token to the resources
49
+ # bound to the grant. When the token request specifies a (valid)
50
+ # subset, use that subset; when it omits `resource`, inherit the
51
+ # grant's full resource set so the token is never issued without an
52
+ # audience restriction.
53
+ effective_resources = resolved_resource_indicators.presence || grant_resource_indicators
54
+ if effective_resources.present?
55
+ unless Doorkeeper.config.access_token_model.resource_indicators_supported?
56
+ raise Errors::MissingResourceColumn, "oauth_access_tokens"
57
+ end
58
+
59
+ token_attributes[:resource] = effective_resources.join(" ")
60
+ end
61
+
42
62
  find_or_create_access_token(
43
63
  client,
44
64
  resource_owner,
45
65
  grant.scopes,
46
- custom_token_attributes_with_data,
66
+ token_attributes,
47
67
  server,
48
68
  )
69
+
70
+ link_access_token_to_grant
49
71
  end
50
72
 
51
73
  super
74
+ rescue Errors::InvalidGrantReuse
75
+ # A concurrent exchange of the same code won the race: the raise
76
+ # rolled this transaction back, so the revocation must happen
77
+ # outside of it. `lock!` reloaded the grant after the winning
78
+ # exchange committed, so the token linkage is visible here.
79
+ revoke_token_issued_for_grant
80
+ raise
52
81
  end
53
82
 
54
83
  def resource_owner
@@ -81,7 +110,16 @@ module Doorkeeper
81
110
  end
82
111
 
83
112
  def validate_grant
84
- return false unless grant && grant.application_id == client.id
113
+ grant && grant.application_id == client.id
114
+ end
115
+
116
+ # Checked after redirect_uri and PKCE so that a caller who cannot prove
117
+ # possession of the code never reaches the reuse handling below.
118
+ def validate_grant_accessible
119
+ # Authorization codes are single-use (RFC 6749 §4.1.2): observing a
120
+ # second exchange attempt denies the request and revokes the tokens
121
+ # already issued for the code (§10.5).
122
+ revoke_token_issued_for_grant if grant.revoked?
85
123
 
86
124
  grant.accessible?
87
125
  end
@@ -120,9 +158,92 @@ module Doorkeeper
120
158
  .symbolize_keys
121
159
  end
122
160
 
161
+ # RFC 8707: validate resource indicators on the token request.
162
+ # If the grant carries resource indicators, the token request's resource
163
+ # parameter must be a subset. If no grant resource is present, the
164
+ # validator checks the request resource against server policy.
165
+ #
166
+ # Subset and syntax enforcement run even when no validator is configured
167
+ # as long as the grant is already audience-restricted: a grant bound to
168
+ # resources must never be exchanged for a token whose audience widens
169
+ # beyond it. Only when the feature is disabled AND the grant has no
170
+ # stored resources is the `resource` parameter ignored entirely.
171
+ def validate_resource_indicators
172
+ @grant_resource_indicators = grant&.try(:resource)&.split
173
+
174
+ validator = Doorkeeper.config.resource_indicator_validator
175
+
176
+ # Feature effectively off: no validator and nothing already bound to
177
+ # enforce against. Ignore the `resource` parameter.
178
+ return true if validator.nil? && @grant_resource_indicators.blank?
179
+
180
+ @resolved_resource_indicators = ResourceIndicatorValidator.validate!(
181
+ @raw_resource_indicators,
182
+ config_validator: validator,
183
+ client: client,
184
+ grant_resource_indicators: @grant_resource_indicators,
185
+ )
186
+ true
187
+ rescue Errors::InvalidTarget
188
+ false
189
+ end
190
+
191
+ def resolved_resource_indicators
192
+ @resolved_resource_indicators || []
193
+ end
194
+
195
+ def grant_resource_indicators
196
+ @grant_resource_indicators || []
197
+ end
198
+
123
199
  def revoke_previous_tokens(application, resource_owner)
124
200
  Doorkeeper.config.access_token_model.revoke_all_for(application.id, resource_owner)
125
201
  end
202
+
203
+ def link_access_token_to_grant
204
+ return unless grant.class.access_token_revoked_on_reuse?
205
+
206
+ grant.class.with_primary_role do
207
+ grant.update_column(:access_token_id, access_token.id)
208
+ end
209
+ end
210
+
211
+ def revoke_token_issued_for_grant
212
+ return unless grant.class.access_token_revoked_on_reuse?
213
+ return if grant.access_token_id.blank?
214
+
215
+ # Look the token up on the primary too: a lagging read replica may not
216
+ # have it yet, which would silently skip the revocation.
217
+ Doorkeeper.config.access_token_model.with_primary_role do
218
+ token = Doorkeeper.config.access_token_model.find_by(id: grant.access_token_id)
219
+ next if token.nil?
220
+
221
+ # With `reuse_access_token` the same token can back several grants
222
+ # (find_or_create returns a shared one). Revoking it on a replay of
223
+ # this grant's code would take down another valid session that still
224
+ # holds it. Only revoke when no other grant references the token, so
225
+ # the single-use revocation reaches a token unique to the replayed
226
+ # code and never collaterally revokes a reused, shared one.
227
+ next if token_shared_with_other_grant?(token)
228
+
229
+ token.revoke
230
+ end
231
+ end
232
+
233
+ # Reads the grant -> token link, which lives in the optional
234
+ # `oauth_access_grants.access_token_id` column: new installs get it from
235
+ # the generated migration, existing apps add it with the
236
+ # `doorkeeper:grant_reuse_revocation` generator. Callers must therefore
237
+ # guard with `access_token_revoked_on_reuse?` (as
238
+ # `link_access_token_to_grant` and `revoke_token_issued_for_grant` do),
239
+ # so an app that never ran the generator returns early and never queries
240
+ # a column it does not have.
241
+ def token_shared_with_other_grant?(token)
242
+ grant.class
243
+ .where(access_token_id: token.id)
244
+ .where.not(id: grant.id)
245
+ .exists?
246
+ end
126
247
  end
127
248
  end
128
249
  end
@@ -59,8 +59,7 @@ module Doorkeeper
59
59
  client_scopes = @client&.scopes
60
60
  return default_scopes if client_scopes.blank?
61
61
 
62
- # Avoid using Scope#& for dynamic scopes
63
- client_scopes.allowed(default_scopes)
62
+ client_scopes.common(default_scopes)
64
63
  end
65
64
  end
66
65
  end