rack-jwt-verifier 0.2.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 +87 -1
- data/README.md +151 -17
- data/lib/rack_jwt_verifier/errors.rb +33 -1
- data/lib/rack_jwt_verifier/in_process_cache.rb +51 -13
- data/lib/rack_jwt_verifier/jwt_helper.rb +36 -3
- data/lib/rack_jwt_verifier/key_source.rb +51 -6
- data/lib/rack_jwt_verifier/middleware.rb +74 -11
- 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 +141 -28
- data/lib/rack_jwt_verifier/version.rb +1 -1
- data/lib/rack_jwt_verifier.rb +4 -1
- metadata +18 -8
|
@@ -39,7 +39,7 @@ module RackJwtVerifier
|
|
|
39
39
|
def initialize(key)
|
|
40
40
|
@verification_key = KeySource.parse_public_key(key)
|
|
41
41
|
rescue OpenSSL::PKey::PKeyError, OpenSSL::X509::CertificateError => e
|
|
42
|
-
raise
|
|
42
|
+
raise ConfigurationError, "public_key is not a valid PEM public key or certificate: #{e.message}"
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def jwks?
|
|
@@ -51,6 +51,49 @@ module RackJwtVerifier
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
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
|
+
|
|
54
97
|
# Shared machinery for key material fetched over HTTPS: caching, strict
|
|
55
98
|
# timeouts, a size cap, single-flight fetching within the process, and a
|
|
56
99
|
# rate-limited refresh for key rotation. Subclasses say how to parse the
|
|
@@ -175,16 +218,17 @@ module RackJwtVerifier
|
|
|
175
218
|
return uri if uri.is_a?(URI::HTTPS)
|
|
176
219
|
return uri if uri.is_a?(URI::HTTP) && allow_insecure_http
|
|
177
220
|
|
|
178
|
-
raise
|
|
221
|
+
raise ConfigurationError,
|
|
179
222
|
"#{url_option_name} must be an https:// URL (got #{url.inspect}). " \
|
|
180
223
|
"Pass allow_insecure_http: true to permit http:// in development."
|
|
181
224
|
rescue URI::InvalidURIError
|
|
182
|
-
raise
|
|
225
|
+
raise ConfigurationError, "#{url_option_name} is not a valid URL: #{url.inspect}"
|
|
183
226
|
end
|
|
184
227
|
|
|
185
228
|
# Performs the HTTP GET with strict timeouts and a size cap. The body is
|
|
186
229
|
# streamed so an oversized response is abandoned early rather than
|
|
187
|
-
# buffered in full.
|
|
230
|
+
# buffered in full. Redirects are deliberately not followed: a 3xx to
|
|
231
|
+
# an http:// or third-party host would defeat the https:// requirement.
|
|
188
232
|
def fetch_body
|
|
189
233
|
uri = @uri
|
|
190
234
|
body = +""
|
|
@@ -192,7 +236,8 @@ module RackJwtVerifier
|
|
|
192
236
|
Net::HTTP.start(uri.host, uri.port,
|
|
193
237
|
use_ssl: uri.scheme == "https",
|
|
194
238
|
open_timeout: @http_timeout,
|
|
195
|
-
read_timeout: @http_timeout
|
|
239
|
+
read_timeout: @http_timeout,
|
|
240
|
+
write_timeout: @http_timeout) do |http|
|
|
196
241
|
request = Net::HTTP::Get.new(uri)
|
|
197
242
|
request["User-Agent"] = "rack_jwt_verifier/#{VERSION}"
|
|
198
243
|
request["Accept"] = accept_header
|
|
@@ -212,7 +257,7 @@ module RackJwtVerifier
|
|
|
212
257
|
end
|
|
213
258
|
|
|
214
259
|
body
|
|
215
|
-
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
260
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
216
261
|
raise KeyFetchError, "Timed out fetching public key from #{@url} after #{@http_timeout}s"
|
|
217
262
|
rescue KeyFetchError
|
|
218
263
|
raise
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "logger"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "scopes"
|
|
5
7
|
require_relative "verifier"
|
|
6
8
|
|
|
7
9
|
module RackJwtVerifier
|
|
@@ -25,7 +27,9 @@ module RackJwtVerifier
|
|
|
25
27
|
ERROR_RESPONSES = {
|
|
26
28
|
missing_token: [401, "Unauthorized: Bearer token required."],
|
|
27
29
|
invalid_token: [401, "Unauthorized: Invalid or expired JWT."],
|
|
28
|
-
|
|
30
|
+
insufficient_scope: [403, "Forbidden: the token does not grant the required scope."],
|
|
31
|
+
key_unavailable: [503, "Service Unavailable: could not fetch the token verification key."],
|
|
32
|
+
replay_cache_unavailable: [503, "Service Unavailable: could not check the token for replay."]
|
|
29
33
|
}.freeze
|
|
30
34
|
|
|
31
35
|
# @param app [#call] The downstream Rack application.
|
|
@@ -42,20 +46,26 @@ module RackJwtVerifier
|
|
|
42
46
|
# `{"error", "error_description"}` (default: plain text).
|
|
43
47
|
# @option options [#call] :on_error `->(env, reason, exception) { rack_response or nil }`
|
|
44
48
|
# to customise refusals.
|
|
49
|
+
# @option options [Array<String>] :require_scopes Scopes every token must grant; a token
|
|
50
|
+
# lacking one gets a 403 with an `insufficient_scope` challenge. Implies :require_token.
|
|
51
|
+
# @option options [Boolean] :require_iss_aud Refuse to boot unless decode_options carries
|
|
52
|
+
# both :iss and :aud (default: true). `false` logs a warning instead.
|
|
53
|
+
# @raise [ConfigurationError] for an unusable option set.
|
|
45
54
|
def initialize(app, options = {})
|
|
46
55
|
@app = app
|
|
47
|
-
@require_token = options.fetch(:require_token, false)
|
|
48
56
|
@logger = options[:logger]
|
|
49
57
|
@env_key = options.fetch(:env_key, RACK_ENV_PAYLOAD_KEY)
|
|
50
58
|
@skip = validate_skip_rules(Array(options[:skip]))
|
|
51
59
|
@json_errors = options.fetch(:json_errors, false)
|
|
52
60
|
@on_error = options[:on_error]
|
|
61
|
+
@require_scopes = validate_required_scopes(options[:require_scopes])
|
|
62
|
+
@require_token = resolve_require_token(options)
|
|
53
63
|
|
|
54
64
|
# The Verifier instance is initialized with options (like public_key_url)
|
|
55
65
|
# and is responsible for all crypto and key management.
|
|
56
66
|
@verifier = Verifier.new(options)
|
|
57
67
|
|
|
58
|
-
|
|
68
|
+
enforce_claim_policy(options)
|
|
59
69
|
end
|
|
60
70
|
|
|
61
71
|
def call(env)
|
|
@@ -86,6 +96,16 @@ module RackJwtVerifier
|
|
|
86
96
|
# client's, so answer 503 rather than 401.
|
|
87
97
|
logger(env).error { "rack_jwt_verifier: #{e.message}" }
|
|
88
98
|
return error_response(env, :key_unavailable, e)
|
|
99
|
+
rescue ReplayCacheError => e
|
|
100
|
+
logger(env).error { "rack_jwt_verifier: #{e.message}" }
|
|
101
|
+
return error_response(env, :replay_cache_unavailable, e)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
missing = Scopes.missing(payload, @require_scopes)
|
|
105
|
+
unless missing.empty?
|
|
106
|
+
error = InsufficientScopeError.new(required: @require_scopes, missing: missing)
|
|
107
|
+
logger(env).warn { "rack_jwt_verifier: #{error.message}" }
|
|
108
|
+
return error_response(env, :insufficient_scope, error)
|
|
89
109
|
end
|
|
90
110
|
|
|
91
111
|
# On successful verification, store the payload in the Rack environment
|
|
@@ -130,25 +150,65 @@ module RackJwtVerifier
|
|
|
130
150
|
rules.each do |rule|
|
|
131
151
|
next if rule.is_a?(String) || rule.is_a?(Regexp) || rule.respond_to?(:call)
|
|
132
152
|
|
|
133
|
-
raise
|
|
153
|
+
raise ConfigurationError, "skip: entries must be a String, a Regexp or respond to #call (got #{rule.inspect})"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_required_scopes(scopes)
|
|
158
|
+
list = Array(scopes).map(&:to_s)
|
|
159
|
+
if list.any?(&:empty?)
|
|
160
|
+
raise ConfigurationError, "require_scopes: entries must be non-empty strings (got #{scopes.inspect})"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
list.uniq.freeze
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# A required scope can only be checked on a token, so require_scopes
|
|
167
|
+
# implies require_token; saying otherwise explicitly is a contradiction.
|
|
168
|
+
def resolve_require_token(options)
|
|
169
|
+
return options.fetch(:require_token, false) if @require_scopes.empty?
|
|
170
|
+
|
|
171
|
+
if options.key?(:require_token) && !options[:require_token]
|
|
172
|
+
raise ConfigurationError, "require_scopes: needs a token to check; drop require_token: false"
|
|
134
173
|
end
|
|
174
|
+
|
|
175
|
+
true
|
|
135
176
|
end
|
|
136
177
|
|
|
137
178
|
def logger(env)
|
|
138
179
|
@logger || env["rack.logger"] || NULL_LOGGER
|
|
139
180
|
end
|
|
140
181
|
|
|
141
|
-
# A key alone proves who signed the token, not who it was meant for
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
182
|
+
# A key alone proves who signed the token, not who it was meant for — and
|
|
183
|
+
# a shared secret proves even less. Both iss and aud must be checked
|
|
184
|
+
# unless the operator explicitly opts out, in which case they get the
|
|
185
|
+
# warning instead.
|
|
186
|
+
def enforce_claim_policy(options)
|
|
187
|
+
decode_options = options.fetch(:decode_options, {})
|
|
188
|
+
missing = %i[iss aud].reject { |claim| present?(decode_options[claim]) }
|
|
189
|
+
return if missing.empty?
|
|
190
|
+
|
|
191
|
+
if options.fetch(:require_iss_aud, true)
|
|
192
|
+
raise ConfigurationError,
|
|
193
|
+
"decode_options must set #{missing.map(&:inspect).join(' and ')} so only tokens issued by " \
|
|
194
|
+
"your provider, for this application, are accepted. Pass require_iss_aud: false to opt out."
|
|
195
|
+
end
|
|
145
196
|
|
|
146
197
|
(@logger || Kernel).warn(
|
|
147
|
-
"rack_jwt_verifier:
|
|
198
|
+
"rack_jwt_verifier: #{missing.map(&:inspect).join(' and ')} not set in decode_options, so any " \
|
|
148
199
|
"token signed by the configured key is accepted. Set decode_options: { iss: ..., aud: ... }."
|
|
149
200
|
)
|
|
150
201
|
end
|
|
151
202
|
|
|
203
|
+
def present?(value)
|
|
204
|
+
case value
|
|
205
|
+
when nil then false
|
|
206
|
+
when String then !value.strip.empty?
|
|
207
|
+
when Array then value.any? { |v| present?(v) }
|
|
208
|
+
else true
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
152
212
|
# Builds the refusal for `reason`, letting an :on_error hook take over
|
|
153
213
|
# first. Returning nil from the hook falls back to the default response.
|
|
154
214
|
def error_response(env, reason, error)
|
|
@@ -159,7 +219,7 @@ module RackJwtVerifier
|
|
|
159
219
|
description = error && sanitize_description(error.message)
|
|
160
220
|
|
|
161
221
|
headers = {}
|
|
162
|
-
headers["www-authenticate"] = challenge(reason, description) if status
|
|
222
|
+
headers["www-authenticate"] = challenge(reason, description) if [401, 403].include?(status)
|
|
163
223
|
headers["retry-after"] = RETRY_AFTER_SECONDS.to_s if status == 503
|
|
164
224
|
|
|
165
225
|
if @json_errors
|
|
@@ -172,11 +232,14 @@ module RackJwtVerifier
|
|
|
172
232
|
|
|
173
233
|
# RFC 6750 §3: a request that carried no credentials at all gets the bare
|
|
174
234
|
# challenge, without an error code; otherwise the code and a description.
|
|
235
|
+
# An insufficient_scope challenge also names the scopes required (§3.1).
|
|
175
236
|
def challenge(reason, description)
|
|
176
237
|
return "Bearer" if reason == :missing_token
|
|
177
238
|
|
|
178
|
-
|
|
239
|
+
code = reason == :insufficient_scope ? "insufficient_scope" : "invalid_token"
|
|
240
|
+
value = "Bearer error=\"#{code}\""
|
|
179
241
|
value << ", error_description=\"#{description}\"" if description && !description.empty?
|
|
242
|
+
value << ", scope=\"#{sanitize_description(@require_scopes.join(' '))}\"" if reason == :insufficient_scope
|
|
180
243
|
value
|
|
181
244
|
end
|
|
182
245
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module RackJwtVerifier
|
|
7
|
+
# Records each verified token's `jti` in a cache store until the token
|
|
8
|
+
# expires, and rejects a token whose `jti` is already there.
|
|
9
|
+
#
|
|
10
|
+
# The store is the same read/write abstraction used for key material
|
|
11
|
+
# (InProcessCache, Rails.cache, ...). An in-process store only detects
|
|
12
|
+
# replays within one worker; use a shared store for real protection.
|
|
13
|
+
#
|
|
14
|
+
# Failure mode is closed: a store that raises turns into ReplayCacheError
|
|
15
|
+
# (a 503 in the middleware). The operator asked for replay protection, so a
|
|
16
|
+
# request whose jti cannot be checked is not waved through.
|
|
17
|
+
class ReplayGuard
|
|
18
|
+
CACHE_KEY_PREFIX = "rack_jwt_verifier:jti"
|
|
19
|
+
|
|
20
|
+
# Used when a payload has no numeric `exp` to derive the TTL from (only
|
|
21
|
+
# possible when the operator removed `exp` from required_claims).
|
|
22
|
+
FALLBACK_TTL = 24 * 60 * 60
|
|
23
|
+
|
|
24
|
+
attr_reader :store, :leeway
|
|
25
|
+
|
|
26
|
+
# @param store [#read, #write] the cache store holding seen jtis.
|
|
27
|
+
# @param leeway [Numeric] clock-skew leeway; a jti is remembered for exp + leeway.
|
|
28
|
+
def initialize(store, leeway: 0)
|
|
29
|
+
@store = store
|
|
30
|
+
@leeway = leeway
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param payload [Hash] the verified claims.
|
|
34
|
+
# @raise [ReplayedTokenError] if this jti has been seen before.
|
|
35
|
+
# @raise [JWT::InvalidJtiError] if the payload carries no usable jti.
|
|
36
|
+
# @raise [ReplayCacheError] if the store cannot be read or written.
|
|
37
|
+
def check!(payload)
|
|
38
|
+
jti = payload["jti"].to_s
|
|
39
|
+
raise JWT::InvalidJtiError, "Missing jti" if jti.strip.empty?
|
|
40
|
+
|
|
41
|
+
key = cache_key(jti)
|
|
42
|
+
ttl = ttl_for(payload)
|
|
43
|
+
|
|
44
|
+
# Read first so stores that ignore unless_exist still catch the common
|
|
45
|
+
# case; the conditional write then closes the window for stores that
|
|
46
|
+
# honour it (ActiveSupport stores return false when the key exists).
|
|
47
|
+
seen, stored = store_access(key, ttl)
|
|
48
|
+
raise ReplayedTokenError, "Token has already been used (jti #{jti})" if seen || stored == false
|
|
49
|
+
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param jti [String]
|
|
54
|
+
# @return [String] the cache key; jti is hashed so an attacker-chosen value
|
|
55
|
+
# cannot produce an unbounded or store-hostile key.
|
|
56
|
+
def cache_key(jti)
|
|
57
|
+
"#{CACHE_KEY_PREFIX}:#{Digest::SHA256.hexdigest(jti)}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def store_access(key, ttl)
|
|
63
|
+
seen = @store.read(key)
|
|
64
|
+
return [true, nil] if seen
|
|
65
|
+
|
|
66
|
+
[false, @store.write(key, 1, expires_in: ttl, unless_exist: true)]
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
raise ReplayCacheError, "replay cache unavailable (#{e.class}: #{e.message})"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def ttl_for(payload)
|
|
72
|
+
exp = payload["exp"]
|
|
73
|
+
return FALLBACK_TTL unless exp.is_a?(Numeric)
|
|
74
|
+
|
|
75
|
+
[(exp - Time.now.to_i + @leeway).ceil, 1].max
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RackJwtVerifier
|
|
4
|
+
# Reads the scopes granted by a verified token.
|
|
5
|
+
#
|
|
6
|
+
# Two claim shapes are understood: `scopes`, an Array of Strings (what
|
|
7
|
+
# jwt_auth_client emits), and `scope`, a space-delimited String (the OAuth 2
|
|
8
|
+
# convention, RFC 8693 §4.2 / RFC 9068 §2.2.3). Both are normalised to an
|
|
9
|
+
# Array of Strings.
|
|
10
|
+
#
|
|
11
|
+
# claims = env["rack_jwt_verifier.payload"]
|
|
12
|
+
# RackJwtVerifier::Scopes.from(claims) # => ["read:invoices"]
|
|
13
|
+
# RackJwtVerifier::Scopes.include?(claims, "read:invoices") # => true
|
|
14
|
+
# RackJwtVerifier::Scopes.missing(claims, %w[read:x write:x]) # => ["write:x"]
|
|
15
|
+
module Scopes
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param payload [Hash, nil] the verified claims.
|
|
19
|
+
# @return [Array<String>] granted scopes; empty when there are none.
|
|
20
|
+
def from(payload)
|
|
21
|
+
return [] unless payload.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
raw = payload["scopes"] || payload[:scopes]
|
|
24
|
+
raw = payload["scope"] || payload[:scope] if raw.nil?
|
|
25
|
+
|
|
26
|
+
case raw
|
|
27
|
+
when Array then raw.map(&:to_s).reject(&:empty?)
|
|
28
|
+
when String then raw.split
|
|
29
|
+
else []
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>] the required scopes the payload does not grant.
|
|
34
|
+
def missing(payload, required)
|
|
35
|
+
Array(required).map(&:to_s) - from(payload)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Boolean] true when every given scope is granted.
|
|
39
|
+
def include?(payload, *scopes)
|
|
40
|
+
missing(payload, scopes.flatten).empty?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -4,11 +4,13 @@ require "jwt"
|
|
|
4
4
|
require_relative "errors"
|
|
5
5
|
require_relative "in_process_cache"
|
|
6
6
|
require_relative "key_source"
|
|
7
|
+
require_relative "replay_guard"
|
|
7
8
|
|
|
8
9
|
module RackJwtVerifier
|
|
9
10
|
# Decodes and verifies JWTs against key material from a configured source:
|
|
10
|
-
# a static PEM, a PEM served at a URL,
|
|
11
|
-
# key rotation
|
|
11
|
+
# a static PEM, a PEM served at a URL, a JWKS endpoint, or a shared HMAC
|
|
12
|
+
# secret. Handles caching, key rotation, claim enforcement and optional jti
|
|
13
|
+
# replay protection so the middleware does not have to.
|
|
12
14
|
class Verifier
|
|
13
15
|
# Kept under the old constant so existing `rescue Verifier::KeyFetchError`
|
|
14
16
|
# code keeps working.
|
|
@@ -27,8 +29,21 @@ module RackJwtVerifier
|
|
|
27
29
|
# Minimum gap (seconds) between rotation-triggered refetches
|
|
28
30
|
DEFAULT_REFETCH_INTERVAL = KeySource::Remote::DEFAULT_REFETCH_INTERVAL
|
|
29
31
|
|
|
30
|
-
# Exactly one of these tells the Verifier where its key comes from.
|
|
31
|
-
|
|
32
|
+
# Exactly one of these tells the Verifier where its key comes from. Having
|
|
33
|
+
# shared_secret in the same exclusive set is what rules out "a secret next
|
|
34
|
+
# to a public key" — the classic RS256/HS256 confusion setup.
|
|
35
|
+
KEY_SOURCE_OPTIONS = %i[public_key public_key_url jwks_url shared_secret].freeze
|
|
36
|
+
|
|
37
|
+
# The HMAC family, only ever enabled by shared_secret.
|
|
38
|
+
HMAC_ALGORITHMS = %w[HS256 HS384 HS512].freeze
|
|
39
|
+
|
|
40
|
+
# RFC 7518 §3.2: an HMAC key must be at least as long as the hash output.
|
|
41
|
+
# Same numbers jwt_auth_client enforces on the signing side.
|
|
42
|
+
MIN_SECRET_BYTES = { "HS256" => 32, "HS384" => 48, "HS512" => 64 }.freeze
|
|
43
|
+
|
|
44
|
+
# Default accepted algorithms per key-source family.
|
|
45
|
+
DEFAULT_ASYMMETRIC_ALGORITHMS = %w[RS256].freeze
|
|
46
|
+
DEFAULT_HMAC_ALGORITHMS = %w[HS256].freeze
|
|
32
47
|
|
|
33
48
|
# ruby-jwt only validates an expected claim value (e.g. `iss: "..."`) when
|
|
34
49
|
# the matching `verify_*` flag is also set. Map each claim to its flag so we
|
|
@@ -39,12 +54,13 @@ module RackJwtVerifier
|
|
|
39
54
|
sub: :verify_sub
|
|
40
55
|
}.freeze
|
|
41
56
|
|
|
42
|
-
# Default options for JWT decoding to ensure strict security compliance
|
|
57
|
+
# Default options for JWT decoding to ensure strict security compliance.
|
|
58
|
+
# ruby-jwt only checks exp/nbf when the claim is present, so `exp` is also
|
|
59
|
+
# listed as required: a token with no expiry would otherwise be valid forever.
|
|
43
60
|
DEFAULT_DECODE_OPTIONS = {
|
|
44
|
-
algorithm: "RS256", # Must match your SSO provider's algorithm
|
|
45
|
-
# THIS MUST BE TRUE: Ensures the 'exp' claim is checked during decoding
|
|
46
61
|
verify_expiration: true,
|
|
47
62
|
verify_not_before: true,
|
|
63
|
+
required_claims: %w[exp].freeze,
|
|
48
64
|
leeway: 60 # Allow a 60-second clock skew for "exp" and "nbf" claims
|
|
49
65
|
}.freeze
|
|
50
66
|
|
|
@@ -52,25 +68,50 @@ module RackJwtVerifier
|
|
|
52
68
|
# @option options [String, OpenSSL::PKey] :public_key A static PEM public key or X.509 certificate.
|
|
53
69
|
# @option options [String] :public_key_url The https:// URL serving a PEM public key or certificate.
|
|
54
70
|
# @option options [String] :jwks_url The https:// URL serving a JSON Web Key Set.
|
|
55
|
-
# @option options [
|
|
71
|
+
# @option options [String, Hash] :shared_secret An HMAC secret (String) or `{ env: "VAR" }`.
|
|
72
|
+
# Enables the HS* algorithms; mutually exclusive with the public-key sources.
|
|
73
|
+
# @option options [Array<String>] :algorithms Accepted signing algorithms
|
|
74
|
+
# (default: ["RS256"], or ["HS256"] with :shared_secret).
|
|
56
75
|
# @option options [Boolean] :allow_insecure_http Permit a plain http:// URL (development only).
|
|
57
|
-
# @option options [Numeric] :http_timeout Open/read timeout in seconds for the key fetch.
|
|
76
|
+
# @option options [Numeric] :http_timeout Open/read/write timeout in seconds for the key fetch.
|
|
58
77
|
# @option options [Integer] :cache_ttl Seconds to cache fetched key material.
|
|
59
78
|
# @option options [Numeric] :refetch_interval Minimum seconds between rotation-triggered refetches.
|
|
60
79
|
# @option options [Object] :cache_store Optional custom cache object (must respond to #read and #write).
|
|
80
|
+
# @option options [Boolean, Object] :replay_cache `true` to track `jti` in :cache_store (or a fresh
|
|
81
|
+
# InProcessCache), or a cache store to track it in. Tokens are then required to carry a `jti`
|
|
82
|
+
# and a second presentation before `exp` is rejected.
|
|
61
83
|
# @option options [Logger] :logger Where cache-store failures are reported (default: silent).
|
|
62
84
|
# @option options [Hash] :decode_options Custom options for JWT.decode.
|
|
85
|
+
# @raise [ConfigurationError] for an unusable option set.
|
|
63
86
|
def initialize(options = {})
|
|
64
|
-
|
|
65
|
-
@
|
|
87
|
+
source_name = key_source_name(options)
|
|
88
|
+
@key_source = build_key_source(source_name, options)
|
|
89
|
+
@algorithms = build_algorithms(source_name, options)
|
|
90
|
+
@decode_options = build_decode_options(options.fetch(:decode_options, {}), replay: replay_wanted?(options))
|
|
91
|
+
@replay_guard = build_replay_guard(options, @decode_options[:leeway])
|
|
66
92
|
end
|
|
67
93
|
|
|
94
|
+
# The algorithms this verifier accepts, after defaults and policy.
|
|
95
|
+
# @return [Array<String>]
|
|
96
|
+
attr_reader :algorithms
|
|
97
|
+
|
|
68
98
|
# Decodes and verifies the JWT.
|
|
69
99
|
# @param token [String] The JWT string from the Authorization header.
|
|
70
100
|
# @return [Hash] The decoded payload (the user claims).
|
|
71
|
-
# @raise [JWT::DecodeError] If the token is invalid, expired, or signature fails.
|
|
101
|
+
# @raise [JWT::DecodeError] If the token is invalid, expired, replayed, or signature fails.
|
|
72
102
|
# @raise [KeyFetchError] If the key material could not be obtained.
|
|
103
|
+
# @raise [ReplayCacheError] If replay protection is on and its store is unavailable.
|
|
73
104
|
def verify(token)
|
|
105
|
+
payload = decode_with_rotation(token)
|
|
106
|
+
# Only a token that passed every other check is recorded: an expired or
|
|
107
|
+
# mis-signed token must not be able to "burn" a jti.
|
|
108
|
+
@replay_guard&.check!(payload)
|
|
109
|
+
payload
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def decode_with_rotation(token)
|
|
74
115
|
decode(token)
|
|
75
116
|
rescue JWT::VerificationError
|
|
76
117
|
# A signature mismatch may mean the provider rotated its key. Refresh
|
|
@@ -81,8 +122,6 @@ module RackJwtVerifier
|
|
|
81
122
|
decode(token)
|
|
82
123
|
end
|
|
83
124
|
|
|
84
|
-
private
|
|
85
|
-
|
|
86
125
|
# Performs the cryptographic verification and claim validation. The `true`
|
|
87
126
|
# is required to enable verification checks; only the payload is returned.
|
|
88
127
|
def decode(token)
|
|
@@ -95,21 +134,25 @@ module RackJwtVerifier
|
|
|
95
134
|
payload
|
|
96
135
|
end
|
|
97
136
|
|
|
98
|
-
def
|
|
137
|
+
def key_source_name(options)
|
|
99
138
|
given = KEY_SOURCE_OPTIONS.select { |name| options[name] }
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
139
|
+
return given.first if given.size == 1
|
|
140
|
+
|
|
141
|
+
raise ConfigurationError,
|
|
142
|
+
"exactly one of #{KEY_SOURCE_OPTIONS.map(&:inspect).join(', ')} must be given" \
|
|
143
|
+
"#{" (got #{given.map(&:inspect).join(' and ')})" unless given.empty?}"
|
|
144
|
+
end
|
|
105
145
|
|
|
106
|
-
|
|
146
|
+
def build_key_source(name, options)
|
|
147
|
+
case name
|
|
107
148
|
when :public_key
|
|
108
149
|
KeySource::Static.new(options[:public_key])
|
|
109
150
|
when :public_key_url
|
|
110
151
|
KeySource::RemotePem.new(url: options[:public_key_url], **remote_options(options))
|
|
111
152
|
when :jwks_url
|
|
112
153
|
KeySource::RemoteJwks.new(url: options[:jwks_url], **remote_options(options))
|
|
154
|
+
when :shared_secret
|
|
155
|
+
KeySource::Secret.new(options[:shared_secret])
|
|
113
156
|
end
|
|
114
157
|
end
|
|
115
158
|
|
|
@@ -126,21 +169,91 @@ module RackJwtVerifier
|
|
|
126
169
|
}
|
|
127
170
|
end
|
|
128
171
|
|
|
172
|
+
# The effective algorithm list, from (in order of precedence) a
|
|
173
|
+
# decode_options :algorithm, the top-level :algorithms, a decode_options
|
|
174
|
+
# :algorithms, or the family default — then checked against the policy:
|
|
175
|
+
# never "none", and HS* only with a shared secret, never mixed with
|
|
176
|
+
# asymmetric algorithms. ruby-jwt would happily verify an RS256 token's
|
|
177
|
+
# public key as an HMAC secret otherwise.
|
|
178
|
+
def build_algorithms(source_name, options)
|
|
179
|
+
decode_options = options.fetch(:decode_options, {})
|
|
180
|
+
hmac = source_name == :shared_secret
|
|
181
|
+
|
|
182
|
+
list = decode_options[:algorithm] || options[:algorithms] || decode_options[:algorithms] ||
|
|
183
|
+
(hmac ? DEFAULT_HMAC_ALGORITHMS : DEFAULT_ASYMMETRIC_ALGORITHMS)
|
|
184
|
+
list = Array(list).map { |alg| alg.to_s.strip }.reject(&:empty?)
|
|
185
|
+
raise ConfigurationError, "algorithms must list at least one algorithm" if list.empty?
|
|
186
|
+
|
|
187
|
+
if list.any? { |alg| alg.casecmp?("none") }
|
|
188
|
+
raise ConfigurationError, '"none" is not an acceptable algorithm: it disables signature verification'
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
hmac_algs, other_algs = list.partition { |alg| HMAC_ALGORITHMS.include?(alg.upcase) }
|
|
192
|
+
if hmac
|
|
193
|
+
unless other_algs.empty?
|
|
194
|
+
raise ConfigurationError,
|
|
195
|
+
"shared_secret only works with #{HMAC_ALGORITHMS.join('/')}; " \
|
|
196
|
+
"remove #{other_algs.join(', ')} from algorithms or use a public key source"
|
|
197
|
+
end
|
|
198
|
+
check_secret_length!(hmac_algs.map(&:upcase))
|
|
199
|
+
elsif hmac_algs.any?
|
|
200
|
+
raise ConfigurationError,
|
|
201
|
+
"#{hmac_algs.join(', ')} require shared_secret; they cannot be mixed with public-key algorithms " \
|
|
202
|
+
"(a public key would be accepted as the HMAC secret)"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
list.freeze
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def check_secret_length!(hmac_algs)
|
|
209
|
+
needed = hmac_algs.map { |alg| MIN_SECRET_BYTES.fetch(alg) }.max
|
|
210
|
+
actual = @key_source.verification_key.bytesize
|
|
211
|
+
return if actual >= needed
|
|
212
|
+
|
|
213
|
+
raise ConfigurationError,
|
|
214
|
+
"shared_secret must be at least #{needed} bytes for #{hmac_algs.join('/')} (got #{actual}); " \
|
|
215
|
+
"generate one with: openssl rand -hex #{needed}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def replay_wanted?(options)
|
|
219
|
+
store = options[:replay_cache]
|
|
220
|
+
!(store.nil? || store == false)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def build_replay_guard(options, leeway)
|
|
224
|
+
return nil unless replay_wanted?(options)
|
|
225
|
+
|
|
226
|
+
store = options[:replay_cache]
|
|
227
|
+
store = options.fetch(:cache_store) { InProcessCache.new } if store == true
|
|
228
|
+
unless store.respond_to?(:read) && store.respond_to?(:write)
|
|
229
|
+
raise ConfigurationError, "replay_cache must be true or a cache store responding to #read and #write"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
ReplayGuard.new(store, leeway: leeway)
|
|
233
|
+
end
|
|
234
|
+
|
|
129
235
|
# Merge default options over any user-provided options, then:
|
|
130
|
-
# -
|
|
131
|
-
#
|
|
132
|
-
# default would otherwise silently override the user's list;
|
|
236
|
+
# - install the policy-checked algorithm list and drop any :algorithm so
|
|
237
|
+
# ruby-jwt (which consults :algorithm first) sees exactly that list;
|
|
133
238
|
# - make sure an expected claim value is actually enforced: `iss: "x"` on
|
|
134
239
|
# its own is a no-op in ruby-jwt unless `verify_iss: true` accompanies
|
|
135
|
-
# it. An explicit `verify_*: false` from the user is left untouched
|
|
136
|
-
|
|
240
|
+
# it. An explicit `verify_*: false` from the user is left untouched;
|
|
241
|
+
# - require a jti when replay protection is on.
|
|
242
|
+
def build_decode_options(user_options, replay:)
|
|
137
243
|
merged = DEFAULT_DECODE_OPTIONS.merge(user_options)
|
|
138
|
-
merged
|
|
139
|
-
merged
|
|
244
|
+
merged.delete(:algorithm)
|
|
245
|
+
merged[:algorithms] = @algorithms
|
|
140
246
|
|
|
141
247
|
CLAIM_VERIFY_FLAGS.each do |claim, flag|
|
|
142
248
|
merged[flag] = true if merged.key?(claim) && !merged.key?(flag)
|
|
143
249
|
end
|
|
250
|
+
merged[:verify_jti] = true if replay && !merged.key?(:verify_jti)
|
|
251
|
+
|
|
252
|
+
leeway = merged[:leeway]
|
|
253
|
+
unless leeway.is_a?(Numeric) && leeway >= 0
|
|
254
|
+
raise ConfigurationError, "decode_options[:leeway] must be a non-negative number of seconds"
|
|
255
|
+
end
|
|
256
|
+
|
|
144
257
|
merged
|
|
145
258
|
end
|
|
146
259
|
end
|
data/lib/rack_jwt_verifier.rb
CHANGED
|
@@ -7,10 +7,13 @@ require_relative "rack_jwt_verifier/version"
|
|
|
7
7
|
require_relative "rack_jwt_verifier/errors"
|
|
8
8
|
require_relative "rack_jwt_verifier/in_process_cache"
|
|
9
9
|
require_relative "rack_jwt_verifier/key_source"
|
|
10
|
+
require_relative "rack_jwt_verifier/replay_guard"
|
|
11
|
+
require_relative "rack_jwt_verifier/scopes"
|
|
10
12
|
require_relative "rack_jwt_verifier/verifier"
|
|
11
13
|
require_relative "rack_jwt_verifier/middleware"
|
|
12
14
|
require_relative "rack_jwt_verifier/jwt_helper"
|
|
13
15
|
|
|
14
|
-
# Namespace for the gem: Middleware, Verifier, KeySource,
|
|
16
|
+
# Namespace for the gem: Middleware, Verifier, KeySource, Scopes, ReplayGuard,
|
|
17
|
+
# InProcessCache and the deprecated JwtHelper.
|
|
15
18
|
module RackJwtVerifier
|
|
16
19
|
end
|