doorkeeper 6.0.0.beta2 → 6.0.0.rc2

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 +48 -2
  3. data/README.md +29 -0
  4. data/app/controllers/doorkeeper/authorizations_controller.rb +66 -3
  5. data/app/controllers/doorkeeper/authorized_applications_controller.rb +23 -0
  6. data/app/controllers/doorkeeper/token_info_controller.rb +13 -1
  7. data/config/locales/en.yml +1 -0
  8. data/lib/doorkeeper/config/validations.rb +91 -0
  9. data/lib/doorkeeper/config.rb +18 -2
  10. data/lib/doorkeeper/errors.rb +14 -0
  11. data/lib/doorkeeper/grape/helpers.rb +11 -1
  12. data/lib/doorkeeper/helpers/controller.rb +12 -0
  13. data/lib/doorkeeper/http_fetcher.rb +79 -19
  14. data/lib/doorkeeper/models/access_token_mixin.rb +120 -2
  15. data/lib/doorkeeper/models/concerns/secret_storable.rb +72 -6
  16. data/lib/doorkeeper/oauth/authorization/token.rb +87 -8
  17. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +13 -0
  18. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +27 -20
  19. data/lib/doorkeeper/oauth/client_credentials/creator.rb +17 -3
  20. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +14 -0
  21. data/lib/doorkeeper/oauth/metadata_response.rb +4 -3
  22. data/lib/doorkeeper/oauth/pre_authorization.rb +50 -0
  23. data/lib/doorkeeper/oauth/refresh_token_request.rb +81 -24
  24. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +10 -0
  25. data/lib/doorkeeper/oauth/token.rb +94 -2
  26. data/lib/doorkeeper/oauth/token_introspection.rb +14 -1
  27. data/lib/doorkeeper/orm/active_record/mixins/access_grant.rb +1 -0
  28. data/lib/doorkeeper/orm/active_record/mixins/access_token.rb +1 -0
  29. data/lib/doorkeeper/orm/active_record/mixins/application.rb +1 -0
  30. data/lib/doorkeeper/orm/active_record/mixins/secret_storable.rb +113 -0
  31. data/lib/doorkeeper/orm/active_record/redirect_uri_validator.rb +5 -1
  32. data/lib/doorkeeper/orm/active_record.rb +1 -0
  33. data/lib/doorkeeper/rails/helpers.rb +25 -2
  34. data/lib/doorkeeper/request.rb +19 -7
  35. data/lib/doorkeeper/version.rb +1 -1
  36. data/lib/doorkeeper.rb +11 -0
  37. data/lib/generators/doorkeeper/refresh_token_scopes_generator.rb +43 -0
  38. data/lib/generators/doorkeeper/templates/add_refresh_token_scopes_to_access_tokens.rb.erb +10 -0
  39. data/lib/generators/doorkeeper/templates/initializer.rb +94 -15
  40. data/lib/generators/doorkeeper/templates/migration.rb.erb +9 -0
  41. metadata +12 -12
@@ -13,13 +13,15 @@ module Doorkeeper
13
13
  # SSRF hardening: the host is resolved up front and the request is refused
14
14
  # when any resolved address falls into an RFC 6890 special-use range
15
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
16
+ # to a vetted address via Net::HTTP#ipaddr= so a second, post-check DNS
17
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.
18
+ # negotiated and verified against the original hostname. When that address
19
+ # cannot be connected to, the host's remaining vetted addresses are tried
20
+ # in turn. An exception for authorization servers themselves running on a
21
+ # loopback interface is intentionally not implemented. These rules follow
22
+ # the fetch hardening of draft-ietf-oauth-client-id-metadata-document
23
+ # (Sections 6.5 / 6.6), which fetches documents from the same kind of
24
+ # client-chosen URL.
23
25
  #
24
26
  # The response body is bounded and so is the total time spent reading it:
25
27
  # a per-read timeout alone does not stop a server that dribbles bytes out
@@ -87,6 +89,15 @@ module Doorkeeper
87
89
 
88
90
  FetchError = Class.new(StandardError)
89
91
 
