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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/README.md +203 -0
  4. data/app/controllers/doorkeeper/applications_controller.rb +38 -9
  5. data/app/controllers/doorkeeper/authorizations_controller.rb +17 -3
  6. data/app/controllers/doorkeeper/tokens_controller.rb +5 -1
  7. data/app/views/doorkeeper/authorizations/new.html.erb +18 -0
  8. data/config/locales/en.yml +2 -0
  9. data/lib/doorkeeper/client_authentication/legacy_callable.rb +6 -0
  10. data/lib/doorkeeper/client_authentication/method.rb +17 -0
  11. data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
  12. data/lib/doorkeeper/client_authentication.rb +9 -0
  13. data/lib/doorkeeper/config.rb +47 -0
  14. data/lib/doorkeeper/document_cache.rb +81 -0
  15. data/lib/doorkeeper/errors.rb +48 -0
  16. data/lib/doorkeeper/http_fetcher.rb +232 -0
  17. data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
  18. data/lib/doorkeeper/models/access_token_mixin.rb +42 -1
  19. data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
  20. data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
  21. data/lib/doorkeeper/oauth/authorization_code_request.rb +123 -2
  22. data/lib/doorkeeper/oauth/base_request.rb +1 -2
  23. data/lib/doorkeeper/oauth/client.rb +6 -0
  24. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +4 -0
  25. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +4 -0
  26. data/lib/doorkeeper/oauth/client_authentication/none.rb +47 -8
  27. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
  28. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
  29. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
  30. data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
  31. data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
  32. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
  33. data/lib/doorkeeper/oauth/metadata_response.rb +8 -0
  34. data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
  35. data/lib/doorkeeper/oauth/pre_authorization.rb +51 -3
  36. data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
  37. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
  38. data/lib/doorkeeper/oauth/scopes.rb +25 -0
  39. data/lib/doorkeeper/oauth/token_introspection.rb +6 -0
  40. data/lib/doorkeeper/version.rb +1 -1
  41. data/lib/doorkeeper.rb +4 -0
  42. data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
  43. data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
  44. data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
  45. data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
  46. data/lib/generators/doorkeeper/templates/initializer.rb +65 -0
  47. data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
  48. metadata +45 -3
@@ -37,6 +37,12 @@ module Doorkeeper
37
37
 
38
38
  def self.authenticate(credentials, method = Doorkeeper.config.application_model.method(:by_uid_and_secret))
39
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
+
40
46
  return unless (application = method.call(credentials.uid, credentials.secret))
41
47
 
42
48
  new(application)
@@ -13,6 +13,10 @@ module Doorkeeper
13
13
  # decoding now would break every existing client whose credentials
14
14
  # contain URL-encodable characters.
15
15
  class ClientSecretBasic
16
+ def self.uses_shared_secret?
17
+ true
18
+ end
19
+
16
20
  # Match whenever the header decodes to a non-blank +client_id+ — i.e.
17
21
  # whenever a Basic authentication *attempt* is present. The secret may
18
22
  # be empty (public clients) or missing; those are still Basic auth
@@ -7,6 +7,10 @@ module Doorkeeper
7
7
  # the request body. The query string is intentionally ignored so that
8
8
  # credentials must be supplied in the body as the spec requires.
9
9
  class ClientSecretPost
10
+ def self.uses_shared_secret?
11
+ true
12
+ end
13
+
10
14
  def self.matches_request?(request)
11
15
  params = request.request_parameters.with_indifferent_access
12
16
 
@@ -6,20 +6,59 @@ module Doorkeeper
6
6
  # RFC 6749 §2.3 "none": a public client that authenticates with only a
7
7
  # client_id and no secret (in the request body, not the query string).
8
8
  class None
9
- # Requires the Authorization header to be absent or blank: a request
10
- # that carries a non-blank one (Basic, Bearer, or anything else) is
11
- # attempting header-based authentication and must not be silently
12
- # treated as an unauthenticated public client. This is narrower than
13
- # the legacy +from_params+ extractor, which read the body +client_id+
14
- # regardless of the Authorization header.
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.
15
33
  def self.matches_request?(request)
