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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -3
- data/README.md +204 -0
- data/app/controllers/doorkeeper/applications_controller.rb +38 -9
- data/app/controllers/doorkeeper/authorizations_controller.rb +17 -3
- data/app/controllers/doorkeeper/metadata_controller.rb +20 -0
- data/app/controllers/doorkeeper/tokens_controller.rb +15 -2
- data/app/views/doorkeeper/authorizations/new.html.erb +24 -0
- data/config/locales/en.yml +2 -0
- data/lib/doorkeeper/client_authentication/credentials.rb +11 -0
- data/lib/doorkeeper/client_authentication/fallback_method.rb +19 -0
- data/lib/doorkeeper/client_authentication/legacy_callable.rb +52 -0
- data/lib/doorkeeper/client_authentication/method.rb +40 -0
- data/lib/doorkeeper/client_authentication/registry.rb +47 -0
- data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
- data/lib/doorkeeper/client_authentication.rb +102 -0
- data/lib/doorkeeper/config/option.rb +1 -1
- data/lib/doorkeeper/config/validations.rb +162 -1
- data/lib/doorkeeper/config.rb +161 -9
- data/lib/doorkeeper/document_cache.rb +81 -0
- data/lib/doorkeeper/errors.rb +49 -0
- data/lib/doorkeeper/http_fetcher.rb +232 -0
- data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
- data/lib/doorkeeper/models/access_token_mixin.rb +81 -5
- data/lib/doorkeeper/models/application_mixin.rb +14 -6
- data/lib/doorkeeper/models/concerns/secret_storable.rb +10 -1
- data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
- data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
- data/lib/doorkeeper/oauth/authorization/uri_builder.rb +11 -0
- data/lib/doorkeeper/oauth/authorization_code_request.rb +127 -3
- data/lib/doorkeeper/oauth/base_request.rb +1 -2
- data/lib/doorkeeper/oauth/client.rb +24 -0
- data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +57 -0
- data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +33 -0
- data/lib/doorkeeper/oauth/client_authentication/none.rb +71 -0
- data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
- data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
- data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
- data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
- data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
- data/lib/doorkeeper/oauth/code_response.rb +16 -2
- data/lib/doorkeeper/oauth/error_response.rb +11 -2
- data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
- data/lib/doorkeeper/oauth/metadata_response.rb +165 -0
- data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
- data/lib/doorkeeper/oauth/pre_authorization.rb +63 -8
- data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
- data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
- data/lib/doorkeeper/oauth/scopes.rb +25 -0
- data/lib/doorkeeper/oauth/token_introspection.rb +33 -7
- data/lib/doorkeeper/orm/active_record/mixins/application.rb +2 -2
- data/lib/doorkeeper/rails/routes/mapping.rb +1 -0
- data/lib/doorkeeper/rails/routes.rb +6 -0
- data/lib/doorkeeper/request.rb +59 -0
- data/lib/doorkeeper/server.rb +5 -2
- data/lib/doorkeeper/version.rb +4 -4
- data/lib/doorkeeper.rb +12 -4
- data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
- data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
- data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
- data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
- data/lib/generators/doorkeeper/templates/initializer.rb +131 -6
- data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
- metadata +52 -4
- data/lib/doorkeeper/oauth/client/credentials.rb +0 -71
- data/lib/doorkeeper/oauth/stateless_token.rb +0 -139
data/lib/doorkeeper/config.rb
CHANGED
|
@@ -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
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
data/lib/doorkeeper/errors.rb
CHANGED
|
@@ -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
|