92
+ # A transport failure raised while the connection was still being
93
+ # established. We'll try to connect to another address if we see this
94
+ # error.
95
+ #
96
+ # Note that this includes some errors that can happen after establishing a
97
+ # TCP connection such as failing to complete a TLS handshake.
98
+ ConnectError = Class.new(StandardError)
99
+ private_constant :ConnectError
100
+
90
101
  # Everything a host can fail at while answering, so that it surfaces as
91
102
  # a rejected client rather than an exception out of the endpoint.
92
103
  #
@@ -123,9 +134,23 @@ module Doorkeeper
123
134
  # and Resolv raises ArgumentError, not ResolvError, when handed nil.
124
135
  raise FetchError, "#{url.inspect} has no host" if uri.host.blank?
125
136
 
126
- address = vetted_address_for(uri.host)
137
+ addresses = vetted_addresses_for(uri.host)
138
+ # One deadline covers connection attempts to all addresses in aggregate.
139
+ deadline = monotonic_now + MAX_TOTAL_TIME
140
+ # If we attempt to connect to multiple addresses and all of them fail,
141
+ # arbitrarily surface the last error we received although all of them are
142
+ # equally valid.
143
+ last_error = nil
144
+
145
+ addresses.each do |address|
146
+ break if last_error && monotonic_now >= deadline
127
147
 
128
- perform_request(uri, address)
148
+ return perform_request(uri, address, deadline)
149
+ rescue ConnectError => e
150
+ last_error = e
151
+ end
152
+
153
+ raise FetchError, "could not connect to #{uri.host}: #{last_error.message}"
129
154
  rescue *TRANSPORT_ERRORS => e
130
155
  raise FetchError, "#{e.class}: #{e.message}"
131
156
  end
@@ -144,7 +169,7 @@ module Doorkeeper
144
169
 
145
170
  private
146
171
 
147
- def vetted_address_for(host)
172
+ def vetted_addresses_for(host)
148
173
  addresses = @resolver.getaddresses(host)
149
174
  raise FetchError, "could not resolve #{host}" if addresses.empty?
150
175
 
@@ -155,16 +180,34 @@ module Doorkeeper
155
180
  raise FetchError, "#{host} resolves to a special-use address (RFC 6890)"
156
181
  end
157
182
 
158
- addresses.first.to_s
183
+ addresses.map(&:to_s)
159
184
  end
160
185
 
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
186
+ def connection_for(uri, address, deadline)
187
+ remaining = remaining_until(deadline)
188
+ raise ConnectError, "no time left to connect to #{address}" if remaining <= 0
189
+
190
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
191
+ http.use_ssl = true
192
+ http.ipaddr = address
193
+ # Net::HTTP can use up to the whole `open_timeout` establishing the TCP
194
+ # connection and again for the TLS handshake. Rather than potentially
195
+ # exceeding `MAX_TOTAL_TIME` by up to `OPEN_TIMEOUT` seconds, we pass
196
+ # in half the timeout to keep the true open time within `OPEN_TIMEOUT`.
197
+ #
198
+ # The initial request is unaffected because half of `MAX_TOTAL_TIME` is
199
+ # not less than `OPEN_TIMEOUT`.
200
+ http.open_timeout = [OPEN_TIMEOUT, remaining / 2.0].min
201
+ http.read_timeout = READ_TIMEOUT
202
+ # Disable Net::HTTP internal retries to prevent re-attempting reads
203
+ # which will reuse the same timeout params and may exceed the overall
204
+ # timeout we're aiming for.
205
+ http.max_retries = 0
206
+ end
207
+ end
167
208
 
209
+ def perform_request(uri, address, deadline)
210
+ http = connection_for(uri, address, deadline)
168
211
  request = Net::HTTP::Get.new(
169
212
  uri.request_uri,
170
213
  # Without an explicit Accept-Encoding, Net::HTTP negotiates gzip and
@@ -173,21 +216,29 @@ module Doorkeeper
173
216
  # on the compressed size. A 5 kilobyte document does not need it.
174
217
  { "Accept" => "application/json", "Accept-Encoding" => "identity" },
175
218
  )
176
- deadline = monotonic_now + MAX_TOTAL_TIME
177
219
  body = nil