16
34
  params = request.request_parameters.with_indifferent_access
17
35
 
18
36
  request.post? &&
19
- request.authorization.blank? &&
37
+ !client_authentication_header?(request) &&
20
38
  params[:client_id].present? &&
21
- params[:client_secret].blank?
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)
22
60
  end
61
+ private_class_method :client_authentication_header?
23
62
 
24
63
  def self.authenticate(request)
25
64
  params = request.request_parameters.with_indifferent_access
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Doorkeeper
7
+ module OAuth
8
+ module ClientAuthentication
9
+ class PrivateKeyJwt
10
+ # Resolves the JWK Set a client's assertion must verify against:
11
+ # the +jwks+ / +jwks_uri+ attributes of its application, when the
12
+ # application model provides them (Doorkeeper defines no such
13
+ # columns itself).
14
+ #
15
+ # Symmetric ("oct") keys are dropped: a symmetric key in a JWK Set is
16
+ # a shared secret, which this method must never verify against.
17
+ #
18
+ # The jwt gem is always referenced as ::JWT: doorkeeper-jwt defines
19
+ # Doorkeeper::JWT, which would otherwise shadow the gem everywhere
20
+ # inside this module.
21
+ module KeyResolver
22
+ # A published JWK Set is remote, client-controlled input, so each
23
+ # level is type-checked before it is indexed into: a JWK Set that
24
+ # is not an object of objects must fail authentication, never
25
+ # raise out of the token endpoint.
26
+ def self.jwk_set_for(application)
27
+ raw = raw_jwks(application)
28
+ return unless raw.is_a?(Hash)
29
+
30
+ keys = raw["keys"] || raw[:keys]
31
+ return unless keys.is_a?(Array)
32
+
33
+ asymmetric = keys.grep(Hash).reject { |key| (key["kty"] || key[:kty]).to_s == "oct" }
34
+ return if asymmetric.empty?
35
+
36
+ ::JWT::JWK::Set.new({ "keys" => asymmetric })
37
+ rescue ::JWT::DecodeError, OpenSSL::OpenSSLError
38
+ # A hostile key can fail to parse in more ways than JWT::JWKError:
39
+ # a member that is not valid base64url raises JWT::Base64DecodeError
40
+ # (a sibling of JWT::JWKError under JWT::DecodeError, not a
41
+ # subclass), and an EC point that is not on its curve raises a bare
42
+ # OpenSSL error. None of them may escape into the token endpoint.
43
+ nil
44
+ end
45
+
46
+ def self.raw_jwks(application)
47
+ application_jwks(application) || fetch_jwks(application_jwks_uri(application))
48
+ end
49
+ private_class_method :raw_jwks
50
+
51
+ def self.application_jwks(application)
52
+ return unless application.respond_to?(:jwks)
53
+
54
+ jwks = application.jwks
55
+ jwks.is_a?(String) ? JSON.parse(jwks) : jwks.presence
56
+ rescue JSON::ParserError
57
+ nil
58
+ end
59
+ private_class_method :application_jwks
60
+
61
+ def self.application_jwks_uri(application)
62
+ application.jwks_uri if application.respond_to?(:jwks_uri)
63
+ end
64
+ private_class_method :application_jwks_uri
65
+
66
+ # The jwks_uri is fetched with a hardened HTTP client: https only,
67
+ # no redirects, 200 OK only, and RFC 6890 special-use addresses
68
+ # refused.
69
+ #
70
+ # The result is memoized, since otherwise every single authenticated
71
+ # request would fetch it again; a rotated key is picked up once the
72
+ # memo expires. Only JSON objects are stored, so a malformed
73
+ # response is not cached.
74
+ def self.fetch_jwks(jwks_uri)
75
+ return if jwks_uri.blank?
76
+
77
+ url = jwks_uri.to_s
78
+ return unless URI.parse(url).is_a?(URI::HTTPS)
79
+
80
+ jwks_cache.fetch(url) do
81
+ parsed = JSON.parse(Doorkeeper::HttpFetcher.new.fetch(url))
82
+ parsed if parsed.is_a?(Hash)
83
+ end
84
+ rescue Doorkeeper::HttpFetcher::FetchError, JSON::ParserError, URI::InvalidURIError
85
+ nil
86
+ end
87
+ private_class_method :fetch_jwks
88
+
89
+ # The built-in cache is process-local with a fixed TTL; the
90
+ # private_key_jwt_jwks_cache config option replaces it with any
91
+ # object answering fetch(url) { ... }.
92
+ def self.jwks_cache
93
+ Doorkeeper.config.private_key_jwt_jwks_cache || default_jwks_cache
94
+ end
95
+
96
+ def self.default_jwks_cache
97
+ @default_jwks_cache ||= Doorkeeper::DocumentCache.new
98
+ end
99
+ private_class_method :default_jwks_cache
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Doorkeeper
6
+ module OAuth
7
+ module ClientAuthentication
8
+ class PrivateKeyJwt
9
+ # In-memory, process-local single-use guard for assertion jti values
10
+ # (OIDC Core §9: an assertion may only be used once). Entries live
11
+ # until the assertion's own exp, which PrivateKeyJwt caps at
12
+ # MAX_LIFETIME, so the memory held here is bounded: at most
13
+ # MAX_ENTRIES entries, each for at most MAX_LIFETIME seconds.
14
+ #
15
+ # Being process-local this cannot catch a replay delivered to a
16
+ # different server process (separate Puma workers, separate hosts).
17
+ # Whether that matters depends on the deployment: the replay window
18
+ # is at most MAX_LIFETIME anyway, and an attacker who can capture an
19
+ # assertion in transit usually defeats TLS first. A deployment that
20
+ # wants cross-process replay protection supplies a shared store
21
+ # (backed by Redis or the like) through the
22
+ # private_key_jwt_replay_guard config option.
23
+ class ReplayGuard
24
+ include Singleton
25
+
26
+ # Upper bound on remembered jti values. When the guard is full even
27
+ # after expired entries are pruned, the oldest entries are evicted
28
+ # rather than new assertions rejected — see first_use?.
29
+ MAX_ENTRIES = 10_000
30
+
31
+ # Expired entries are swept periodically rather than on every
32
+ # authentication: the sweep is O(entries) and would otherwise run on
33
+ # each request, walking up to MAX_ENTRIES every time. An entry that
34
+ # outlives its exp by up to this long only makes the guard stricter,
35
+ # never more permissive.
36
+ SWEEP_INTERVAL = 10
37
+
38
+ def initialize
39
+ @mutex = Mutex.new
40
+ @seen = {}
41
+ @sweep_after = 0
42
+ end
43
+
44
+ # @return [Boolean] true when the key was not seen before; the key
45
+ # is then remembered until +expires_at+ (unix time).
46
+ def first_use?(key, expires_at:)
47
+ now = Time.now.to_i
48
+
49
+ @mutex.synchronize do
50
+ if now >= @sweep_after || @seen.size >= MAX_ENTRIES
51
+ @seen.delete_if { |_, expiry| expiry <= now }
52
+ @sweep_after = now + SWEEP_INTERVAL
53
+ end
54
+
55
+ return false if @seen.key?(key)
56
+
57
+ # When full even after expiry pruning, evict the oldest entries
58
+ # rather than rejecting new assertions: rejecting would let a
59
+ # flood of assertions lock legitimate clients out entirely,
60
+ # while evicting only shortens the replay window under attack.
61
+ @seen.shift while @seen.size >= MAX_ENTRIES
62
+
63
+ @seen[key] = expires_at
64
+ true
65
+ end
66
+ end
67
+
68
+ def clear
69
+ @mutex.synchronize do
70
+ @seen.clear
71
+ @sweep_after = 0
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ require "doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver"
6
+ require "doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard"
7
+
8
+ module Doorkeeper
9
+ module OAuth
10
+ module ClientAuthentication
11
+ # "private_key_jwt" client authentication (RFC 7523 / OIDC Core §9):
12
+ # the client authenticates with a JWT assertion signed by its private
13
+ # key; the server verifies it against the client's published public
14
+ # keys (a jwks attribute on the application model, or keys fetched
15
+ # from its jwks_uri). No shared secret is involved.
16
+ #
17
+ # The "jwt" gem is required only when an assertion is actually
18
+ # authenticated, so servers that don't enable this method don't need
19
+ # the dependency. Its constants are always referenced as ::JWT:
20
+ # doorkeeper-jwt defines Doorkeeper::JWT, which would otherwise shadow
21
+ # the gem everywhere inside this class.
22
+ class PrivateKeyJwt
23
+ CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
24
+
25
+ # Assertions are verified against the client's published public
26
+ # keys; no shared secret is involved.
27
+ def self.uses_shared_secret?
28
+ false
29
+ end
30
+
31
+ # Asymmetric signature algorithms only: HMAC family (and "none") are
32
+ # shared-secret/unauthenticated and must never verify an assertion.
33
+ ALLOWED_ALGORITHMS = %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512].freeze
34
+
35
+ # iss/sub identify the client, aud prevents cross-server replay,
36
+ # exp bounds the assertion lifetime and jti makes it single-use
37
+ # (OIDC Core §9 requires all of these for private_key_jwt).
38
+ REQUIRED_CLAIMS = %w[iss sub aud exp jti].freeze
39
+
40
+ # Upper bound on how far in the future an assertion may expire. This
41
+ # both rejects sloppily long-lived assertions and bounds the replay
42
+ # guard's memory.
43
+ MAX_LIFETIME = 3600
44
+
45
+ def self.matches_request?(request)
46
+ params = request.request_parameters.with_indifferent_access
47
+
48
+ request.post? &&
49
+ params[:client_assertion].present? &&
50
+ params[:client_assertion_type] == CLIENT_ASSERTION_TYPE
51
+ end
52
+
53
+ def self.authenticate(request)
54
+ require_jwt!
55
+
56
+ params = request.request_parameters.with_indifferent_access
57
+ assertion = params[:client_assertion].to_s
58
+
59
+ client_id = unverified_client_id(assertion)
60
+ return if client_id.blank?
61
+ # RFC 7521 §4.2: a client_id parameter sent alongside the assertion
62
+ # must agree with the assertion's issuer.
63
+ return if params[:client_id].present? && params[:client_id] != client_id
64
+
65
+ application = OAuth::Client.find(client_id)&.application
66
+ return unless application
67
+
68
+ jwk_set = KeyResolver.jwk_set_for(application)
69
+ return unless jwk_set
70
+
71
+ claims = verified_claims(assertion, client_id, jwk_set, request)
72
+ return unless claims
73
+ return unless replay_guard.first_use?(
74
+ "#{client_id}:#{claims["jti"]}",
75
+ expires_at: claims["exp"].to_i,
76
+ )
77
+
78
+ Doorkeeper::ClientAuthentication::VerifiedCredentials.new(client_id)
79
+ end
80
+
81
+ # The built-in guard is process-local; a multi-process deployment can
82
+ # supply a shared store through the private_key_jwt_replay_guard
83
+ # config option.
84
+ def self.replay_guard
85
+ Doorkeeper.config.private_key_jwt_replay_guard || ReplayGuard.instance
86
+ end
87
+ private_class_method :replay_guard
88
+
89
+ # The issuer read without verifying the signature — only used to
90
+ # locate the client (and thereby its keys); every claim is verified
91
+ # against those keys before the assertion authenticates anyone.
92
+ def self.unverified_client_id(assertion)
93
+ claims, = ::JWT.decode(assertion, nil, false)
94
+ # A JWT payload is any JSON value, not necessarily an object, and
95
+ # nothing is verified at this point — so the decoded claims are
96
+ # type-checked before being indexed into.
97
+ return unless claims.is_a?(Hash)
98
+
99
+ issuer = claims["iss"]
100
+
101
+ issuer if issuer.is_a?(String) && issuer == claims["sub"]
102
+ rescue ::JWT::DecodeError
103
+ nil
104
+ end
105
+ private_class_method :unverified_client_id
106
+
107
+ def self.verified_claims(assertion, client_id, jwk_set, request)
108
+ claims, = ::JWT.decode(
109
+ assertion,
110
+ nil,
111
+ true,
112
+ algorithms: ALLOWED_ALGORITHMS,
113
+ jwks: jwk_set,
114
+ required_claims: REQUIRED_CLAIMS,
115
+ iss: client_id,
116
+ verify_iss: true,
117
+ sub: client_id,
118
+ verify_sub: true,
119
+ aud: acceptable_audiences(request),
120
+ verify_aud: true,
121
+ # Passed explicitly so a host application that globally disabled
122
+ # expiration checking for its own tokens (JWT.configuration.decode)
123
+ # cannot silently turn off assertion expiry verification.
124
+ verify_expiration: true,
125
+ )
126
+
127
+ # RFC 7519 §4.1.4: exp is a NumericDate — a number. The jwt gem's
128
+ # own expiration check casts it with to_i, which would let a numeric
129
+ # string through (and read a non-numeric one as 0), so the type is
130
+ # pinned before the value is compared or handed to the replay guard.
131
+ exp = claims["exp"]
132
+ return unless exp.is_a?(Numeric) && exp.finite?
133
+ return unless exp <= Time.now.to_i + MAX_LIFETIME
134
+ return unless claims["jti"].is_a?(String) && claims["jti"].present?
135
+
136
+ claims
137
+ rescue ::JWT::DecodeError, OpenSSL::OpenSSLError
138
+ # A published key is only parsed far enough to be usable when it is
139
+ # actually needed to verify a signature, so a structurally valid but
140
+ # mathematically nonsensical key (an EC point that is not on the
141
+ # curve, say) surfaces here as a bare OpenSSL error rather than a
142
+ # JWT one. Both mean the same thing: this assertion does not verify.
143
+ nil
144
+ end
145
+ private_class_method :verified_claims
146
+
147
+ # RFC 7523bis expects the issuer identifier as the audience; older
148
+ # deployments use the token endpoint URL. Both are accepted.
149
+ #
150
+ # OIDC Core §9 says the audience SHOULD be the token endpoint URL, and
151
+ # clients follow that for every endpoint they authenticate at — so the
152
+ # token endpoint URL is accepted at the revocation and introspection
153
+ # endpoints too, not just at the endpoint being called.
154
+ #
155
+ # The endpoint URLs are built from the server's own configured
156
+ # identity, never from the request: aud is what keeps an assertion
157
+ # minted for another authorization server from being replayed here, so
158
+ # deriving it from the client-supplied Host header would defeat its
159
+ # purpose on any deployment that does not filter hosts. Only a server
160
+ # that identifies itself nowhere falls back to the request, which is
161
+ # how MetadataResponse derives its issuer as well.
162
+ def self.acceptable_audiences(request)
163
+ options = server_url_options(request)
164
+
165
+ [
166
+ Doorkeeper.config.issuer.presence,
167
+ "#{base_url(options)}#{request.path}",
168
+ token_endpoint_url(options),
169
+ ].compact.uniq
170
+ end
171
+ private_class_method :acceptable_audiences
172
+
173
+ def self.server_url_options(request)
174
+ configured_url_options ||
175
+ { protocol: request.protocol, host: request.host, port: request.optional_port }
176
+ end
177
+ private_class_method :server_url_options
178
+
179
+ # An explicitly configured canonical host wins; failing that, the
180
+ # issuer, when it is an absolute URL (Doorkeeper allows any string).
181
+ def self.configured_url_options
182
+ default = ::Rails.application&.routes&.default_url_options || {}
183
+ return url_options_from(default) if default[:host].present?
184
+
185
+ issuer = URI.parse(Doorkeeper.config.issuer.to_s)
186
+ return unless issuer.is_a?(URI::HTTP) && issuer.host.present?
187
+
188
+ {
189
+ protocol: "#{issuer.scheme}://",
190
+ host: issuer.host,
191
+ port: (issuer.port unless issuer.port == issuer.default_port),
192
+ }
193
+ rescue URI::InvalidURIError
194
+ nil
195
+ end
196
+ private_class_method :configured_url_options
197
+
198
+ def self.url_options_from(default)
199
+ { protocol: default[:protocol] || "https://", host: default[:host], port: default[:port] }
200
+ end
201
+ private_class_method :url_options_from
202
+
203
+ def self.base_url(options)
204
+ protocol = options[:protocol].to_s
205
+ protocol = "#{protocol}://" unless protocol.end_with?("://")
206
+ host = options[:port].present? ? "#{options[:host]}:#{options[:port]}" : options[:host]
207
+
208
+ "#{protocol}#{host}"
209
+ end
210
+ private_class_method :base_url
211
+
212
+ # Built the way MetadataResponse advertises the token endpoint, so a
213
+ # host application that renamed the tokens controller still gets the
214
+ # URL its own metadata publishes.
215
+ def self.token_endpoint_url(url_options)
216
+ mapping = Doorkeeper::Rails::Routes.mapping[:tokens]
217
+ return unless mapping
218
+
219
+ ::Rails.application.routes.url_for(
220
+ { controller: "/#{mapping[:controllers]}", action: "create" }.merge(url_options),
221
+ )
222
+ rescue StandardError
223
+ # Routes may be skipped or unmounted; fall back to the other audiences.
224
+ nil
225
+ end
226
+ private_class_method :token_endpoint_url
227
+
228
+ # Enabling this method without the gem installed is a deployment
229
+ # mistake, not a protocol error the client could correct — so the
230
+ # explanation has to reach the operator rather than the client. A
231
+ # Doorkeeper::Errors::DoorkeeperError would be translated into an OAuth
232
+ # error response whose "error" value is its message (DoorkeeperError#type
233
+ # returns the message), handing the client a sentence where a registered
234
+ # error code belongs; LoadError keeps the diagnosis in the server's logs
235
+ # and surfaces the request as the server error it is.
236
+ def self.require_jwt!
237
+ require "jwt"
238
+ rescue LoadError
239
+ raise LoadError,
240
+ "private_key_jwt client authentication requires the 'jwt' gem (>= 2.7); " \
241
+ "add it to your Gemfile to use this method"
242
+ end
243
+ private_class_method :require_jwt!
244
+ end
245
+ end
246
+ end
247
+ end
@@ -5,13 +5,13 @@ module Doorkeeper
5
5
  module ClientCredentials
