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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
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"
|
|
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
|
-
@
|
|
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
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
#
|
|
45
|
-
|
|
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(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
#
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
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
|