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.
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rack_jwt_verifier/verifier"
3
+ require "json"
4
+ require "logger"
5
+ require_relative "errors"
6
+ require_relative "scopes"
7
+ require_relative "verifier"
4
8
 
5
9
  module RackJwtVerifier
6
10
  # The primary middleware class responsible for intercepting requests,
@@ -10,61 +14,248 @@ module RackJwtVerifier
10
14
  # The default key in the Rack environment used to store the verified JWT payload.
11
15
  # This can be accessed by downstream applications (e.g., Rails controllers)
12
16
  # to retrieve the authenticated user's details.
13
- RACK_ENV_PAYLOAD_KEY = "rack_jwt_verifier.payload".freeze
17
+ RACK_ENV_PAYLOAD_KEY = "rack_jwt_verifier.payload"
14
18
 
19
+ # Seconds a client is told to wait before retrying after a 503.
20
+ RETRY_AFTER_SECONDS = 5
21
+
22
+ # Used when neither a :logger option nor env["rack.logger"] is available.
23
+ NULL_LOGGER = Logger.new(IO::NULL)
24
+
25
+ # Status and default plain-text body for each way a request can be refused.
26
+ # The reason symbol doubles as the `error` code in JSON bodies.
27
+ ERROR_RESPONSES = {
28
+ missing_token: [401, "Unauthorized: Bearer token required."],
29
+ invalid_token: [401, "Unauthorized: Invalid or expired JWT."],
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."]
33
+ }.freeze
34
+
35
+ # @param app [#call] The downstream Rack application.
36
+ # @param options [Hash] Middleware options; everything else is forwarded to Verifier.
37
+ # @option options [Boolean] :require_token Reject requests that carry no Bearer token
38
+ # (default: false, pass through).
39
+ # @option options [Logger] :logger Logger for verification failures
40
+ # (default: env["rack.logger"], else silent).
41
+ # @option options [String] :env_key Rack env key that receives the payload
42
+ # (default: RACK_ENV_PAYLOAD_KEY).
43
+ # @option options [Array<String, Regexp, #call>] :skip Paths (exact string, regexp) or
44
+ # predicates on env that bypass the middleware.
45
+ # @option options [Boolean] :json_errors Render 401/503 bodies as JSON
46
+ # `{"error", "error_description"}` (default: plain text).
47
+ # @option options [#call] :on_error `->(env, reason, exception) { rack_response or nil }`
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.
15
54
  def initialize(app, options = {})
16
55
  @app = app
17
- @options = options
18
-
56
+ @logger = options[:logger]
57
+ @env_key = options.fetch(:env_key, RACK_ENV_PAYLOAD_KEY)
58
+ @skip = validate_skip_rules(Array(options[:skip]))
59
+ @json_errors = options.fetch(:json_errors, false)
60
+ @on_error = options[:on_error]
61
+ @require_scopes = validate_required_scopes(options[:require_scopes])
62
+ @require_token = resolve_require_token(options)
63
+
19
64
  # The Verifier instance is initialized with options (like public_key_url)
20
65
  # and is responsible for all crypto and key management.
21
66
  @verifier = Verifier.new(options)
67
+
68
+ enforce_claim_policy(options)
22
69
  end
23
70
 
24
71
  def call(env)
72
+ return @app.call(env) if skip?(env)
73
+
25
74
  token = extract_token(env)
26
-
27
- # If no token is found, we immediately pass the request down the stack
28
- # and the application is responsible for handling the unauthenticated state.
29
- return @app.call(env) unless token
30
75
 
76
+ # With no token the request is passed down the stack and the application
77
+ # decides how to treat the unauthenticated state — unless require_token
78
+ # is set, in which case it is rejected here.
79
+ unless token
80
+ return error_response(env, :missing_token, nil) if @require_token
81
+
82
+ return @app.call(env)
83
+ end
84
+
85
+ # Only the verification step is guarded: a JWT::DecodeError raised by the
86
+ # downstream application must propagate, not be turned into a 401 here.
31
87
  begin
32
88
  # Use the Verifier to handle the complex crypto and validation logic
33
89
  payload = @verifier.verify(token)
34
-
35
- # On successful verification, store the payload in the Rack environment
36
- env[RACK_ENV_PAYLOAD_KEY] = payload
37
-
38
- @app.call(env)
39
90
  rescue JWT::DecodeError => e
40
- # If verification fails (invalid signature, expired, invalid claim),
41
- # log the error and return an unauthenticated response.
42
- warn "JWT Verification Failed: #{e.message}"
43
-
44
- # Return a 401 Unauthorized response
45
- unauthorized_response
91
+ # Invalid signature, expired, bad claim: the client's problem.
92
+ logger(env).warn { "rack_jwt_verifier: token rejected: #{e.message}" }
93
+ return error_response(env, :invalid_token, e)
94
+ rescue KeyFetchError => e
95
+ # We could not obtain the key to check the token: our problem, not the
96
+ # client's, so answer 503 rather than 401.
97
+ logger(env).error { "rack_jwt_verifier: #{e.message}" }
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)
46
109
  end
