doorkeeper 6.0.0.beta1 → 6.0.0.beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -1
- data/README.md +203 -0
- data/app/controllers/doorkeeper/applications_controller.rb +38 -9
- data/app/controllers/doorkeeper/authorizations_controller.rb +17 -3
- data/app/controllers/doorkeeper/tokens_controller.rb +5 -1
- data/app/views/doorkeeper/authorizations/new.html.erb +18 -0
- data/config/locales/en.yml +2 -0
- data/lib/doorkeeper/client_authentication/legacy_callable.rb +6 -0
- data/lib/doorkeeper/client_authentication/method.rb +17 -0
- data/lib/doorkeeper/client_authentication/verified_credentials.rb +19 -0
- data/lib/doorkeeper/client_authentication.rb +9 -0
- data/lib/doorkeeper/config.rb +47 -0
- data/lib/doorkeeper/document_cache.rb +81 -0
- data/lib/doorkeeper/errors.rb +48 -0
- data/lib/doorkeeper/http_fetcher.rb +232 -0
- data/lib/doorkeeper/models/access_grant_mixin.rb +16 -0
- data/lib/doorkeeper/models/access_token_mixin.rb +42 -1
- data/lib/doorkeeper/oauth/authorization/code.rb +10 -0
- data/lib/doorkeeper/oauth/authorization/token.rb +13 -2
- data/lib/doorkeeper/oauth/authorization_code_request.rb +123 -2
- data/lib/doorkeeper/oauth/base_request.rb +1 -2
- data/lib/doorkeeper/oauth/client.rb +6 -0
- data/lib/doorkeeper/oauth/client_authentication/client_secret_basic.rb +4 -0
- data/lib/doorkeeper/oauth/client_authentication/client_secret_post.rb +4 -0
- data/lib/doorkeeper/oauth/client_authentication/none.rb +47 -8
- data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/key_resolver.rb +104 -0
- data/lib/doorkeeper/oauth/client_authentication/private_key_jwt/replay_guard.rb +78 -0
- data/lib/doorkeeper/oauth/client_authentication/private_key_jwt.rb +247 -0
- data/lib/doorkeeper/oauth/client_credentials/creator.rb +25 -9
- data/lib/doorkeeper/oauth/client_credentials_request.rb +36 -5
- data/lib/doorkeeper/oauth/helpers/uri_checker.rb +35 -23
- data/lib/doorkeeper/oauth/metadata_response.rb +8 -0
- data/lib/doorkeeper/oauth/password_access_token_request.rb +27 -1
- data/lib/doorkeeper/oauth/pre_authorization.rb +51 -3
- data/lib/doorkeeper/oauth/refresh_token_request.rb +51 -1
- data/lib/doorkeeper/oauth/resource_indicator_validator.rb +70 -0
- data/lib/doorkeeper/oauth/scopes.rb +25 -0
- data/lib/doorkeeper/oauth/token_introspection.rb +6 -0
- data/lib/doorkeeper/version.rb +1 -1
- data/lib/doorkeeper.rb +4 -0
- data/lib/generators/doorkeeper/grant_reuse_revocation_generator.rb +43 -0
- data/lib/generators/doorkeeper/resource_indicators_generator.rb +33 -0
- data/lib/generators/doorkeeper/templates/add_access_token_to_access_grants.rb.erb +10 -0
- data/lib/generators/doorkeeper/templates/enable_resource_indicators_migration.rb.erb +8 -0
- data/lib/generators/doorkeeper/templates/initializer.rb +65 -0
- data/lib/generators/doorkeeper/templates/migration.rb.erb +13 -0
- metadata +45 -3
|
@@ -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
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
#
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
@
|
|
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
|