220
+ connected = false
178
221
 
179
222
  http.start do |connection|
223
+ connected = true
224
+ # Re-set this timeout later in the connection to account for the time
225
+ # we spent trying to connect to the address.
226
+ connection.read_timeout = [READ_TIMEOUT, remaining_until(deadline)].min
180
227
  # Net::HTTP never follows redirects on its own; a 3xx just fails
181
228
  # the status check below.
182
229
  connection.request(request) do |response|
183
230
  raise FetchError, "expected 200 OK from #{uri.host}, got #{response.code}" unless response.is_a?(Net::HTTPOK)
184
231
 
185
232
  verify_media_type!(response, uri.host)
186
- body = bounded_body(response, uri.host, deadline)
233
+ body = bounded_body(response, connection, uri.host, deadline)
187
234
  end
188
235
  end
189
236
 
190
237
  body
238
+ rescue *TRANSPORT_ERRORS => e
239
+ raise if connected
240
+
241
+ raise ConnectError, "#{e.class}: #{e.message}"
191
242
  end
192
243
 
193
244
  def verify_media_type!(response, host)
@@ -203,7 +254,7 @@ module Doorkeeper
203
254
  # Reads the response in chunks so an oversized (or endlessly dribbled)
204
255
  # body is abandoned instead of buffered in full. Raising here unwinds
205
256
  # out of Net::HTTP#start, which closes the connection.
206
- def bounded_body(response, host, deadline)
257
+ def bounded_body(response, connection, host, deadline)
207
258
  declared = response["Content-Length"]
208
259
  if declared && declared.to_i > MAX_RESPONSE_SIZE
209
260
  raise FetchError, "#{host} declares a #{declared} byte document, over the " \
@@ -220,6 +271,11 @@ module Doorkeeper
220
271
  elsif monotonic_now > deadline
221
272
  raise FetchError, "reading the document from #{host} took too long"
222
273
  end
274
+
275
+ # Shorten the timeout for the next read if we've already spent a lot of
276
+ # time connecting and reading part of the request. Otherwise we could
277
+ # overshoot `MAX_TOTAL_TIME` by up to `READ_TIMEOUT` seconds.
278
+ connection.read_timeout = [connection.read_timeout, remaining_until(deadline)].min
223
279
  end
224
280
 
225
281
  body
@@ -228,5 +284,9 @@ module Doorkeeper
228
284
  def monotonic_now
229
285
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
230
286
  end
287
+
288
+ def remaining_until(deadline)
289
+ [deadline - monotonic_now, 0].max
290
+ end
231
291
  end
232
292
  end
@@ -179,6 +179,34 @@ module Doorkeeper
179
179
  access_token.refresh_token.present? == !!token_attributes[:use_refresh_token]
180
180
  end
181
181
 
182
+ # Checks whether a candidate token's refresh token carries the scope
183
+ # the current request grants.
184
+ #
185
+ # A refresh token issued for a grant carries that grant's scope
186
+ # (RFC 6749 §6). A candidate whose refresh token was granted a wider
187
+ # scope than the access token it is paired with (a chain narrowed by an
188
+ # earlier refresh) would hand the new grant a refresh token able to mint
189
+ # more than the grant allows, so it is not reused. Rows without a stored
190
+ # granted scope (no column, or created before its migration) report the
191
+ # access token scope and match as before, and a candidate without a
192
+ # refresh token has nothing to mint with, so its stored value is not
193
+ # consulted.
194
+ #
195
+ # @param access_token [Doorkeeper::AccessToken]
196
+ # the candidate token for reuse
197
+ # @param scopes [Doorkeeper::OAuth::Scopes]
198
+ # scopes the request grants
199
+ #
200
+ # @return [Boolean] true if the candidate's refresh token scope equals
201
+ # the requested scopes, false otherwise
202
+ #
203
+ def refresh_token_scopes_match?(access_token, scopes)
204
+ return true unless refresh_token_scopes_supported?
205
+ return true if access_token.refresh_token.blank?
206
+
207
+ access_token.refresh_token_scopes == scopes
208
+ end
209
+
182
210
  # Checks whether the token scopes match the scopes from the parameters
183
211
  #
