doorkeeper 5.9.5 → 6.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -3
  3. data/README.md +204 -0
  4. data/app/controllers/doorkeeper/applications_controller.rb +38 -9
  5. data/app/controllers/doorkeeper/authorizations_controller.rb +17 -3
  6. data/app/controllers/doorkeeper/metadata_controller.rb +20 -0
  7. data/app/controllers/doorkeeper/tokens_controller.rb +15 -2
  8. data/app/views/doorkeeper/authorizations/new.html.erb +24 -0
  9. data/config/locales/en.yml +2 -0
  10. data/lib/doorkeeper/client_authentication/credentials.rb +11 -0
  11. data/lib/doorkeeper/client_authentication/fallback_method.rb +19 -0
  12. data/lib/doorkeeper/client_authentication/legacy_callable.rb +52 -0
  13. data/lib/doorkeeper/client_authentication/method.rb +40 -0
  14. data/lib/doorkeeper/client_authentication/registry.rb +47 -0
  15. data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
  16. data/lib/doorkeeper/client_authentication.rb +102 -0
  17. data/lib/doorkeeper/config/option.rb +1 -1
  18. data/lib/doorkeeper/config/validations.rb +162 -1
  19. data/lib/doorkeeper/config.rb +161 -9
  20. data/lib/doorkeeper/document_cache.rb +81 -0
  21. data/lib/doorkeeper/errors.rb +49 -0
  22. data/lib/doorkeeper/http_fetcher.rb +232 -0
  23. data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
  24. data/lib/doorkeeper/models/access_token_mixin.rb +81 -5
  25. data/lib/doorkeeper/models/application_mixin.rb +14 -6
  26. data/lib/doorkeeper/models/concerns/secret_storable.rb +10 -1
  27. data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
  28. data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
  29. data/lib/doorkeeper/oauth/authorization/uri_builder.rb +11 -0
  30. data/lib/doorkeeper/oauth/authorization_code_request.rb +127 -3
  31. data/lib/doorkeeper/oauth/base_request.rb +1 -2
  32. data/lib/doorkeeper/oauth/client.rb +24 -0
  33. data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +57 -0
  34. data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +33 -0
  35. data/lib/doorkeeper/oauth/client_authentication/none.rb +71 -0
  36. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
  37. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
  38. data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
  39. data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
  40. data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
  41. data/lib/doorkeeper/oauth/code_response.rb +16 -2
  42. data/lib/doorkeeper/oauth/error_response.rb +11 -2
  43. data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
  44. data/lib/doorkeeper/oauth/metadata_response.rb +165 -0
  45. data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
  46. data/lib/doorkeeper/oauth/pre_authorization.rb +63 -8
  47. data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
  48. data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
  49. data/lib/doorkeeper/oauth/scopes.rb +25 -0
  50. data/lib/doorkeeper/oauth/token_introspection.rb +33 -7
  51. data/lib/doorkeeper/orm/active_record/mixins/application.rb +2 -2
  52. data/lib/doorkeeper/rails/routes/mapping.rb +1 -0
  53. data/lib/doorkeeper/rails/routes.rb +6 -0
  54. data/lib/doorkeeper/request.rb +59 -0
  55. data/lib/doorkeeper/server.rb +5 -2
  56. data/lib/doorkeeper/version.rb +4 -4
  57. data/lib/doorkeeper.rb +12 -4
  58. data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
  59. data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
  60. data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
  61. data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
  62. data/lib/generators/doorkeeper/templates/initializer.rb +131 -6
  63. data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
  64. metadata +52 -4
  65. data/lib/doorkeeper/oauth/client/credentials.rb +0 -71
  66. data/lib/doorkeeper/oauth/stateless_token.rb +0 -139
@@ -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
@@ -28,17 +28,22 @@ module Doorkeeper
28
28
  token_type: auth.token.token_type,
29
29
  expires_in: auth.token.expires_in_seconds,
30
30
  state: pre_auth.state,
31
- }
31
+ }.merge(iss_parameter)
32
32
  elsif auth.try(:access_grant?)
33
33
  {
34
34
  code: auth.token.plaintext_token,
35
35
  state: pre_auth.state,
36
- }
36
+ }.merge(iss_parameter)
37
37
  end
38
38
  end
39
39
 
40
40
  def redirect_uri
41
41
  if URIChecker.oob_uri?(pre_auth.redirect_uri)
42
+ # Out-of-band "redirects" render the code/token on an authorization
43
+ # server page for the user to copy manually; there is no redirect
44
+ # back to the client. RFC 9207 scopes the iss parameter to the
45
+ # authorization response sent to the client, so - like state, which
46
+ # oob_redirect also omits - iss is intentionally not carried here.
42
47
  auth.oob_redirect
43
48
  elsif response_on_fragment
44
49
  Authorization::URIBuilder.uri_with_fragment(pre_auth.redirect_uri, body)
@@ -46,6 +51,15 @@ module Doorkeeper
46
51
  Authorization::URIBuilder.uri_with_query(pre_auth.redirect_uri, body)
47
52
  end
48
53
  end
54
+
55
+ private
56
+
57
+ # RFC 9207 Authorization Server Issuer Identification: advertise the
58
+ # issuer in the authorization response only when an issuer is configured.
59
+ def iss_parameter
60
+ issuer = Doorkeeper.config.issuer
61
+ issuer.present? ? { iss: issuer } : {}
62
+ end
49
63
  end
50
64
  end
51
65
  end
@@ -5,7 +5,7 @@ module Doorkeeper
5
5
  class ErrorResponse < BaseResponse
6
6
  include OAuth::Helpers
7
7
 
8
- NON_REDIRECTABLE_STATES = %i[invalid_redirect_uri invalid_client unauthorized_client].freeze
8
+ NON_REDIRECTABLE_STATES = %i[invalid_redirect_uri invalid_client].freeze
9
9
 
10
10
  def self.from_request(request, attributes = {})
11
11
  new(
@@ -38,6 +38,7 @@ module Doorkeeper
38
38
  @exception_class = attributes[:exception_class]
39
39
  @redirect_uri = attributes[:redirect_uri]
40
40
  @response_on_fragment = attributes[:response_on_fragment]
41
+ @issuer = attributes[:issuer]
41
42
  end
42
43
 
43
44
  def body
@@ -45,11 +46,19 @@ module Doorkeeper
45
46
  error: name,
46
47
  error_description: description,
47
48
  state: state,
49
+ # RFC 9207 scopes the issuer to authorization error responses sent to
50
+ # the client, i.e. the ones redirected back to its redirect_uri. It is
51
+ # supplied only by the authorization endpoint (never token,
52
+ # introspection or protected resource), and gated on redirectable? so
53
+ # non-redirectable errors (invalid_client, invalid_redirect_uri) and
54
+ # out-of-band flows - which render to the user instead of redirecting
55
+ # to the client - do not carry it. Blank values are dropped below.
56
+ iss: (@issuer if redirectable?),
48
57
  }.reject { |_, v| v.blank? }
49
58
  end
50
59
 
51
60
  def status
52
- if name == :invalid_client || name == :unauthorized_client
61
+ if name == :invalid_client
53
62
  :unauthorized
54
63
  else
55
64
  :bad_request