6
6
  class Creator
7
7
  def call(client, scopes, attributes = {})
8
- existing_token = nil
9
-
10
- if lookup_existing_token?
11
- existing_token = find_active_existing_token_for(client, scopes, attributes)
12
- return existing_token if Doorkeeper.config.reuse_access_token && existing_token&.reusable?
8
+ if Doorkeeper.config.reuse_access_token
9
+ reusable_token = find_reusable_token_for(client, scopes, attributes)
10
+ return reusable_token if reusable_token
13
11
  end
14
12
 
13
+ existing_token = find_revocable_token_for(client, scopes, attributes)
14
+
15
15
  with_revocation(existing_token: existing_token) do
16
16
  application = client.is_a?(Doorkeeper.config.application_model) ? client : client&.application
17
17
  Doorkeeper.config.access_token_model.create_for(
@@ -39,9 +39,16 @@ module Doorkeeper
39
39
  end
40
40
  end
41
41
 
42
- def lookup_existing_token?
43
- Doorkeeper.config.reuse_access_token ||
44
- Doorkeeper.config.revoke_previous_client_credentials_token?
42
+ def find_reusable_token_for(client, scopes, attributes)
43
+ token = find_active_existing_token_for(client, scopes, attributes)
44
+
45
+ token if token&.reusable?
46
+ end
47
+
48
+ def find_revocable_token_for(client, scopes, attributes)
49
+ return unless Doorkeeper.config.revoke_previous_client_credentials_token?
50
+
51
+ find_active_existing_token_for(client, scopes, attributes)
45
52
  end
46
53
 
47
54
  def find_active_existing_token_for(client, scopes, attributes)
@@ -52,7 +59,16 @@ module Doorkeeper
52
59
  .extract_custom_attributes(attributes)
53
60
  Doorkeeper.config.access_token_model.matching_token_for(
54
61
  client, nil, scopes, custom_attributes: custom_attributes, include_expired: false,
55
- )
62
+ ) do |token|
63
+ # RFC 8707: a token bound to another audience is a different token.
64
+ # It must neither be reused for this request nor revoked on its
65
+ # behalf, so the resource takes part in the lookup both callers use.
66
+ # It has to be part of the lookup rather than a check on its result,
67
+ # because only the newest match is returned.
68
+ Doorkeeper.config.access_token_model.resource_indicators_match?(
69
+ token, attributes[:resource],
70
+ )
71
+ end
56
72
  end
