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
|
@@ -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
|
|
@@ -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
|
|
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
|
|
61
|
+
if name == :invalid_client
|
|
53
62
|
:unauthorized
|
|
54
63
|
else
|
|
55
64
|
:bad_request
|