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
|
@@ -92,13 +92,16 @@ module Doorkeeper
|
|
|
92
92
|
# A nil value will ignore custom attributes, while an empty hash will
|
|
93
93
|
# only match tokens that have no custom attributes set.
|
|
94
94
|
#
|
|
95
|
+
# @yield [token] optional additional predicate a token must satisfy to
|
|
96
|
+
# count as a match (e.g. the refresh token requirement of the request).
|
|
97
|
+
#
|
|
95
98
|
# @return [Doorkeeper::AccessToken, nil] Access Token instance or
|
|
96
99
|
# nil if matching record was not found
|
|
97
100
|
#
|
|
98
|
-
def matching_token_for(application, resource_owner, scopes, custom_attributes: nil, include_expired: true)
|
|
101
|
+
def matching_token_for(application, resource_owner, scopes, custom_attributes: nil, include_expired: true, &filter)
|
|
99
102
|
tokens = authorized_tokens_for(application&.id, resource_owner)
|
|
100
103
|
tokens = tokens.not_expired unless include_expired
|
|
101
|
-
find_matching_token(tokens, application, custom_attributes, scopes)
|
|
104
|
+
find_matching_token(tokens, application, custom_attributes, scopes, &filter)
|
|
102
105
|
end
|
|
103
106
|
|
|
104
107
|
# Interface to enumerate access token records in batches in order not
|
|
@@ -131,7 +134,7 @@ module Doorkeeper
|
|
|
131
134
|
# @return [Doorkeeper::AccessToken, nil] Access Token instance or
|
|
132
135
|
# nil if matching record was not found
|
|
133
136
|
#
|
|
134
|
-
def find_matching_token(relation, application, custom_attributes, scopes)
|
|
137
|
+
def find_matching_token(relation, application, custom_attributes, scopes, &filter)
|
|
135
138
|
return nil unless relation
|
|
136
139
|
|
|
137
140
|
matching_tokens = []
|
|
@@ -140,7 +143,8 @@ module Doorkeeper
|
|
|
140
143
|
find_access_token_in_batches(relation, batch_size: batch_size) do |batch|
|
|
141
144
|
tokens = batch.select do |token|
|
|
142
145
|
scopes_match?(token.scopes, scopes, application&.scopes) &&
|
|
143
|
-
custom_attributes_match?(token, custom_attributes)
|
|
146
|
+
custom_attributes_match?(token, custom_attributes) &&
|
|
147
|
+
(filter.nil? || filter.call(token))
|
|
144
148
|
end
|
|
145
149
|
|
|
146
150
|
matching_tokens.concat(tokens)
|
|
@@ -149,6 +153,32 @@ module Doorkeeper
|
|
|
149
153
|
matching_tokens.max_by(&:created_at)
|
|
150
154
|
end
|
|
151
155
|
|
|
156
|
+
# Checks whether a candidate token for reuse matches the refresh token
|
|
157
|
+
# requirement of the current request.
|
|
158
|
+
#
|
|
159
|
+
# The candidate's refresh token presence must match what the request asks
|
|
160
|
+
# for, in both directions. A request that expects a refresh token (e.g. an
|
|
161
|
+
# authorization_code or password grant while `use_refresh_token` is
|
|
162
|
+
# enabled) must not reuse a token issued without one, or the response would
|
|
163
|
+
# silently omit the refresh token. Conversely, a request that does not ask
|
|
164
|
+
# for a refresh token must not reuse a token that carries one, or the
|
|
165
|
+
# response would return a refresh token the request never requested (this
|
|
166
|
+
# is reachable when `refresh_token_enabled` is a per-request callable).
|
|
167
|
+
# When no matching token satisfies the requirement a fresh token is
|
|
168
|
+
# issued instead.
|
|
169
|
+
#
|
|
170
|
+
# @param access_token [Doorkeeper::AccessToken]
|
|
171
|
+
# the candidate token for reuse
|
|
172
|
+
# @param token_attributes [Hash]
|
|
173
|
+
# attributes for the token being requested
|
|
174
|
+
#
|
|
175
|
+
# @return [Boolean] true if the candidate token's refresh token presence
|
|
176
|
+
# matches the request's refresh token requirement, false otherwise
|
|
177
|
+
#
|
|
178
|
+
def refresh_token_matches?(access_token, token_attributes)
|
|
179
|
+
access_token.refresh_token.present? == !!token_attributes[:use_refresh_token]
|
|
180
|
+
end
|
|
181
|
+
|
|
152
182
|
# Checks whether the token scopes match the scopes from the parameters
|
|
153
183
|
#
|
|
154
184
|
# @param token_scopes [#to_s]
|
|
@@ -199,6 +229,39 @@ module Doorkeeper
|
|
|
199
229
|
end
|
|
200
230
|
end
|
|
201
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
|
+
|
|
202
265
|
# Looking for not expired AccessToken record with a matching set of
|
|
203
266
|
# scopes that belongs to specific Application and Resource Owner.
|
|
204
267
|
# If it doesn't exists - then creates it.
|
|
@@ -226,9 +289,22 @@ module Doorkeeper
|
|
|
226
289
|
# attributes when matching, while an empty hash only matches tokens
|
|
227
290
|
# that have no custom attributes set.
|
|
228
291
|
custom_attributes = extract_custom_attributes(token_attributes)
|
|
292
|
+
# The refresh token requirement participates in the matching itself
|
|
293
|
+
# rather than being checked on its single newest result: the newest
|
|
294
|
+
# matching token may carry the wrong refresh token presence (e.g. it
|
|
295
|
+
# was issued through a grant with a different `use_refresh_token`)
|
|
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
|
+
|
|
229
302
|
access_token = matching_token_for(
|
|
230
303
|
application, resource_owner, scopes, custom_attributes: custom_attributes, include_expired: false,
|
|
231
|
-
)
|
|
304
|
+
) do |token|
|
|
305
|
+
refresh_token_matches?(token, token_attributes) &&
|
|
306
|
+
resource_indicators_match?(token, requested_resource)
|
|
307
|
+
end
|
|
232
308
|
|
|
233
309
|
return access_token if access_token&.reusable?
|
|
234
310
|
end
|
|
@@ -5,6 +5,7 @@ module Doorkeeper
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
include OAuth::Helpers
|
|
8
|
+
include Models::Concerns::WriteToPrimary
|
|
8
9
|
include Models::Orderable
|
|
9
10
|
include Models::SecretStorable
|
|
10
11
|
include Models::Scopes
|
|
@@ -27,9 +28,8 @@ module Doorkeeper
|
|
|
27
28
|
app = by_uid(uid)
|
|
28
29
|
return unless app
|
|
29
30
|
return app if secret.blank? && !app.confidential?
|
|
30
|
-
return unless app.secret_matches?(secret)
|
|
31
31
|
|
|
32
|
-
app
|
|
32
|
+
app if app.secret_matches?(secret)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# Returns an instance of the Doorkeeper::Application with specific UID.
|
|
@@ -76,18 +76,26 @@ module Doorkeeper
|
|
|
76
76
|
# @return [Boolean] Whether the given secret matches the stored secret
|
|
77
77
|
# of this application.
|
|
78
78
|
#
|
|
79
|
+
# @note When the secret matches only via the fallback strategy, the stored
|
|
80
|
+
# secret is upgraded to the active strategy as a side-effect (mirrors
|
|
81
|
+
# the find_by_plaintext_token -> find_by_fallback_token pattern).
|
|
82
|
+
#
|
|
79
83
|
def secret_matches?(input)
|
|
80
84
|
# return false if either is nil, since secure_compare depends on strings
|
|
81
85
|
# but Application secrets MAY be nil depending on confidentiality.
|
|
82
86
|
return false if input.nil? || secret.nil?
|
|
83
87
|
|
|
88
|
+
input = input.to_s
|
|
89
|
+
|
|
84
90
|
# When matching the secret by comparer function, all is well.
|
|
85
91
|
return true if secret_strategy.secret_matches?(input, secret)
|
|
86
92
|
|
|
87
|
-
# When fallback lookup is enabled, ensure applications
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
# When fallback lookup is enabled, ensure applications with plain secrets
|
|
94
|
+
# can still be found, upgrading the stored secret to the active strategy
|
|
95
|
+
# on a successful match.
|
|
96
|
+
if fallback_secret_strategy&.secret_matches?(input, secret)
|
|
97
|
+
self.class.upgrade_fallback_value(self, :secret, input)
|
|
98
|
+
true
|
|
91
99
|
else
|
|
92
100
|
false
|
|
93
101
|
end
|
|
@@ -84,7 +84,16 @@ module Doorkeeper
|
|
|
84
84
|
#
|
|
85
85
|
def upgrade_fallback_value(instance, attr, plain_secret)
|
|
86
86
|
upgraded = secret_strategy.store_secret(instance, attr, plain_secret)
|
|
87
|
-
|
|
87
|
+
|
|
88
|
+
# The upgrade is a write on what is otherwise a read path (finding a
|
|
89
|
+
# record by its secret), so it must reach the primary database when
|
|
90
|
+
# automatic role switching would route the surrounding request to a
|
|
91
|
+
# read replica.
|
|
92
|
+
if respond_to?(:with_primary_role)
|
|
93
|
+
with_primary_role { instance.update(attr => upgraded) }
|
|
94
|
+
else
|
|
95
|
+
instance.update(attr => upgraded)
|
|
96
|
+
end
|
|
88
97
|
end
|
|
89
98
|
|
|
90
99
|
##
|
|
@@ -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
|
|
@@ -10,6 +10,17 @@ module Doorkeeper
|
|
|
10
10
|
def uri_with_query(url, parameters = {})
|
|
11
11
|
uri = URI.parse(url)
|
|
12
12
|
original_query = Rack::Utils.parse_query(uri.query)
|
|
13
|
+
# `parse_query` yields string keys while `parameters` uses symbol
|
|
14
|
+
# keys, so a naive merge cannot dedupe a collision (e.g. a registered
|
|
15
|
+
# redirect_uri already carrying `state`) and would emit the param
|
|
16
|
+
# twice. Normalize keys so the response parameters win over any
|
|
17
|
+
# same-named query already present in the redirect_uri. Blank
|
|
18
|
+
# response parameters are dropped before the merge: they carry no
|
|
19
|
+
# value to respond with, and letting them clobber a same-named
|
|
20
|
+
# registered parameter would violate RFC 6749 §3.1.2 (the registered
|
|
21
|
+
# query component "MUST be retained when adding additional query
|
|
22
|
+
# parameters").
|
|
23
|
+
parameters = parameters.transform_keys(&:to_s).reject { |_, value| value.blank? }
|
|
13
24
|
uri.query = build_query(original_query.merge(parameters))
|
|
14
25
|
uri.to_s
|
|
15
26
|
end
|
|
@@ -9,10 +9,17 @@ 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
|
|
15
19
|
|
|
20
|
+
# A scope parameter is deliberately not read here: RFC 6749 does not
|
|
21
|
+
# define one for the authorization_code token request (§4.1.3), so it
|
|
22
|
+
# is ignored and the access token inherits the scopes of the grant.
|
|
16
23
|
def initialize(server, grant, client, parameters = {})
|
|
17
24
|
super()
|
|
18
25
|
@server = server
|
|
@@ -21,6 +28,7 @@ module Doorkeeper
|
|
|
21
28
|
@grant_type = Doorkeeper::OAuth::AUTHORIZATION_CODE
|
|
22
29
|
@redirect_uri = parameters[:redirect_uri]
|
|
23
30
|
@code_verifier = parameters[:code_verifier]
|
|
31
|
+
@raw_resource_indicators = parameters[:resource]
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
private
|
|
@@ -36,16 +44,40 @@ module Doorkeeper
|
|
|
36
44
|
|
|
37
45
|
grant.revoke
|
|
38
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
|
+
|
|
39
62
|
find_or_create_access_token(
|
|
40
63
|
client,
|
|
41
64
|
resource_owner,
|
|
42
65
|
grant.scopes,
|
|
43
|
-
|
|
66
|
+
token_attributes,
|
|
44
67
|
server,
|
|
45
68
|
)
|
|
69
|
+
|
|
70
|
+
link_access_token_to_grant
|
|
46
71
|
end
|
|
47
72
|
|
|
48
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
|
|
49
81
|
end
|
|
50
82
|
|
|
51
83
|
def resource_owner
|
|
@@ -64,7 +96,7 @@ module Doorkeeper
|
|
|
64
96
|
@missing_param =
|
|
65
97
|
if grant&.uses_pkce? && code_verifier.blank?
|
|
66
98
|
:code_verifier
|
|
67
|
-
elsif client &&
|
|
99
|
+
elsif client && Doorkeeper.config.force_pkce? && code_verifier.blank?
|
|
68
100
|
:code_verifier
|
|
69
101
|
elsif redirect_uri.blank?
|
|
70
102
|
:redirect_uri
|
|
@@ -78,7 +110,16 @@ module Doorkeeper
|
|
|
78
110
|
end
|
|
79
111
|
|
|
80
112
|
def validate_grant
|
|
81
|
-
|
|
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?
|
|
82
123
|
|
|
83
124
|
grant.accessible?
|
|
84
125
|
end
|
|
@@ -117,9 +158,92 @@ module Doorkeeper
|
|
|
117
158
|
.symbolize_keys
|
|
118
159
|
end
|
|
119
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
|
+
|
|
120
199
|
def revoke_previous_tokens(application, resource_owner)
|
|
121
200
|
Doorkeeper.config.access_token_model.revoke_all_for(application.id, resource_owner)
|
|
122
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
|
|
123
247
|
end
|
|
124
248
|
end
|
|
125
249
|
end
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# The full registry rather than just credentials: requiring only
|
|
4
|
+
# client_authentication/credentials fires the ClientAuthentication autoload
|
|
5
|
+
# mid-load, which re-requires the in-progress file and warns under -w
|
|
6
|
+
# ("circular require considered harmful").
|
|
7
|
+
require "doorkeeper/client_authentication"
|
|
8
|
+
|
|
3
9
|
module Doorkeeper
|
|
4
10
|
module OAuth
|
|
5
11
|
class Client
|
|
12
|
+
# @deprecated Moved to +Doorkeeper::ClientAuthentication::Credentials+.
|
|
13
|
+
# This alias keeps the long-standing +Doorkeeper::OAuth::Client::Credentials+
|
|
14
|
+
# constant resolvable for one release so referencing code does not raise
|
|
15
|
+
# +NameError+; update references to the new constant. Note the legacy
|
|
16
|
+
# +.from_request+/+.from_basic+/+.from_params+ class methods are gone —
|
|
17
|
+
# client credential extraction now goes through the client authentication
|
|
18
|
+
# registry (RFC 6749 §2.3). Marked with +deprecate_constant+, so Ruby
|
|
19
|
+
# warns on access when deprecation warnings are enabled
|
|
20
|
+
# (+Warning[:deprecated] = true+ or +-W:deprecated+).
|
|
21
|
+
Credentials = Doorkeeper::ClientAuthentication::Credentials
|
|
22
|
+
deprecate_constant :Credentials
|
|
23
|
+
|
|
6
24
|
attr_reader :application
|
|
7
25
|
|
|
8
26
|
delegate :id, :name, :uid, :redirect_uri, :scopes, :confidential, to: :@application
|
|
@@ -19,6 +37,12 @@ module Doorkeeper
|
|
|
19
37
|
|
|
20
38
|
def self.authenticate(credentials, method = Doorkeeper.config.application_model.method(:by_uid_and_secret))
|
|
21
39
|
return if credentials.blank?
|
|
40
|
+
|
|
41
|
+
# Credentials that were fully authenticated by their client
|
|
42
|
+
# authentication method (e.g. a verified private_key_jwt assertion)
|
|
43
|
+
# carry no secret to compare — resolve the client by uid alone.
|
|
44
|
+
return find(credentials.uid) if credentials.respond_to?(:pre_authenticated?) && credentials.pre_authenticated?
|
|
45
|
+
|
|
22
46
|
return unless (application = method.call(credentials.uid, credentials.secret))
|
|
23
47
|
|
|
24
48
|
new(application)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doorkeeper
|
|
4
|
+
module OAuth
|
|
5
|
+
module ClientAuthentication
|
|
6
|
+
# RFC 6749 §2.3.1 "client_secret_basic": client credentials are sent
|
|
7
|
+
# using HTTP Basic authentication.
|
|
8
|
+
#
|
|
9
|
+
# Known deviation: §2.3.1 also requires the client_id and client_secret
|
|
10
|
+
# to be form-urlencoded before being placed into the Basic header.
|
|
11
|
+
# Doorkeeper has never URL-decoded them (like much of the ecosystem),
|
|
12
|
+
# and this strategy deliberately keeps that behaviour — adding the
|
|
13
|
+
# decoding now would break every existing client whose credentials
|
|
14
|
+
# contain URL-encodable characters.
|
|
15
|
+
class ClientSecretBasic
|
|
16
|
+
def self.uses_shared_secret?
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Match whenever the header decodes to a non-blank +client_id+ — i.e.
|
|
21
|
+
# whenever a Basic authentication *attempt* is present. The secret may
|
|
22
|
+
# be empty (public clients) or missing; those are still Basic auth
|
|
23
|
+
# attempts and must be claimed here so that invalid credentials fail
|
|
24
|
+
# with +invalid_client+ instead of silently falling through to another
|
|
25
|
+
# configured method or the fallback (which would downgrade a failed
|
|
26
|
+
# authentication attempt to "no authentication provided").
|
|
27
|
+
def self.matches_request?(request)
|
|
28
|
+
credentials_from(request).present?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.authenticate(request)
|
|
32
|
+
client_id, client_secret = credentials_from(request)
|
|
33
|
+
return unless client_id
|
|
34
|
+
|
|
35
|
+
Doorkeeper::ClientAuthentication::Credentials.new(client_id, client_secret)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the decoded [client_id, client_secret] pair, or nil when the
|
|
39
|
+
# request carries no HTTP Basic +client_id+. A header that merely
|
|
40
|
+
# starts with "Basic " but decodes to an empty/blank client_id (e.g.
|
|
41
|
+
# an empty payload or a leading ":") is not a usable attempt and does
|
|
42
|
+
# not match.
|
|
43
|
+
def self.credentials_from(request)
|
|
44
|
+
authorization = request.authorization.to_s
|
|
45
|
+
return unless authorization.downcase.start_with?("basic ")
|
|
46
|
+
|
|
47
|
+
value = authorization.split(" ", 2).last
|
|
48
|
+
client_id, client_secret = Base64.decode64(value.to_s).split(":", 2)
|
|
49
|
+
return if client_id.blank?
|
|
50
|
+
|
|
51
|
+
[client_id, client_secret]
|
|
52
|
+
end
|
|
53
|
+
private_class_method :credentials_from
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doorkeeper
|
|
4
|
+
module OAuth
|
|
5
|
+
module ClientAuthentication
|
|
6
|
+
# RFC 6749 §2.3.1 "client_secret_post": client credentials are sent in
|
|
7
|
+
# the request body. The query string is intentionally ignored so that
|
|
8
|
+
# credentials must be supplied in the body as the spec requires.
|
|
9
|
+
class ClientSecretPost
|
|
10
|
+
def self.uses_shared_secret?
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.matches_request?(request)
|
|
15
|
+
params = request.request_parameters.with_indifferent_access
|
|
16
|
+
|
|
17
|
+
request.post? &&
|
|
18
|
+
params[:client_id].present? &&
|
|
19
|
+
params[:client_secret].present?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.authenticate(request)
|
|
23
|
+
params = request.request_parameters.with_indifferent_access
|
|
24
|
+
|
|
25
|
+
Doorkeeper::ClientAuthentication::Credentials.new(
|
|
26
|
+
params[:client_id],
|
|
27
|
+
params[:client_secret],
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doorkeeper
|
|
4
|
+
module OAuth
|
|
5
|
+
module ClientAuthentication
|
|
6
|
+
# RFC 6749 §2.3 "none": a public client that authenticates with only a
|
|
7
|
+
# client_id and no secret (in the request body, not the query string).
|
|
8
|
+
class None
|
|
9
|
+
# The absence of client authentication involves no secret at all.
|
|
10
|
+
def self.uses_shared_secret?
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Rejects a request that carries header-based client authentication (a
|
|
15
|
+
# +Basic+ credential, or any non-blank Authorization header that is not
|
|
16
|
+
# a bearer token): such a request must not be silently treated as an
|
|
17
|
+
# unauthenticated public client. This is narrower than the legacy
|
|
18
|
+
# +from_params+ extractor, which read the body +client_id+ regardless
|
|
19
|
+
# of the Authorization header.
|
|
20
|
+
#
|
|
21
|
+
# A +Bearer+ Authorization header is the exception: it authorizes
|
|
22
|
+
# access to the endpoint itself (e.g. a bearer-protected introspection
|
|
23
|
+
# endpoint, RFC 7662 §2.1, or a revocation request) rather than
|
|
24
|
+
# authenticating the client, so it must not suppress the +none+
|
|
25
|
+
# strategy for a public client that identifies itself with a body
|
|
26
|
+
# +client_id+.
|
|
27
|
+
#
|
|
28
|
+
# A request carrying a client_assertion is likewise attempting real
|
|
29
|
+
# client authentication (RFC 7521 allows a bare client_id next to the
|
|
30
|
+
# assertion), so it must not be picked up as an unauthenticated
|
|
31
|
+
# public client no matter where this method sits in the configured
|
|
32
|
+
# order.
|
|
33
|
+
def self.matches_request?(request)
|
|
34
|
+
params = request.request_parameters.with_indifferent_access
|
|
35
|
+
|
|
36
|
+
request.post? &&
|
|
37
|
+
!client_authentication_header?(request) &&
|
|
38
|
+
params[:client_id].present? &&
|
|
39
|
+
params[:client_secret].blank? &&
|
|
40
|
+
params[:client_assertion].blank?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A blank Authorization header carries no client authentication; a
|
|
44
|
+
# bearer token authorizes the endpoint rather than the client. Every
|
|
45
|
+
# other non-blank scheme (Basic, etc.) is header-based client
|
|
46
|
+
# authentication. The pattern tolerates the optional whitespace HTTP
|
|
47
|
+
# allows before the scheme and requires whitespace between the scheme
|
|
48
|
+
# and the token, accepting in both places only the spaces and tabs
|
|
49
|
+
# that OWS is made of (RFC 9110 §5.6.3): a well-formed Bearer header
|
|
50
|
+
# is therefore not misclassified, while a value that relies on any
|
|
51
|
+
# other character in those positions is not taken for a bearer
|
|
52
|
+
# credential. A bearer credential also carries at least one token
|
|
53
|
+
# character after the scheme (RFC 6750 §2.1), so a scheme-only value
|
|
54
|
+
# such as "Bearer " is not exempted either.
|
|
55
|
+
def self.client_authentication_header?(request)
|
|
56
|
+
header = request.authorization
|
|
57
|
+
return false if header.blank?
|
|
58
|
+
|
|
59
|
+
!header.match?(/\A[ \t]*Bearer[ \t]+\S/i)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :client_authentication_header?
|
|
62
|
+
|
|
63
|
+
def self.authenticate(request)
|
|
64
|
+
params = request.request_parameters.with_indifferent_access
|
|
65
|
+
|
|
66
|
+
Doorkeeper::ClientAuthentication::Credentials.new(params[:client_id], nil)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|