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.
@@ -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 ArgumentError, "public_key is not a valid PEM public key or certificate: #{e.message}"
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 ArgumentError,
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 ArgumentError, "#{url_option_name} is not a valid URL: #{url.inspect}"
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) do |http|
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
- key_unavailable: [503, "Service Unavailable: could not fetch the token verification key."]
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
- warn_if_claims_unrestricted(options.fetch(:decode_options, {}))
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 ArgumentError, "skip: entries must be a String, a Regexp or respond to #call (got #{rule.inspect})"
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. Nudge
142
- # the operator once at boot if neither iss nor aud is being checked.
143
- def warn_if_claims_unrestricted(decode_options)
144
- return if decode_options.key?(:iss) || decode_options.key?(:aud)
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: neither :iss nor :aud is set in decode_options, so any " \
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 == 401
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
- value = +'Bearer error="invalid_token"'
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, or a JWKS endpoint. Handles caching,
11
- # key rotation and claim enforcement so the middleware does not have to.
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
- KEY_SOURCE_OPTIONS = %i[public_key public_key_url jwks_url].freeze
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 [Array<String>] :algorithms Accepted signing algorithms (default: ["RS256"]).
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
- @key_source = build_key_source(options)
65
- @decode_options = build_decode_options(options.fetch(:decode_options, {}), options[:algorithms])
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 build_key_source(options)
137
+ def key_source_name(options)
99
138
  given = KEY_SOURCE_OPTIONS.select { |name| options[name] }
100
- unless given.size == 1
101
- raise ArgumentError,
102
- "exactly one of #{KEY_SOURCE_OPTIONS.map(&:inspect).join(', ')} must be given" \
103
- "#{" (got #{given.map(&:inspect).join(' and ')})" unless given.empty?}"
104
- end
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
- case given.first
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
- # - apply a top-level :algorithms list, and drop our :algorithm default
131
- # whenever a list is in play — ruby-jwt consults :algorithm first, so the
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
- def build_decode_options(user_options, algorithms)
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[:algorithms] = Array(algorithms) if algorithms
139
- merged.delete(:algorithm) if merged.key?(:algorithms) && !user_options.key?(:algorithm)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RackJwtVerifier
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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, InProcessCache, JwtHelper.
16
+ # Namespace for the gem: Middleware, Verifier, KeySource, Scopes, ReplayGuard,
17
+ # InProcessCache and the deprecated JwtHelper.
15
18
  module RackJwtVerifier
16
19
  end