rack-jwt-verifier 0.1.0 → 0.3.0
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 +187 -0
- data/README.md +266 -76
- data/lib/rack-jwt-verifier.rb +5 -0
- data/lib/rack_jwt_verifier/errors.rb +40 -0
- data/lib/rack_jwt_verifier/in_process_cache.rb +61 -17
- data/lib/rack_jwt_verifier/jwt_helper.rb +55 -31
- data/lib/rack_jwt_verifier/key_source.rb +344 -0
- data/lib/rack_jwt_verifier/middleware.rb +221 -30
- data/lib/rack_jwt_verifier/replay_guard.rb +78 -0
- data/lib/rack_jwt_verifier/scopes.rb +43 -0
- data/lib/rack_jwt_verifier/verifier.rb +226 -65
- data/lib/rack_jwt_verifier/version.rb +1 -1
- data/lib/rack_jwt_verifier.rb +10 -11
- metadata +39 -92
- data/.rspec_status +0 -26
- data/Gemfile +0 -14
- data/Gemfile.lock +0 -63
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module RackJwtVerifier
|
|
6
|
+
# Base class for every error this gem raises on its own behalf. Token
|
|
7
|
+
# verification failures keep raising ruby-jwt's JWT::DecodeError family.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Raised at boot for an unusable option set: no key source, a shared secret
|
|
11
|
+
# next to a public key, HS* mixed with RS*/ES*, a too-short secret, missing
|
|
12
|
+
# iss/aud, ... Raised from Middleware.new / Verifier.new, never per request.
|
|
13
|
+
class ConfigurationError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when the verification key material cannot be obtained or parsed:
|
|
16
|
+
# the remote endpoint is down or slow, returned something that is not a
|
|
17
|
+
# key, or the configured static key does not parse.
|
|
18
|
+
class KeyFetchError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when the replay cache (jti store) cannot be read or written. The
|
|
21
|
+
# middleware answers 503: the operator asked for replay protection, so a
|
|
22
|
+
# request whose jti cannot be checked is refused rather than waved through.
|
|
23
|
+
class ReplayCacheError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when a token's jti has already been seen. Subclasses ruby-jwt's
|
|
26
|
+
# InvalidJtiError so it travels the same 401 path as any other bad claim.
|
|
27
|
+
class ReplayedTokenError < JWT::InvalidJtiError; end
|
|
28
|
+
|
|
29
|
+
# Handed to the :on_error hook when a token lacks a scope listed in
|
|
30
|
+
# require_scopes. Carries what was required and what was missing.
|
|
31
|
+
class InsufficientScopeError < Error
|
|
32
|
+
attr_reader :required, :missing
|
|
33
|
+
|
|
34
|
+
def initialize(required:, missing:)
|
|
35
|
+
@required = required
|
|
36
|
+
@missing = missing
|
|
37
|
+
super("Token lacks required scope(s): #{missing.join(' ')}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -8,38 +8,45 @@ module RackJwtVerifier
|
|
|
8
8
|
# The cache lifespan in seconds (5 minutes)
|
|
9
9
|
DEFAULT_EXPIRY = 300
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# Expiry is measured on the monotonic clock, so a wall-clock jump (NTP
|
|
12
|
+
# correction, DST) cannot extend or cut short an entry's life.
|
|
13
|
+
MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
14
|
+
|
|
15
|
+
# Expired entries are swept whenever the store grows past this many
|
|
16
|
+
# entries, and the threshold then doubles from whatever survived. Amortised
|
|
17
|
+
# O(1) per write, so a jti-per-request workload cannot grow the store
|
|
18
|
+
# without bound while a single-key workload never pays for a sweep.
|
|
19
|
+
SWEEP_THRESHOLD = 1024
|
|
20
|
+
|
|
21
|
+
# @param clock [#call] Returns the current time in seconds; injectable for tests.
|
|
22
|
+
def initialize(clock: MONOTONIC_CLOCK)
|
|
12
23
|
@store = {}
|
|
13
24
|
@lock = Mutex.new # Ensure thread safety for multi-threaded environments
|
|
25
|
+
@clock = clock
|
|
26
|
+
@sweep_at = SWEEP_THRESHOLD
|
|
14
27
|
end
|
|
15
28
|
|
|
16
29
|
# Reads the value for a given key. Automatically checks for expiry.
|
|
17
30
|
# @param key [String] The cache key.
|
|
18
31
|
# @return [Object, nil] The cached value or nil if expired or not found.
|
|
19
32
|
def read(key)
|
|
20
|
-
@lock.synchronize
|
|
21
|
-
entry = @store[key]
|
|
22
|
-
return nil unless entry
|
|
23
|
-
|
|
24
|
-
value, expires_at = entry
|
|
25
|
-
|
|
26
|
-
# Check if the entry is expired
|
|
27
|
-
return nil if Time.now.to_i >= expires_at
|
|
28
|
-
|
|
29
|
-
value
|
|
30
|
-
end
|
|
33
|
+
@lock.synchronize { live_value(key) }
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
# Writes a value to the cache with an optional expiration time.
|
|
34
37
|
# @param key [String] The cache key.
|
|
35
38
|
# @param value [Object] The value to store.
|
|
36
|
-
# @param options [Hash]
|
|
37
|
-
#
|
|
39
|
+
# @param options [Hash] :expires_in (seconds); :unless_exist (true to keep
|
|
40
|
+
# an existing live entry, as ActiveSupport stores do).
|
|
41
|
+
# @return [Object, false] The stored value, or false when :unless_exist
|
|
42
|
+
# was given and a live entry already existed.
|
|
38
43
|
def write(key, value, options = {})
|
|
39
44
|
@lock.synchronize do
|
|
45
|
+
return false if options[:unless_exist] && !live_value(key).nil?
|
|
46
|
+
|
|
40
47
|
expiry = options[:expires_in] || DEFAULT_EXPIRY
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
@store[key] = [value, @clock.call + expiry]
|
|
49
|
+
sweep_if_needed
|
|
43
50
|
value
|
|
44
51
|
end
|
|
45
52
|
end
|
|
@@ -49,8 +56,45 @@ module RackJwtVerifier
|
|
|
49
56
|
# @return [Object, nil] The deleted entry value or nil.
|
|
50
57
|
def delete(key)
|
|
51
58
|
@lock.synchronize do
|
|
52
|
-
@store.delete(key)
|
|
59
|
+
entry = @store.delete(key)
|
|
60
|
+
entry&.first
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Removes every entry.
|
|
65
|
+
def clear
|
|
66
|
+
@lock.synchronize do
|
|
67
|
+
@store.clear
|
|
68
|
+
@sweep_at = SWEEP_THRESHOLD
|
|
53
69
|
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Number of entries held, expired ones included until the next sweep.
|
|
74
|
+
def size
|
|
75
|
+
@lock.synchronize { @store.size }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Caller holds the lock.
|
|
81
|
+
def live_value(key)
|
|
82
|
+
entry = @store[key]
|
|
83
|
+
return nil unless entry
|
|
84
|
+
|
|
85
|
+
value, expires_at = entry
|
|
86
|
+
return nil if @clock.call >= expires_at
|
|
87
|
+
|
|
88
|
+
value
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Caller holds the lock.
|
|
92
|
+
def sweep_if_needed
|
|
93
|
+
return if @store.size <= @sweep_at
|
|
94
|
+
|
|
95
|
+
now = @clock.call
|
|
96
|
+
@store.delete_if { |_, (_, expires_at)| now >= expires_at }
|
|
97
|
+
@sweep_at = [@store.size * 2, SWEEP_THRESHOLD].max
|
|
54
98
|
end
|
|
55
99
|
end
|
|
56
100
|
end
|
|
@@ -3,53 +3,77 @@
|
|
|
3
3
|
require 'jwt'
|
|
4
4
|
require 'openssl'
|
|
5
5
|
|
|
6
|
-
# A helper class for handling JWT creation and verification using RSA keys (RS256).
|
|
7
|
-
# This is typically used by the application or a separate service to *create* tokens.
|
|
8
6
|
module RackJwtVerifier
|
|
7
|
+
# @deprecated Will be removed in 0.4.0. Issues (and, for round-trip checks,
|
|
8
|
+
# decodes) RS256 tokens from an RSA private key.
|
|
9
|
+
#
|
|
10
|
+
# This is a second token issuer living inside the verifier: it sets neither
|
|
11
|
+
# `iss`, `aud`, `nbf` nor `jti`, so what it mints does not pass the claim
|
|
12
|
+
# policy the middleware now enforces. Issue tokens with the `jwt_auth_client`
|
|
13
|
+
# gem instead (HMAC today, RS256/ES256 from its 0.3.0); in a test suite,
|
|
14
|
+
# sign with `JWT.encode` directly — see spec/support/token_factory.rb in this
|
|
15
|
+
# repository for a helper you can copy.
|
|
9
16
|
class JwtHelper
|
|
10
|
-
|
|
17
|
+
ALGORITHM = 'RS256'
|
|
18
|
+
|
|
19
|
+
DEPRECATION = 'RackJwtVerifier::JwtHelper is deprecated and will be removed in 0.4.0: issue tokens with the ' \
|
|
20
|
+
'jwt_auth_client gem, or sign test tokens with JWT.encode. Set ' \
|
|
21
|
+
'RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS=1 to silence this warning.'
|
|
22
|
+
|
|
23
|
+
@warned = false
|
|
24
|
+
@warn_lock = Mutex.new
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Emits the deprecation warning once per process.
|
|
28
|
+
def warn_deprecated
|
|
29
|
+
return if ENV['RACK_JWT_VERIFIER_SILENCE_DEPRECATIONS'] == '1'
|
|
30
|
+
|
|
31
|
+
@warn_lock.synchronize do
|
|
32
|
+
return if @warned
|
|
33
|
+
|
|
34
|
+
@warned = true
|
|
35
|
+
end
|
|
36
|
+
Kernel.warn(DEPRECATION, uplevel: 2)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Forget that the warning was emitted. Intended for test suites.
|
|
40
|
+
def reset_deprecation_warning!
|
|
41
|
+
@warn_lock.synchronize { @warned = false }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
11
45
|
attr_reader :private_key, :public_key
|
|
12
46
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# @param private_key_pem [String] The PEM string of the RSA Private Key.
|
|
47
|
+
# @param private_key_pem [String, OpenSSL::PKey::RSA] The RSA private key used for signing.
|
|
16
48
|
def initialize(private_key_pem)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@private_key = OpenSSL::PKey::RSA.new(private_key_pem)
|
|
49
|
+
self.class.warn_deprecated
|
|
50
|
+
@private_key = private_key_pem.is_a?(OpenSSL::PKey::RSA) ? private_key_pem : OpenSSL::PKey::RSA.new(private_key_pem)
|
|
20
51
|
@public_key = @private_key.public_key
|
|
21
52
|
end
|
|
22
53
|
|
|
23
|
-
# Encodes a payload into a JWT
|
|
54
|
+
# Encodes a payload into a JWT, adding `iat` and `exp`.
|
|
24
55
|
#
|
|
25
|
-
# @param payload [Hash]
|
|
26
|
-
# @param expires_in [Integer]
|
|
56
|
+
# @param payload [Hash] Claims (string or symbol keys; an explicit `exp` in the payload wins).
|
|
57
|
+
# @param expires_in [Integer] Seconds until the token expires (default: 1 hour).
|
|
27
58
|
# @return [String] The signed JWT string.
|
|
28
59
|
def encode(payload, expires_in = 3600)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
JWT.encode(payload_with_claims, @private_key, 'RS256')
|
|
60
|
+
now = Time.now.to_i
|
|
61
|
+
# Normalise to string keys first so a caller's 'exp' and our :exp cannot
|
|
62
|
+
# both end up in the JSON as duplicate "exp" members.
|
|
63
|
+
claims = { 'iat' => now, 'exp' => now + expires_in }.merge(payload.transform_keys(&:to_s))
|
|
64
|
+
JWT.encode(claims, @private_key, ALGORITHM)
|
|
37
65
|
end
|
|
38
66
|
|
|
39
|
-
# Decodes and verifies a JWT
|
|
40
|
-
#
|
|
41
|
-
# NOTE: This method is used primarily for self-testing in the application
|
|
42
|
-
# but the primary verification logic for the middleware is in the Verifier class.
|
|
67
|
+
# Decodes and verifies a JWT with the public key. Meant for self-checks;
|
|
68
|
+
# the middleware's verification lives in Verifier.
|
|
43
69
|
#
|
|
44
70
|
# @param token [String] The JWT string to decode.
|
|
71
|
+
# @param options [Hash] Extra options for JWT.decode (leeway, iss, verify_iss, ...).
|
|
45
72
|
# @return [Hash] The decoded payload if verification is successful.
|
|
46
|
-
# @raise [JWT::
|
|
47
|
-
def decode(token)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
decoded = JWT.decode(token, @public_key, true, { algorithm: 'RS256' })
|
|
51
|
-
# Returns only the payload (the first element of the array).
|
|
52
|
-
decoded.first
|
|
73
|
+
# @raise [JWT::DecodeError] If the token is invalid or expired.
|
|
74
|
+
def decode(token, options = {})
|
|
75
|
+
payload, _header = JWT.decode(token, @public_key, true, { algorithm: ALGORITHM }.merge(options))
|
|
76
|
+
payload
|
|
53
77
|
end
|
|
54
78
|
end
|
|
55
79
|
end
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "jwt"
|
|
6
|
+
require "logger"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "openssl"
|
|
9
|
+
require "uri"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "version"
|
|
12
|
+
|
|
13
|
+
module RackJwtVerifier
|
|
14
|
+
# Where the verification key material comes from.
|
|
15
|
+
#
|
|
16
|
+
# Every source answers #jwks? and #refresh!. A single-key source (Static,
|
|
17
|
+
# RemotePem) also answers #verification_key; a JWKS source answers
|
|
18
|
+
# #jwks_loader, in the shape ruby-jwt's :jwks decode option expects.
|
|
19
|
+
module KeySource
|
|
20
|
+
# Turns PEM text into an OpenSSL public key. Accepts a bare public key
|
|
21
|
+
# (RSA, EC, Ed25519, ...) or an X.509 certificate, which is what many SSO
|
|
22
|
+
# providers serve at their ".pem" endpoints. An OpenSSL::PKey passes through.
|
|
23
|
+
def self.parse_public_key(pem)
|
|
24
|
+
return pem if pem.is_a?(OpenSSL::PKey::PKey)
|
|
25
|
+
|
|
26
|
+
text = pem.to_s
|
|
27
|
+
if text.include?("-----BEGIN CERTIFICATE-----")
|
|
28
|
+
OpenSSL::X509::Certificate.new(text).public_key
|
|
29
|
+
else
|
|
30
|
+
OpenSSL::PKey.read(text)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A key handed over directly: a PEM string, a certificate PEM, or an
|
|
35
|
+
# OpenSSL::PKey. Nothing to fetch, cache or refresh.
|
|
36
|
+
class Static
|
|
37
|
+
attr_reader :verification_key
|
|
38
|
+
|
|
39
|
+
def initialize(key)
|
|
40
|
+
@verification_key = KeySource.parse_public_key(key)
|
|
41
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::X509::CertificateError => e
|
|
42
|
+
raise ConfigurationError, "public_key is not a valid PEM public key or certificate: #{e.message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def jwks?
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def refresh!
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# An HMAC secret shared with the issuer (jwt_auth_client's mode). Nothing
|
|
55
|
+
# to fetch or rotate: a secret has no kid, so rotation means redeploying
|
|
56
|
+
# both sides. The RFC 7518 minimum length is enforced by the Verifier,
|
|
57
|
+
# which knows the algorithm list.
|
|
58
|
+
class Secret
|
|
59
|
+
attr_reader :verification_key
|
|
60
|
+
|
|
61
|
+
# @param secret [String, Hash] the secret itself, or { env: "VAR_NAME" }
|
|
62
|
+
# to read it from the environment at boot.
|
|
63
|
+
def initialize(secret)
|
|
64
|
+
@verification_key = resolve(secret)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def jwks?
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def refresh!
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def resolve(secret)
|
|
78
|
+
value =
|
|
79
|
+
case secret
|
|
80
|
+
when Hash
|
|
81
|
+
name = secret[:env] || secret["env"]
|
|
82
|
+
raise ConfigurationError, "shared_secret: { env: ... } needs the variable name" if name.to_s.empty?
|
|
83
|
+
|
|
84
|
+
ENV.fetch(name.to_s) { raise ConfigurationError, "shared_secret: environment variable #{name} is not set" }
|
|
85
|
+
when String
|
|
86
|
+
secret
|
|
87
|
+
else
|
|
88
|
+
raise ConfigurationError, "shared_secret must be a String or { env: \"VAR_NAME\" } (got #{secret.class})"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
raise ConfigurationError, "shared_secret must not be empty" if value.empty?
|
|
92
|
+
|
|
93
|
+
value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Shared machinery for key material fetched over HTTPS: caching, strict
|
|
98
|
+
# timeouts, a size cap, single-flight fetching within the process, and a
|
|
99
|
+
# rate-limited refresh for key rotation. Subclasses say how to parse the
|
|
100
|
+
# body and which cache namespace to use.
|
|
101
|
+
class Remote
|
|
102
|
+
# Timeout (seconds) applied separately to opening the connection and to
|
|
103
|
+
# reading the response. Kept short so a slow endpoint cannot pin every
|
|
104
|
+
# request thread on a cache miss.
|
|
105
|
+
DEFAULT_HTTP_TIMEOUT = 5
|
|
106
|
+
# Largest response body (bytes) we are willing to read. A PEM key is well
|
|
107
|
+
# under 1 KB and a JWKS with a handful of keys a few KB.
|
|
108
|
+
MAX_RESPONSE_BYTES = 64 * 1024
|
|
109
|
+
# How long (seconds) fetched material is served from the cache.
|
|
110
|
+
DEFAULT_CACHE_TTL = 300
|
|
111
|
+
# Minimum gap (seconds) between two rotation-triggered refetches, so a
|
|
112
|
+
# flood of tokens with bad signatures or unknown kids cannot turn into a
|
|
113
|
+
# flood of requests to the provider.
|
|
114
|
+
DEFAULT_REFETCH_INTERVAL = 60
|
|
115
|
+
|
|
116
|
+
attr_reader :url, :cache_key
|
|
117
|
+
|
|
118
|
+
def initialize(url:, cache:, cache_ttl: DEFAULT_CACHE_TTL, http_timeout: DEFAULT_HTTP_TIMEOUT,
|
|
119
|
+
refetch_interval: DEFAULT_REFETCH_INTERVAL, allow_insecure_http: false, logger: nil)
|
|
120
|
+
@url = url.to_s
|
|
121
|
+
@uri = parse_url(@url, allow_insecure_http: allow_insecure_http)
|
|
122
|
+
@cache = cache
|
|
123
|
+
@logger = logger || Logger.new(IO::NULL)
|
|
124
|
+
@cache_ttl = cache_ttl
|
|
125
|
+
@http_timeout = http_timeout
|
|
126
|
+
@refetch_interval = refetch_interval
|
|
127
|
+
# Scoped to the URL so two verifiers sharing one cache store (two SSO
|
|
128
|
+
# providers behind one Redis) never read each other's key.
|
|
129
|
+
@cache_key = "#{cache_key_prefix}:#{Digest::SHA256.hexdigest(@url)[0, 16]}"
|
|
130
|
+
|
|
131
|
+
@fetch_lock = Mutex.new # single-flight: one network fetch per process on a cold cache
|
|
132
|
+
@state_lock = Mutex.new # guards @parsed and @last_refetch_at
|
|
133
|
+
@parsed = nil # [body, parsed material] of the last body parsed
|
|
134
|
+
@last_refetch_at = nil # monotonic clock
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Refetches the material unless a refresh happened less than
|
|
138
|
+
# refetch_interval seconds ago. Returns true if it actually refetched.
|
|
139
|
+
def refresh!
|
|
140
|
+
@state_lock.synchronize do
|
|
141
|
+
now = monotonic_now
|
|
142
|
+
return false if @last_refetch_at && now - @last_refetch_at < @refetch_interval
|
|
143
|
+
|
|
144
|
+
@last_refetch_at = now
|
|
145
|
+
end
|
|
146
|
+
fetch_and_store
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
# Parsed material, from the cache or (on a miss) the network.
|
|
153
|
+
def material
|
|
154
|
+
body = cache_read || fetch_once
|
|
155
|
+
parsed_for(body)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Only one thread per process performs the fetch on a cold cache; the
|
|
159
|
+
# others wait for it and then find the body in the cache.
|
|
160
|
+
def fetch_once
|
|
161
|
+
@fetch_lock.synchronize do
|
|
162
|
+
cache_read || fetch_and_store
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def fetch_and_store
|
|
167
|
+
body = fetch_body
|
|
168
|
+
# Parse *before* caching so a 200 that is not key material (an HTML
|
|
169
|
+
# maintenance page, say) is never stored and served for the TTL.
|
|
170
|
+
parse!(body)
|
|
171
|
+
cache_write(body)
|
|
172
|
+
body
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# A broken cache store (Redis down, say) must not take authentication
|
|
176
|
+
# down with it: treat a failed read as a miss and a failed write as a
|
|
177
|
+
# no-op, and say so in the log. Fetches then fall back to one per
|
|
178
|
+
# request per process until the store recovers.
|
|
179
|
+
def cache_read
|
|
180
|
+
@cache.read(cache_key)
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
@logger.warn do
|
|
183
|
+
"rack_jwt_verifier: cache read failed (#{e.class}: #{e.message}); fetching key material directly"
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def cache_write(body)
|
|
189
|
+
@cache.write(cache_key, body, expires_in: @cache_ttl)
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
@logger.warn do
|
|
192
|
+
"rack_jwt_verifier: cache write failed (#{e.class}: #{e.message}); key material will be refetched"
|
|
193
|
+
end
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Parsing is not free (and JWKS parsing less so), so the result for a
|
|
198
|
+
# given body is memoised until the body changes.
|
|
199
|
+
def parsed_for(body)
|
|
200
|
+
@state_lock.synchronize do
|
|
201
|
+
@parsed = [body, parse!(body)] unless @parsed && @parsed[0] == body
|
|
202
|
+
@parsed[1]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def parse!(body)
|
|
207
|
+
parse(body)
|
|
208
|
+
rescue KeyFetchError
|
|
209
|
+
raise
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
raise KeyFetchError, "Error processing public key: #{e.message}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The material must travel over TLS: an attacker who can tamper with a
|
|
215
|
+
# plaintext fetch can substitute their own key and mint arbitrary tokens.
|
|
216
|
+
def parse_url(url, allow_insecure_http:)
|
|
217
|
+
uri = URI.parse(url)
|
|
218
|
+
return uri if uri.is_a?(URI::HTTPS)
|
|
219
|
+
return uri if uri.is_a?(URI::HTTP) && allow_insecure_http
|
|
220
|
+
|
|
221
|
+
raise ConfigurationError,
|
|
222
|
+
"#{url_option_name} must be an https:// URL (got #{url.inspect}). " \
|
|
223
|
+
"Pass allow_insecure_http: true to permit http:// in development."
|
|
224
|
+
rescue URI::InvalidURIError
|
|
225
|
+
raise ConfigurationError, "#{url_option_name} is not a valid URL: #{url.inspect}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Performs the HTTP GET with strict timeouts and a size cap. The body is
|
|
229
|
+
# streamed so an oversized response is abandoned early rather than
|
|
230
|
+
# buffered in full. Redirects are deliberately not followed: a 3xx to
|
|
231
|
+
# an http:// or third-party host would defeat the https:// requirement.
|
|
232
|
+
def fetch_body
|
|
233
|
+
uri = @uri
|
|
234
|
+
body = +""
|
|
235
|
+
|
|
236
|
+
Net::HTTP.start(uri.host, uri.port,
|
|
237
|
+
use_ssl: uri.scheme == "https",
|
|
238
|
+
open_timeout: @http_timeout,
|
|
239
|
+
read_timeout: @http_timeout,
|
|
240
|
+
write_timeout: @http_timeout) do |http|
|
|
241
|
+
request = Net::HTTP::Get.new(uri)
|
|
242
|
+
request["User-Agent"] = "rack_jwt_verifier/#{VERSION}"
|
|
243
|
+
request["Accept"] = accept_header
|
|
244
|
+
|
|
245
|
+
http.request(request) do |response|
|
|
246
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
247
|
+
raise KeyFetchError, "Failed to fetch public key from #{@url}: #{response.code}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
response.read_body do |chunk|
|
|
251
|
+
body << chunk
|
|
252
|
+
if body.bytesize > MAX_RESPONSE_BYTES
|
|
253
|
+
raise KeyFetchError, "Public key response from #{@url} exceeds #{MAX_RESPONSE_BYTES} bytes"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
body
|
|
260
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
261
|
+
raise KeyFetchError, "Timed out fetching public key from #{@url} after #{@http_timeout}s"
|
|
262
|
+
rescue KeyFetchError
|
|
263
|
+
raise
|
|
264
|
+
rescue StandardError => e
|
|
265
|
+
raise KeyFetchError, "Error fetching public key from #{@url}: #{e.message}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def monotonic_now
|
|
269
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# A single PEM public key (or X.509 certificate) served at a URL.
|
|
274
|
+
class RemotePem < Remote
|
|
275
|
+
CACHE_KEY_PREFIX = "rack_jwt_verifier:public_key"
|
|
276
|
+
|
|
277
|
+
def verification_key
|
|
278
|
+
material
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def jwks?
|
|
282
|
+
false
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private
|
|
286
|
+
|
|
287
|
+
def cache_key_prefix
|
|
288
|
+
CACHE_KEY_PREFIX
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def url_option_name
|
|
292
|
+
"public_key_url"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def accept_header
|
|
296
|
+
"application/x-pem-file, text/plain, */*"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def parse(body)
|
|
300
|
+
KeySource.parse_public_key(body)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# A JSON Web Key Set served at a URL (typically /.well-known/jwks.json).
|
|
305
|
+
class RemoteJwks < Remote
|
|
306
|
+
CACHE_KEY_PREFIX = "rack_jwt_verifier:jwks"
|
|
307
|
+
|
|
308
|
+
def jwks?
|
|
309
|
+
true
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# ruby-jwt calls the loader once, and again with kid_not_found: true when
|
|
313
|
+
# the token's kid is absent from the set it got — the rotation signal for
|
|
314
|
+
# JWKS. Refresh (rate-limited) on that signal, then hand back the set.
|
|
315
|
+
def jwks_loader
|
|
316
|
+
lambda do |options|
|
|
317
|
+
refresh! if options[:kid_not_found] || options[:invalidate]
|
|
318
|
+
material
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
private
|
|
323
|
+
|
|
324
|
+
def cache_key_prefix
|
|
325
|
+
CACHE_KEY_PREFIX
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def url_option_name
|
|
329
|
+
"jwks_url"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def accept_header
|
|
333
|
+
"application/jwk-set+json, application/json, */*"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def parse(body)
|
|
337
|
+
set = JWT::JWK::Set.new(JSON.parse(body))
|
|
338
|
+
raise KeyFetchError, "JWKS from #{@url} contains no keys" if set.none?
|
|
339
|
+
|
|
340
|
+
set
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|