184
212
  # @param token_scopes [#to_s]
@@ -262,6 +290,20 @@ module Doorkeeper
262
290
  column_names.include?("resource")
263
291
  end
264
292
 
293
+ # RFC 6749 §6: a refresh token keeps the scope originally granted by the
294
+ # resource owner even when the access tokens issued with it are
295
+ # narrowed, so that a later refresh can restore the granted scope.
296
+ # Doorkeeper tracks that scope in the `refresh_token_scopes` column
297
+ # (added by the `doorkeeper:refresh_token_scopes` generator). Without
298
+ # the column a refresh is validated against the presented access
299
+ # token's scope, so a narrowed refresh permanently narrows the chain.
300
+ #
301
+ # @return [Boolean] true if the refresh_token_scopes column exists
302
+ #
303
+ def refresh_token_scopes_supported?
304
+ column_names.include?("refresh_token_scopes")
305
+ end
306
+
265
307
  # Looking for not expired AccessToken record with a matching set of
266
308
  # scopes that belongs to specific Application and Resource Owner.
267
309
  # If it doesn't exists - then creates it.
@@ -275,7 +317,12 @@ module Doorkeeper
275
317
  # @param token_attributes [Hash]
276
318
  # Additional attributes to use when creating a token
277
319
  # @option token_attributes [Integer] :expires_in
278
- # token lifetime in seconds
320
+ # token lifetime in seconds. It is used as given for a new token: with
321
+ # +public_client_access_token_expires_in+ configured, pass a TTL
322
+ # already capped for the application (see
323
+ # +Doorkeeper::OAuth::Authorization::Token.access_token_expires_in+ or
324
+ # +.cap_for_public_client+), since an existing token that outlives the
325
+ # cap is not reused for a public client
279
326
  # @option token_attributes [Boolean] :use_refresh_token
280
327
  # whether to use the refresh token
281
328
  #
@@ -297,13 +344,21 @@ module Doorkeeper
297
344
  #
298
345
  # RFC 8707: resource indicators must also match so that a token
299
346
  # audience-restricted to one resource is never reused for another.
347
+ #
348
+ # A token that would outlive +public_client_access_token_expires_in+
349
+ # is not handed out again either, so the cap holds for a public
350
+ # client whatever tokens it was issued before.
300
351
  requested_resource = token_attributes[:resource]
301
352
 
302
353
  access_token = matching_token_for(
303
354
  application, resource_owner, scopes, custom_attributes: custom_attributes, include_expired: false,
304
355
  ) do |token|
305
356
  refresh_token_matches?(token, token_attributes) &&
306
- resource_indicators_match?(token, requested_resource)
357
+ refresh_token_scopes_match?(token, scopes) &&
358
+ resource_indicators_match?(token, requested_resource) &&
359
+ Doorkeeper::OAuth::Authorization::Token.within_public_client_expires_in?(
360
+ Doorkeeper.config, application, token,
361
+ )
307
362
  end
308
363
 
309
364
  return access_token if access_token&.reusable?
@@ -429,6 +484,55 @@ module Doorkeeper
429
484
  !!@use_refresh_token
430
485
  end
431
486
 
