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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -2
- data/README.md +29 -0
- data/app/controllers/doorkeeper/authorizations_controller.rb +66 -3
- data/app/controllers/doorkeeper/authorized_applications_controller.rb +23 -0
- data/app/controllers/doorkeeper/token_info_controller.rb +13 -1
- data/config/locales/en.yml +1 -0
- data/lib/doorkeeper/config/validations.rb +91 -0
- data/lib/doorkeeper/config.rb +18 -2
- data/lib/doorkeeper/errors.rb +14 -0
- data/lib/doorkeeper/grape/helpers.rb +11 -1
- data/lib/doorkeeper/helpers/controller.rb +12 -0
- data/lib/doorkeeper/http_fetcher.rb +79 -19
- data/lib/doorkeeper/models/access_token_mixin.rb +120 -2
- data/lib/doorkeeper/models/concerns/secret_storable.rb +72 -6
- data/lib/doorkeeper/oauth/authorization/token.rb +87 -8
- data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +13 -0
- data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +27 -20
- data/lib/doorkeeper/oauth/client_credentials/creator.rb +17 -3
- data/lib/doorkeeper/oauth/helpers/uri_checker.rb +14 -0
- data/lib/doorkeeper/oauth/metadata_response.rb +4 -3
- data/lib/doorkeeper/oauth/pre_authorization.rb +50 -0
- data/lib/doorkeeper/oauth/refresh_token_request.rb +81 -24
- data/lib/doorkeeper/oauth/resource_indicator_validator.rb +10 -0
- data/lib/doorkeeper/oauth/token.rb +94 -2
- data/lib/doorkeeper/oauth/token_introspection.rb +14 -1
- data/lib/doorkeeper/orm/active_record/mixins/access_grant.rb +1 -0
- data/lib/doorkeeper/orm/active_record/mixins/access_token.rb +1 -0
- data/lib/doorkeeper/orm/active_record/mixins/application.rb +1 -0
- data/lib/doorkeeper/orm/active_record/mixins/secret_storable.rb +113 -0
- data/lib/doorkeeper/orm/active_record/redirect_uri_validator.rb +5 -1
- data/lib/doorkeeper/orm/active_record.rb +1 -0
- data/lib/doorkeeper/rails/helpers.rb +25 -2
- data/lib/doorkeeper/request.rb +19 -7
- data/lib/doorkeeper/version.rb +1 -1
- data/lib/doorkeeper.rb +11 -0
- data/lib/generators/doorkeeper/refresh_token_scopes_generator.rb +43 -0
- data/lib/generators/doorkeeper/templates/add_refresh_token_scopes_to_access_tokens.rb.erb +10 -0
- data/lib/generators/doorkeeper/templates/initializer.rb +94 -15
- data/lib/generators/doorkeeper/templates/migration.rb.erb +9 -0
- 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
|
|
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.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
183
|
+
addresses.map(&:to_s)
|
|
159
184
|
end
|
|
160
185
|
|
|
161
|
-
def
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
74
|
-
#
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
#
|
|
91
|
-
#
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
#
|
|
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
|
|
159
|
-
#
|
|
160
|
-
# that identifies itself nowhere
|
|
161
|
-
#
|
|
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
|
-
|
|
174
|
+
audiences = [Doorkeeper.config.issuer.presence]
|
|
175
|
+
options = configured_url_options
|
|
164
176
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 :
|
|
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).
|