doorkeeper 5.9.5 → 6.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -3
  3. data/README.md +204 -0
  4. data/app/controllers/doorkeeper/applications_controller.rb +38 -9
  5. data/app/controllers/doorkeeper/authorizations_controller.rb +17 -3
  6. data/app/controllers/doorkeeper/metadata_controller.rb +20 -0
  7. data/app/controllers/doorkeeper/tokens_controller.rb +15 -2
  8. data/app/views/doorkeeper/authorizations/new.html.erb +24 -0
  9. data/config/locales/en.yml +2 -0
  10. data/lib/doorkeeper/client_authentication/credentials.rb +11 -0
  11. data/lib/doorkeeper/client_authentication/fallback_method.rb +19 -0
  12. data/lib/doorkeeper/client_authentication/legacy_callable.rb +52 -0
  13. data/lib/doorkeeper/client_authentication/method.rb +40 -0
  14. data/lib/doorkeeper/client_authentication/registry.rb +47 -0
  15. data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
  16. data/lib/doorkeeper/client_authentication.rb +102 -0
  17. data/lib/doorkeeper/config/option.rb +1 -1
  18. data/lib/doorkeeper/config/validations.rb +162 -1
  19. data/lib/doorkeeper/config.rb +161 -9
  20. data/lib/doorkeeper/document_cache.rb +81 -0
  21. data/lib/doorkeeper/errors.rb +49 -0
  22. data/lib/doorkeeper/http_fetcher.rb +232 -0
  23. data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
  24. data/lib/doorkeeper/models/access_token_mixin.rb +81 -5
  25. data/lib/doorkeeper/models/application_mixin.rb +14 -6
  26. data/lib/doorkeeper/models/concerns/secret_storable.rb +10 -1
  27. data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
  28. data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
  29. data/lib/doorkeeper/oauth/authorization/uri_builder.rb +11 -0
  30. data/lib/doorkeeper/oauth/authorization_code_request.rb +127 -3
  31. data/lib/doorkeeper/oauth/base_request.rb +1 -2
  32. data/lib/doorkeeper/oauth/client.rb +24 -0
  33. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +57 -0
  34. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +33 -0
  35. data/lib/doorkeeper/oauth/client_authentication/none.rb +71 -0
  36. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
  37. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
  38. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
  39. data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
  40. data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
  41. data/lib/doorkeeper/oauth/code_response.rb +16 -2
  42. data/lib/doorkeeper/oauth/error_response.rb +11 -2
  43. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
  44. data/lib/doorkeeper/oauth/metadata_response.rb +165 -0
  45. data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
  46. data/lib/doorkeeper/oauth/pre_authorization.rb +63 -8
  47. data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
  48. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
  49. data/lib/doorkeeper/oauth/scopes.rb +25 -0
  50. data/lib/doorkeeper/oauth/token_introspection.rb +33 -7
  51. data/lib/doorkeeper/orm/active_record/mixins/application.rb +2 -2
  52. data/lib/doorkeeper/rails/routes/mapping.rb +1 -0
  53. data/lib/doorkeeper/rails/routes.rb +6 -0
  54. data/lib/doorkeeper/request.rb +59 -0
  55. data/lib/doorkeeper/server.rb +5 -2
  56. data/lib/doorkeeper/version.rb +4 -4
  57. data/lib/doorkeeper.rb +12 -4
  58. data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
  59. data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
  60. data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
  61. data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
  62. data/lib/generators/doorkeeper/templates/initializer.rb +131 -6
  63. data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
  64. metadata +52 -4
  65. data/lib/doorkeeper/oauth/client/credentials.rb +0 -71
  66. data/lib/doorkeeper/oauth/stateless_token.rb +0 -139
@@ -65,13 +65,50 @@ module Doorkeeper
65
65
  end
66
66
 
67
67
  # Change the way client credentials are retrieved from the request object.