487
+ # Scope the refresh token was issued with: the scope originally granted
488
+ # by the resource owner (RFC 6749 §6), which the refresh grant validates
489
+ # a requested scope against and issues the next refresh token with.
490
+ #
491
+ # Falls back to the access token scope when the `refresh_token_scopes`
492
+ # column is absent or empty (rows that predate its migration), which is
493
+ # the behavior Doorkeeper had before the column existed.
494
+ #
495
+ # @return [Doorkeeper::OAuth::Scopes] refresh token scope
496
+ #
497
+ def refresh_token_scopes
498
+ stored = refresh_token_scopes_string
499
+ return scopes if stored.blank?
500
+
501
+ OAuth::Scopes.from_string(stored)
502
+ end
503
+
504
+ # @param value [String, Array, Doorkeeper::OAuth::Scopes, nil]
505
+ # scope to store; normalized the same way `scopes=` is. A blank value
506
+ # means "no explicit granted scope", so `generate_refresh_token` derives
507
+ # it from the access token scope as if nothing had been assigned.
508
+ # Ignored when the `refresh_token_scopes` column is absent, so a host
509
+ # app that assigns it ahead of the migration keeps the fallback above
510
+ # instead of failing on the missing attribute.
511
+ def refresh_token_scopes=(value)
512
+ return unless self.class.refresh_token_scopes_supported?
513
+
514
+ normalized =
515
+ if value.is_a?(Array)
516
+ OAuth::Scopes.from_array(value).to_s
517
+ else
518
+ OAuth::Scopes.from_string(value.to_s).to_s
519
+ end
520
+
521
+ @refresh_token_scopes_assigned = normalized.present?
522
+ super(normalized)
523
+ end
524
+
525
+ # Raw `refresh_token_scopes` column value, nil when the column is
526
+ # absent.
527
+ #
528
+ # @return [String, nil]
529
+ #
530
+ def refresh_token_scopes_string
531
+ return unless self.class.refresh_token_scopes_supported?
532
+
533
+ self[:refresh_token_scopes]
534
+ end
535
+
432
536
  # JSON representation of the Access Token instance.
433
537
  #
434
538
  # @return [Hash] hash with token data
@@ -548,6 +652,20 @@ module Doorkeeper
548
652
  # @return [String] refresh token value
549
653
  #
550
654
  def generate_refresh_token
655
+ # A refresh token issued outside the refresh grant (authorization code,
656
+ # password, or a host app creating the record itself) starts its chain
657
+ # with the granted scope: the access token's own scope. The refresh
658
+ # grant assigns the attribute explicitly to carry the presented refresh
659
+ # token's scope forward instead.
660
+ #
661
+ # This callback runs on every validation of a new record, so the
662
+ # derived value is written each time rather than only when blank:
663
+ # a scope narrowed between `valid?` and `save` must not leave the
664
+ # refresh token with the wider scope of the earlier validation. The
665
+ # column is written directly so the derivation does not count as an
666
+ # explicit assignment.
667
+ self[:refresh_token_scopes] = scopes.to_s if self.class.refresh_token_scopes_supported? && !@refresh_token_scopes_assigned
668
+
551
669
  @raw_refresh_token = UniqueToken.generate
552
670
  secret_strategy.store_secret(self, :refresh_token, @raw_refresh_token)
553
671
  end
@@ -70,8 +70,9 @@ module Doorkeeper
70
70
  end
71
71
  end
72
72
 
73
- # Allow implementations in ORMs to replace a plain
74
- # value falling back to to avoid it remaining as plain text.
73
+ # Replaces a value found through the fallback strategy with its
74
+ # upgraded form, so it does not remain stored under the old strategy
75
+ # (e.g. as plain text).
75
76
  #
76
77
  # @param instance
77
78
  # An instance of this model with a plain value token.
@@ -82,13 +83,78 @@ module Doorkeeper
82
83
  # @param plain_secret
83
84
  # The plain secret to upgrade.
84
85
  #
86
+ # @return [Boolean]
87
+ # Whether the stored value was upgraded.
88
+ #
85
89
  def upgrade_fallback_value(instance, attr, plain_secret)
90
+ # The value the fallback strategy matched against, read before
91
+ # `store_secret` assigns the upgraded one over it.
92
+ matched = instance.public_send(attr)
86
93
  upgraded = secret_strategy.store_secret(instance, attr, plain_secret)