57
73
  end
58
74
  end
@@ -7,21 +7,25 @@ module Doorkeeper
7
7
 
8
8
  alias error_response response
9
9
 
10
- delegate :error, to: :issuer
11
-
12
10
  def initialize(server, client, parameters = {})
11
+ super()
13
12
  @client = client
14
13
  @server = server
15
14
  @response = nil
16
15
  @grant_type = Doorkeeper::OAuth::CLIENT_CREDENTIALS
17
16
  @original_scopes = parameters[:scope]
18
- @parameters = parameters.except(:scope)
17
+ @raw_resource_indicators = parameters[:resource]
18
+ @parameters = parameters.except(:scope, :resource)
19
19
  end
20
20
 
21
21
  def access_token
22
22
  issuer.token
23
23
  end
24
24
 
25
+ def error
26
+ @resource_indicator_error || issuer.error
27
+ end
28
+
25
29
  def issuer
26
30
  @issuer ||= ClientCredentials::Issuer.new(
27
31
  server,
@@ -32,14 +36,41 @@ module Doorkeeper
32
36
  private
33
37
 
34
38
  def valid?
35
- issuer.create(client, scopes, custom_token_attributes_with_data)
39
+ validate_resource_indicators && issuer.create(client, scopes, custom_token_attributes_with_data)
40
+ end
41
+
42
+ def validate_resource_indicators
43
+ validator = Doorkeeper.config.resource_indicator_validator
44
+ return true unless validator
45
+ return true if @raw_resource_indicators.blank?
46
+
47
+ @resolved_resource_indicators = ResourceIndicatorValidator.validate!(
48
+ @raw_resource_indicators,
49
+ config_validator: validator,
50
+ client: client,
51
+ )
52
+ true
53
+ rescue Errors::InvalidTarget
54
+ @resource_indicator_error = Errors::InvalidTarget
55
+ false
36
56
  end
37
57
 
38
58
  def custom_token_attributes_with_data
39
- parameters
59
+ attrs = parameters
40
60
  .with_indifferent_access
41
61
  .slice(*Doorkeeper.config.custom_access_token_attributes)
42
62
  .symbolize_keys
63
+
64
+ # RFC 8707: attach validated resource indicators to token attributes
65
+ if @resolved_resource_indicators.present?
66
+ unless Doorkeeper.config.access_token_model.resource_indicators_supported?
67
+ raise Errors::MissingResourceColumn, "oauth_access_tokens"
68
+ end
69
+
70
+ attrs[:resource] = @resolved_resource_indicators.join(" ")
71
+ end
72
+
73
+ attrs
43
74
  end
44
75
  end
45
76
  end