68
- # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
69
- # falls back to the `:client_id` and `:client_secret` params from the
70
- # `params` object.
68
+ #
69
+ # @deprecated Use the +client_authentication+ option instead. The legacy
70
+ # +:from_basic+ / +:from_params+ methods are automatically converted to
71
+ # the +:client_secret_basic+ / +:client_secret_post+ authentication
72
+ # methods. +:none+ (public client support) is appended only when
73
+ # +:from_params+ was configured, since that is the only legacy method
74
+ # that accepted a bare +client_id+ without a secret — +:from_basic+ on
75
+ # its own never did, so it is not broadened. Callable extractors are
76
+ # wrapped in a legacy adapter so they keep working during the
77
+ # deprecation window.
71
78
  #
72
79
  # @param methods [Array] Define client credentials
73
80
  def client_credentials(*methods)
74
- @config.instance_variable_set(:@client_credentials_methods, methods)
81
+ deprecated(
82
+ "client_credentials",
83
+ "Use the client_authentication option instead. Automatically converting to client_authentication",
84
+ )
85
+
86
+ client_authentication = Doorkeeper::ClientAuthentication.from_legacy_client_credentials(methods)
87
+
88
+ if client_authentication.empty?
89
+ Kernel.warn(
90
+ "[DOORKEEPER] No known client_credentials method detected, " \
91
+ "cannot automatically convert to client_authentication option",
92
+ )
93
+ else
94
+ @config.instance_variable_set(:@client_credentials_methods, client_authentication)
95
+ end
96
+ end
97
+
98
+ # Declare which client authentication methods (RFC 6749 §2.3) are
99
+ # accepted and the order in which they are tried. Accepts either an array
100
+ # or varargs, so both forms are honoured exactly as written:
101
+ #
102
+ # client_authentication %i[client_secret_basic client_secret_post none]
103
+ # client_authentication :client_secret_basic, :client_secret_post
104
+ #
105
+ # Unlike the deprecated +client_credentials+ option, the listed methods
106
+ # are used verbatim — nothing (in particular +:none+) is appended, so a
107
+ # restrictive configuration is never silently broadened.
108
+ #
109
+ # @param methods [Array<Symbol>] the client authentication method names
110
+ def client_authentication(*methods)
111
+ @config.instance_variable_set(:@client_authentication, methods.flatten)
75
112
  end
76
113
 
77
114
  # Change the way access token is authenticated from the request object.
@@ -202,6 +239,13 @@ module Doorkeeper
202
239
 
203
240
  private
204
241
 
242
+ def deprecated(name, message = nil)
243
+ warning = "[DOORKEEPER] #{name} has been deprecated and will soon be removed"
244
+ warning = "#{warning}\n#{message}" if message.present?
245
+
246
+ Kernel.warn(warning)
247
+ end
248
+
205
249
  # Configure the secret storing functionality
206
250
  def configure_secrets_for(type, using:, fallback:)
207
251
  raise ArgumentError, "Invalid type #{type}" if %i[application token].exclude?(type)
@@ -337,6 +381,57 @@ module Doorkeeper
337
381
  #
338
382
  option :realm, default: "Doorkeeper"
339
383
 
