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