87
94
 
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.
95
+ return true if write_upgraded_secret(instance, attr, matched, upgraded)
96
+
97
+ # Nothing was written: put the instance back rather than leave it
98
+ # carrying a secret that was never stored.
99
+ restore_matched_secret(instance, attr, matched)
100
+ false
101
+ end
102
+
103
+ # ORM hook: puts +matched+ back on +instance+ after nothing was
104
+ # written. This default assigns through the attribute writer, which
105
+ # an ORM can bypass the way the Active Record implementation does:
106
+ # a custom writer can transform +matched+ on the way in, leaving the
107
+ # attribute dirty with a value the row never held, and a later save
108
+ # would write that over whatever replaced the matched secret — the
109
+ # stale write the conditional upgrade exists to prevent.
110
+ #
111
+ # @param instance
112
+ # The instance to restore, still carrying the unwritten upgrade.
113
+ #
114
+ # @param attr
115
+ # The secret attribute name being restored.
116
+ #
117
+ # @param matched
118
+ # The stored value the fallback lookup matched.
119
+ #
120
+ def restore_matched_secret(instance, attr, matched)
121
+ instance.public_send(:"#{attr}=", matched)
122
+ end
123
+
124
+ # ORM hook: writes the upgraded value to storage and answers whether
125
+ # it was written.
126
+ #
127
+ # The upgrade is a write on what is otherwise a read path (finding a
128
+ # record by its secret), so the row can move between the match and
129
+ # this write — another request renewing the secret, or the
130
+ # application replacing it. An ORM can therefore make the write
131
+ # conditional on +attr+ still holding +matched+, answering false when
132
+ # it no longer does, as the Active Record implementation does
133
+ # (Doorkeeper::Orm::ActiveRecord::Mixins::SecretStorable). This
134
+ # default keeps the historical unconditional write for ORMs that have
135
+ # not implemented a conditional one.
136
+ #
137
+ # @param instance
138
+ # The instance whose row to write, already carrying +upgraded+.
139
+ #
140
+ # @param attr
141
+ # The secret attribute name being upgraded.
142
+ #
143
+ # @param matched
144
+ # The stored value the fallback lookup matched.
145
+ #
146
+ # @param upgraded
147
+ # The upgraded value to store, as returned by the strategy. The
148
+ # instance carries it too, assigned through the attribute writer —
149
+ # an implementation that writes past the writer should persist the
150
+ # value the writer left on the instance rather than this one.
151
+ #
152
+ # @return [Boolean]
153
+ # Whether the value was written.
154
+ #
155
+ def write_upgraded_secret(instance, attr, _matched, upgraded)
156
+ # The write must reach the primary database when automatic role
157
+ # switching would route the surrounding request to a read replica.
92
158
  if respond_to?(:with_primary_role)
93
159
  with_primary_role { instance.update(attr => upgraded) }
94
160
  else
@@ -24,15 +24,72 @@ module Doorkeeper
24
24
  )
25
25
  end
26
26
 
27
- def access_token_expires_in(configuration, context)
28
- if configuration.option_defined?(:custom_access_token_expires_in)
29
- expiration = configuration.custom_access_token_expires_in.call(context)
30
- return nil if expiration == Float::INFINITY
27
+ # TTL for a new access token issued in +context+.
28
+ #
29
+ # When +custom_access_token_expires_in+ is configured and returns a
30
+ # value, that value is used (+Float::INFINITY+ meaning "never
31
+ # expires"). Otherwise the block, when given, supplies the TTL, and
32
+ # without a block it is +access_token_expires_in+.
33
+ #
34
+ # Whatever the source, the result is then capped for a public client
35
+ # by +public_client_access_token_expires_in+ (see
36
+ # +cap_for_public_client+).
37
+ #
38
+ # @yieldreturn [Integer, nil] the TTL to fall back to when no custom
39
+ # expiration applies (the refresh_token grant passes the TTL of the
40
+ # token being refreshed here).
41
+ def access_token_expires_in(configuration, context, &fallback)
42
+ fallback ||= -> { configuration.access_token_expires_in }
43
+
44
+ expires_in = if configuration.option_defined?(:custom_access_token_expires_in)
45
+ expiration = configuration.custom_access_token_expires_in.call(context)
46
+ expiration == Float::INFINITY ? nil : (expiration || fallback.call)
47
+ else
48
+ fallback.call
49
+ end
50
+
51
+ cap_for_public_client(configuration, context.client, expires_in)
52
+ end
31
53
 