384
+ # Issuer URL advertised in the OAuth 2.0 Authorization Server Metadata
385
+ # (RFC 8414). When nil, the request base URL is used instead.
386
+ option :issuer, default: nil
387
+
388
+ # Resource Indicators for OAuth 2.0 (RFC 8707).
389
+ #
390
+ # When set to a callable (lambda/proc), enables RFC 8707 support. The callable
391
+ # receives the array of resource indicator URIs and the client, and must return
392
+ # true if the resource(s) are acceptable, or false to reject with `invalid_target`.
393
+ #
394
+ # When nil (default), the `resource` parameter is ignored and RFC 8707 is disabled.
395
+ #
396
+ # @example
397
+ # resource_indicator_validator ->(resource_indicators, client) {
398
+ # resource_indicators.all? { |r| allowed_resources.include?(r) }
399
+ # }
400
+ #
401
+ # @param validator [Proc, nil] A callable that validates resource indicators
402
+ # @see https://datatracker.ietf.org/doc/html/rfc8707
403
+ #
404
+ option :resource_indicator_validator, default: nil
405
+
406
+ # Replay guard for `private_key_jwt` client assertions (jti single-use
407
+ # tracking, OIDC Core §9). The default guard remembers jti values in
408
+ # process-local memory, so it cannot see a replay delivered to a
409
+ # different server process; a multi-process deployment can supply a
410
+ # shared store instead — any object answering
411
+ # `first_use?(key, expires_at:)`, returning true when the key was never
412
+ # seen before and remembering it until the unix time `expires_at`.
413
+ #
414
+ # @example
415
+ # private_key_jwt_replay_guard RedisReplayGuard.new
416
+ #
417
+ # @param guard [#first_use?, nil] nil uses the built-in process-local guard
418
+ #
419
+ option :private_key_jwt_replay_guard, default: nil
420
+
421
+ # Cache for JWK Sets fetched from a client's `jwks_uri` during
422
+ # `private_key_jwt` authentication. Defaults to a process-local
423
+ # Doorkeeper::DocumentCache with a 60 second TTL; supply your own
424
+ # instance to change the TTL, or any object answering
425
+ # `fetch(url) { ... }` (returning the cached document or storing and
426
+ # returning the block's result) to share the cache across processes.
427
+ #
428
+ # @example
429
+ # private_key_jwt_jwks_cache Doorkeeper::DocumentCache.new(ttl: 300)
430
+ #
431
+ # @param cache [#fetch, nil] nil uses a built-in process-local cache
432
+ #
433
+ option :private_key_jwt_jwks_cache, default: nil
434
+
340
435
  # Forces the usage of the HTTPS protocol in non-native redirect uris
341
436
  # (enabled by default in non-development environments). OAuth2
342
437
  # delegates security in communication to the HTTPS protocol so it is
@@ -413,6 +508,10 @@ module Doorkeeper
413
508
  option :application_class,
414
509
  default: "Doorkeeper::Application"
415
510
 
511
+ # Allows setting a hash of custom data merged into the OAuth 2.0
512
+ # Authorization Server Metadata response (RFC 8414).
513
+ option :custom_metadata, default: {}
514
+
416
515
  # Allows to set blank redirect URIs for Applications in case
417
516
  # server configured to use URI-less grant flows.
418
517
  #
@@ -596,8 +695,57 @@ module Doorkeeper
596
695
  pkce_code_challenge_methods
597
696
  end
598
697
 
698
+ # Resolves the configured client authentication methods (RFC 6749 §2.3)
699
+ # into the registered +Doorkeeper::ClientAuthentication::Method+ objects.
700
+ #
701
+ # Honors the deprecated +client_credentials+ option for backwards
702
+ # compatibility: if it was used it provides the source of truth, unless
703
+ # +client_authentication+ was also set explicitly, in which case the
704
+ # latter wins.
705
+ def client_authentication_methods
706
+ return @client_authentication_methods if defined?(@client_authentication_methods)
707
+
708
+ # When both the deprecated +client_credentials+ and the new
709
+ # +client_authentication+ are set, +client_authentication+ wins. The
710
+ # conflict is warned about at validation time (see Validations), not here,
711
+ # so the message is not swallowed by this memoised resolver.
712
+ only_legacy = instance_variable_defined?(:@client_credentials_methods) &&
713
+ !instance_variable_defined?(:@client_authentication)
714
+ names = only_legacy ? @client_credentials_methods : client_authentication
715
+
716
+ # Names configured more than once resolve to the same registered Method
717
+ # instance, so identity-based #uniq drops the duplicates (which would
718
+ # otherwise be matched against requests twice and advertised twice in
719
+ # the server metadata) while distinct legacy callable adapters survive.
720
+ @client_authentication_methods = names.filter_map do |name|
721
+ # Legacy callables are already wrapped as Method adapters (see #client_credentials).
722
+ name.is_a?(Doorkeeper::ClientAuthentication::Method) ? name : Doorkeeper::ClientAuthentication.get(name)
723
+ end.uniq
724
+ end
725
+
726
+ # The configured client authentication method names (RFC 6749 §2.3),
727
+ # defaulting to the registry's DEFAULT_METHODS when not set.
728
+ def client_authentication
729
+ return Doorkeeper::ClientAuthentication::DEFAULT_METHODS.dup unless instance_variable_defined?(:@client_authentication)
730
+
731
+ @client_authentication
732
+ end
733
+
734
+ # @deprecated Renamed to +client_authentication_methods+. This alias keeps
735
+ # external callers (e.g. doorkeeper-openid_connect) working for one release
736
+ # and will be removed afterwards. It returns the legacy symbol names
737
+ # (e.g. +:from_basic+) rather than the internal Method objects so that
738
+ # consumers mapping from those symbols keep working unchanged.
599
739
  def client_credentials_methods