110
+
111
+ # On successful verification, store the payload in the Rack environment
112
+ env[@env_key] = payload
113
+
114
+ @app.call(env)
47
115
  end
48
116
 
49
117
  private
50
118
 
51
- # Extracts the JWT from the standard Authorization header format: "Bearer <token>"
119
+ # Extracts the JWT from the standard Authorization header format: "Bearer <token>".
120
+ # The scheme is matched case-insensitively (RFC 7235 §2.1).
52
121
  def extract_token(env)
53
122
  # Rack converts HTTP_AUTHORIZATION header to ENV['HTTP_AUTHORIZATION']
54
123
  auth_header = env["HTTP_AUTHORIZATION"]
55
-
124
+
56
125
  return nil unless auth_header
57
-
58
- scheme, token = auth_header.split(" ", 2)
59
-
60
- # Only process if the scheme is "Bearer" and a token is present
61
- (scheme == "Bearer") ? token : nil
126
+
127
+ scheme, token = auth_header.strip.split(/\s+/, 2)
128
+ return nil unless scheme&.casecmp?("bearer")
129
+
130
+ token = token&.strip
131
+ token.nil? || token.empty? ? nil : token
132
+ end
133
+
134
+ # A skip rule is an exact path string, a regexp matched against the path,
135
+ # or a callable given the whole env.
136
+ def skip?(env)
137
+ return false if @skip.empty?
138
+
139
+ path = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
140
+ @skip.any? do |rule|
141
+ case rule
142
+ when String then rule == path
143
+ when Regexp then rule.match?(path)
144
+ else rule.call(env)
145
+ end
146
+ end
147
+ end
148
+
149
+ def validate_skip_rules(rules)
150
+ rules.each do |rule|
151
+ next if rule.is_a?(String) || rule.is_a?(Regexp) || rule.respond_to?(:call)
152
+
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"
173
+ end
174
+
175
+ true
176
+ end
177
+
178
+ def logger(env)
179
+ @logger || env["rack.logger"] || NULL_LOGGER
180
+ end
181
+
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
196
+
197
+ (@logger || Kernel).warn(
198
+ "rack_jwt_verifier: #{missing.map(&:inspect).join(' and ')} not set in decode_options, so any " \
199
+ "token signed by the configured key is accepted. Set decode_options: { iss: ..., aud: ... }."
200
+ )
201
+ end
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
+
212
+ # Builds the refusal for `reason`, letting an :on_error hook take over
213
+ # first. Returning nil from the hook falls back to the default response.
214
+ def error_response(env, reason, error)
215
+ custom = @on_error&.call(env, reason, error)
216
+ return custom if custom
217
+
218
+ status, default_text = ERROR_RESPONSES.fetch(reason)
219
+ description = error && sanitize_description(error.message)
220
+
221
+ headers = {}
222
+ headers["www-authenticate"] = challenge(reason, description) if [401, 403].include?(status)
223
+ headers["retry-after"] = RETRY_AFTER_SECONDS.to_s if status == 503
224
+
225
+ if @json_errors
226
+ body = JSON.generate(error: reason, error_description: description || default_text)
227
+ respond(status, body, "application/json", headers)
228
+ else
229
+ respond(status, default_text, "text/plain", headers)
230
+ end
231
+ end
232
+
233
+ # RFC 6750 §3: a request that carried no credentials at all gets the bare
234
+ # challenge, without an error code; otherwise the code and a description.
235
+ # An insufficient_scope challenge also names the scopes required (§3.1).
236
+ def challenge(reason, description)
237
+ return "Bearer" if reason == :missing_token
238
+
239
+ code = reason == :insufficient_scope ? "insufficient_scope" : "invalid_token"
240
+ value = "Bearer error=\"#{code}\""
241
+ value << ", error_description=\"#{description}\"" if description && !description.empty?
242
+ value << ", scope=\"#{sanitize_description(@require_scopes.join(' '))}\"" if reason == :insufficient_scope
243
+ value
244
+ end
245
+
246
+ # error_description is a quoted-string: keep it to the characters RFC 6750
247
+ # allows inside one (printable ASCII minus `"` and `\`) and a sane length.
248
+ def sanitize_description(message)
249
+ message.to_s.gsub(/[^\x20-\x21\x23-\x5B\x5D-\x7E]/, "")[0, 200]
62
250
  end
63
251
 
64
- # Standard 401 Unauthorized Rack response
65
- def unauthorized_response
66
- # Status, Headers, Body (Array of strings)
67
- [401, { "Content-Type" => "text/plain", "WWW-Authenticate" => "Bearer error=\"invalid_token\"" }, ["Unauthorized: Invalid or expired JWT."]]
252
+ # Rack 3 requires lowercase header names; they are equally valid on Rack 2.
253
+ def respond(status, body, content_type, extra_headers = {})
254
+ headers = {
255
+ "content-type" => content_type,
256
+ "content-length" => body.bytesize.to_s
257
+ }.merge(extra_headers)
258
+ [status, headers, [body]]
68
259
  end
69
260
  end
70
261
  end
@@ -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