32
- expiration || configuration.access_token_expires_in
33
- else
34
- configuration.access_token_expires_in
35
- end
54
+ # OAuth 2.1 (draft-ietf-oauth-v2-1-15) Section 2.4: the authorization
55
+ # server must limit the exposure of tokens issued to unauthenticated
56
+ # clients. When +public_client_access_token_expires_in+ is configured,
57
+ # +expires_in+ is capped to it unless +application+ is a confidential
58
+ # client. A request that carries no application (client
59
+ # authentication skipped) is treated as a public client, and so is a
60
+ # never-expiring TTL (nil), which becomes the cap.
61
+ #
62
+ # @param application [Doorkeeper::Application, nil]
63
+ # @param expires_in [Integer, String, nil] the TTL the configuration
64
+ # would issue; a numeric String is compared as a number and
65
+ # returned as given, for the ORM to cast
66
+ # @return [Integer, String, nil]
67
+ def cap_for_public_client(configuration, application, expires_in)
68
+ cap = public_client_expires_in_cap(configuration, application)
69
+ return expires_in if cap.nil?
70
+
71
+ [expires_in, cap].compact.min_by { |ttl| comparable_ttl(ttl) }
72
+ end
73
+
74
+ # Whether +access_token+, found for reuse (+reuse_access_token+), can
75
+ # be handed out again to +application+ without exceeding
76
+ # +public_client_access_token_expires_in+. A token issued before the
77
+ # cap was configured, or while the application was still
78
+ # confidential, can outlive it. What counts is the lifetime the token
79
+ # has left, since that is what handing it out again exposes; a
80
+ # never-expiring token never fits under a cap. An ORM extension with
81
+ # its own +find_or_create_for+ should apply the same check to its
82
+ # reuse candidates.
83
+ #
84
+ # @param application [Doorkeeper::Application, nil]
85
+ # @param access_token [Doorkeeper::AccessToken] the candidate for reuse
86
+ # @return [Boolean]
87
+ def within_public_client_expires_in?(configuration, application, access_token)
88
+ cap = public_client_expires_in_cap(configuration, application)
89
+ return true if cap.nil?
90
+ return false if access_token.expires_in.nil?
91
+
92
+ access_token.expires_in_seconds <= comparable_ttl(cap)
36
93
  end
37
94
 
38
95
  def refresh_token_enabled?(server, context)
@@ -42,6 +99,28 @@ module Doorkeeper
42
99
  !!server.refresh_token_enabled?
43
100
  end
44
101
  end
102
+
103
+ private
104
+
105
+ # The cap +public_client_access_token_expires_in+ puts on tokens
106
+ # issued to +application+, or nil when none applies.
107
+ def public_client_expires_in_cap(configuration, application)
108
+ cap = configuration.public_client_access_token_expires_in
109
+ return if cap.nil?
110
+ return if application.respond_to?(:confidential?) && application.confidential?
111
+
112
+ cap
113
+ end
114
+
115
+ # A TTL can arrive as a numeric String: read from ENV without #to_i,
116
+ # or returned by a +custom_access_token_expires_in+ callable. The ORM
117
+ # casts it on write, but compared as is it raises against an Integer,
118
+ # and two Strings compare lexicographically ("86400" < "900"). It is
119
+ # compared the way the ORM casts it (#to_i), so the value compared is
120
+ # the value stored.
121
+ def comparable_ttl(ttl)
122
+ ttl.is_a?(String) ? ttl.to_i : ttl
123
+ end
45
124
  end
46
125
 
47
126
  def initialize(pre_auth, resource_owner)
@@ -32,6 +32,19 @@ module Doorkeeper
32
32
  client_id, client_secret = credentials_from(request)
33
33
  return unless client_id
34
34
 
35
+ # RFC 7521 §4.2: a client_id parameter sent alongside another
36
+ # authentication method must agree with the identity that method
37
+ # establishes — the same check PrivateKeyJwt applies to an
38
+ # assertion's issuer. A request carrying credentials for one client
39
+ # and a client_id naming another presents two client identities and
40
+ # authenticates neither: without this it would be authenticated as
41
+ # the Basic client while the body asked to act as a different one,
42
+ # with nothing signalling the mismatch. A bare client_id is not an
43
+ # authentication method of its own, so +validate_client_authentication!+
44
+ # deliberately doesn't count it and cannot catch this.
45
+ request_client_id = request.request_parameters["client_id"] || request.request_parameters[:client_id]
46
+ return if request_client_id.present? && request_client_id != client_id
47
+
35
48
  Doorkeeper::ClientAuthentication::Credentials.new(client_id, client_secret)