600
- @client_credentials_methods ||= %i[from_basic from_params]
740
+ unless defined?(@client_credentials_methods_rename_warned)
741
+ Kernel.warn(
742
+ "[DOORKEEPER] Doorkeeper.config.client_credentials_methods has been renamed to " \
743
+ "client_authentication_methods and will be removed in a future version.",
744
+ )
745
+ @client_credentials_methods_rename_warned = true
746
+ end
747
+
748
+ Doorkeeper::ClientAuthentication.to_legacy_client_credentials_names(client_authentication_methods)
601
749
  end
602
750
 
603
751
  def access_token_methods
@@ -665,13 +813,17 @@ module Doorkeeper
665
813
  def calculate_token_grant_types
666
814
  types = grant_flows - ["implicit"]
667
815
  types << "refresh_token" if refresh_token_enabled?
668
- types
816
+ types.uniq
669
817
  end
670
818
 
671
819
  # Calculates grant flows configured by the user in Doorkeeper
672
820
  # configuration considering registered aliases that is exposed
673
821
  # to single or multiple other flows.
674
822
  #
823
+ # The refresh_token flow is added implicitly when +use_refresh_token+
824
+ # is configured, so the result lists every enabled flow (useful for
825
+ # RFC 8414 authorization server metadata).
826
+ #
675
827
  def calculate_grant_flows
676
828
  configured_flows = grant_flows.map(&:to_s)
677
829
  aliases = Doorkeeper::GrantFlow.aliases.keys.map(&:to_s)
@@ -683,6 +835,8 @@ module Doorkeeper
683
835
  flows.concat(Doorkeeper::GrantFlow.expand_alias(flow_alias))
684
836
  end
685
837
 
838
+ flows << "refresh_token" if refresh_token_enabled?
839
+
686
840
  flows.flatten.uniq
687
841
  end
688
842
 
@@ -713,9 +867,7 @@ module Doorkeeper
713
867
  end
714
868
 
715
869
  def calculate_token_grant_flows
716
- flows = enabled_grant_flows.select(&:handles_grant_type?)
717
- flows << Doorkeeper::GrantFlow.get("refresh_token") if refresh_token_enabled?
718
- flows
870
+ enabled_grant_flows.select(&:handles_grant_type?)
719
871
  end
720
872
  end
721
873
  end
@@ -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
 
@@ -75,6 +92,28 @@ module Doorkeeper
75
92
  UnableToGenerateToken = Class.new(DoorkeeperError)
76
93
  TokenGeneratorNotFound = Class.new(DoorkeeperError)
77
94
  NoOrmCleaner = Class.new(DoorkeeperError)
95
+ MissingConfigurationBuilderClass = Class.new(DoorkeeperError)
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
78
117
 
79
118
  InvalidRequest = Class.new(BaseResponseError)
80
119
  InvalidToken = Class.new(BaseResponseError)
@@ -82,6 +121,16 @@ module Doorkeeper
82
121
  InvalidScope = Class.new(BaseResponseError)
83
122
  InvalidRedirectUri = Class.new(BaseResponseError)
84
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
85
134
 
86
135
  UnauthorizedClient = Class.new(BaseResponseError)
87
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