36
49
  end
37
50
 
@@ -53,6 +53,17 @@ module Doorkeeper
53
53
  def self.authenticate(request)
54
54
  require_jwt!
55
55
 
56
+ # A server that identifies itself nowhere has no audience to check
57
+ # an assertion against, and aud is the only claim that ties one to
58
+ # this server rather than to another one. Nothing can authenticate
59
+ # against an empty list, so the assertion is refused here rather
60
+ # than after the client has been looked up and its keys resolved —
61
+ # which, for a client published through a jwks_uri, would mean an
62
+ # outbound request on every cache miss for an authentication that
63
+ # cannot succeed.
64
+ audiences = acceptable_audiences(request)
65
+ return if audiences.empty?
66
+
56
67
  params = request.request_parameters.with_indifferent_access
57
68
  assertion = params[:client_assertion].to_s
58
69
 
@@ -68,7 +79,7 @@ module Doorkeeper
68
79
  jwk_set = KeyResolver.jwk_set_for(application)
69
80
  return unless jwk_set
70
81
 
71
- claims = verified_claims(assertion, client_id, jwk_set, request)
82
+ claims = verified_claims(assertion, client_id, jwk_set, audiences)
72
83
  return unless claims
73
84
  return unless replay_guard.first_use?(
74
85
  "#{client_id}:#{claims["jti"]}",
@@ -104,7 +115,7 @@ module Doorkeeper
104
115
  end
105
116
  private_class_method :unverified_client_id
106
117
 
107
- def self.verified_claims(assertion, client_id, jwk_set, request)
118
+ def self.verified_claims(assertion, client_id, jwk_set, audiences)
108
119
  claims, = ::JWT.decode(
109
120
  assertion,
110
121
  nil,
@@ -116,7 +127,7 @@ module Doorkeeper
116
127
  verify_iss: true,
117
128
  sub: client_id,
118
129
  verify_sub: true,
119
- aud: acceptable_audiences(request),
130
+ aud: audiences,
120
131
  verify_aud: true,
121
132
  # Passed explicitly so a host application that globally disabled
122
133
  # expiration checking for its own tokens (JWT.configuration.decode)
@@ -152,29 +163,25 @@ module Doorkeeper
152
163
  # token endpoint URL is accepted at the revocation and introspection
153
164
  # endpoints too, not just at the endpoint being called.
154
165
  #
155
- # The endpoint URLs are built from the server's own configured
166
+ # Every acceptable value is built from the server's own configured
156
167
  # identity, never from the request: aud is what keeps an assertion
157
168
  # minted for another authorization server from being replayed here, so
158
- # deriving it from the client-supplied Host header would defeat its
159
- # purpose on any deployment that does not filter hosts. Only a server
160
- # that identifies itself nowhere falls back to the request, which is
161
- # how MetadataResponse derives its issuer as well.
169
+ # deriving it from the client-supplied Host header would let whoever
170
+ # sends the assertion choose the audience it is checked against. A
171
+ # server that identifies itself nowhere therefore accepts no audience
172
+ # at all, and is warned about that at boot.
162
173
  def self.acceptable_audiences(request)
163
- options = server_url_options(request)
174
+ audiences = [Doorkeeper.config.issuer.presence]
175
+ options = configured_url_options
164
176
 
165
- [
166
- Doorkeeper.config.issuer.presence,
167
- "#{base_url(options)}#{request.path}",
168
- token_endpoint_url(options),
169
- ].compact.uniq
170
- end
171
- private_class_method :acceptable_audiences
177
+ if options
178
+ audiences << "#{base_url(options)}#{request.path}"
179
+ audiences << token_endpoint_url(options)
180
+ end
172
181
 
173
- def self.server_url_options(request)
174
- configured_url_options ||
175
- { protocol: request.protocol, host: request.host, port: request.optional_port }
182
+ audiences.compact.uniq
176
183
  end
177
- private_class_method :server_url_options
184
+ private_class_method :acceptable_audiences
178
185
 
179
186
  # An explicitly configured canonical host wins; failing that, the
180
187
  # issuer, when it is an absolute URL (Doorkeeper allows any string).