tina4ruby 3.13.91 → 3.13.92
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/lib/tina4/auth.rb +129 -41
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '099963e70978ad017383430b404b9cd1a2ff28f0563ca431f1e4adeec9b063fe'
|
|
4
|
+
data.tar.gz: 190a787d77c3e73a4e48177c865f0e2bdb5c529c4483781ca59e2cdfe4303efa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 86ab09867ede9d7587e600d0f98eb333ad767bd392ecd8c5251a5672e5957073d35cace49cee8507a900e568ee6d98bee85b1294d5b5031498e6f41379978481
|
|
7
|
+
data.tar.gz: 7b42923a37f18d3a96821c0e19144524809d1c631920349ac5bcafcfea49944eea25fce473de20f5b2b19b05c3d1d25291a18b7ee0c7695e4096ab77419043aa
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -21,6 +21,23 @@ module Tina4
|
|
|
21
21
|
"run was NOT detected as dev - typically a container or CI without " \
|
|
22
22
|
"TINA4_DEBUG set, or TINA4_ENV=production."
|
|
23
23
|
|
|
24
|
+
# Supported JWT algorithms. HMAC only — the whole family ships in OpenSSL, so
|
|
25
|
+
# this stays zero-gem. The header's "alg" is now always the one we actually
|
|
26
|
+
# sign with: the digest is looked up here rather than hardcoded to SHA256.
|
|
27
|
+
# Mirrors the Python master's _HMAC_ALGORITHMS (a name -> digest map); the
|
|
28
|
+
# value is the digest CLASS and a fresh instance is made per signature, so
|
|
29
|
+
# nothing about the digest state is shared between calls or threads.
|
|
30
|
+
HMAC_ALGORITHMS = {
|
|
31
|
+
"HS256" => OpenSSL::Digest::SHA256,
|
|
32
|
+
"HS384" => OpenSSL::Digest::SHA384,
|
|
33
|
+
"HS512" => OpenSSL::Digest::SHA512
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without
|
|
37
|
+
# this a token minted on one host and validated on another a second behind is
|
|
38
|
+
# rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
|
|
39
|
+
JWT_LEEWAY_SECONDS = 60
|
|
40
|
+
|
|
24
41
|
class << self
|
|
25
42
|
def setup(root_dir = Dir.pwd)
|
|
26
43
|
@keys_dir = File.join(root_dir, KEYS_DIR)
|
|
@@ -111,62 +128,120 @@ module Tina4
|
|
|
111
128
|
Base64.urlsafe_decode64(str)
|
|
112
129
|
end
|
|
113
130
|
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
131
|
+
# Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else
|
|
132
|
+
# HS256. A blank value counts as unset (parity with Python, where an empty
|
|
133
|
+
# env string is falsy and falls through).
|
|
134
|
+
#
|
|
135
|
+
# Raises ArgumentError naming the supported set when asked for one we cannot
|
|
136
|
+
# sign — Ruby's idiomatic equivalent of the master's ValueError. The env var
|
|
137
|
+
# was registered in the CLI's known_vars and then silently ignored here, so a
|
|
138
|
+
# user could set HS512 and still get HS256 tokens.
|
|
139
|
+
def resolve_algorithm(algorithm = nil)
|
|
140
|
+
candidate = [algorithm, ENV["TINA4_JWT_ALGORITHM"]]
|
|
141
|
+
.find { |value| !value.nil? && !value.to_s.strip.empty? }
|
|
142
|
+
chosen = (candidate || "HS256").to_s.strip
|
|
143
|
+
unless HMAC_ALGORITHMS.key?(chosen)
|
|
144
|
+
raise ArgumentError,
|
|
145
|
+
"Unsupported JWT algorithm #{chosen.inspect}. Tina4 signs with " \
|
|
146
|
+
"#{HMAC_ALGORITHMS.keys.sort.join(', ')} (HMAC only, zero-dependency). " \
|
|
147
|
+
"Set TINA4_JWT_ALGORITHM to one of those."
|
|
148
|
+
end
|
|
149
|
+
chosen
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# HMAC the signing input with the digest the algorithm actually names, so the
|
|
153
|
+
# header's "alg" can never disagree with the bytes we produced.
|
|
154
|
+
def hmac_signature(algorithm, secret, signing_input)
|
|
155
|
+
OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Build a JWT with Ruby's OpenSSL::HMAC (no gem needed). The algorithm is the
|
|
159
|
+
# explicit argument, else TINA4_JWT_ALGORITHM, else HS256 — and the header
|
|
160
|
+
# advertises exactly the algorithm that signed.
|
|
161
|
+
def hmac_encode(claims, secret, algorithm: nil)
|
|
162
|
+
alg = resolve_algorithm(algorithm)
|
|
163
|
+
header = { "alg" => alg, "typ" => "JWT" }
|
|
117
164
|
segments = [
|
|
118
165
|
base64url_encode(JSON.generate(header)),
|
|
119
166
|
base64url_encode(JSON.generate(claims))
|
|
120
167
|
]
|
|
121
168
|
signing_input = segments.join(".")
|
|
122
|
-
|
|
123
|
-
segments << base64url_encode(signature)
|
|
169
|
+
segments << base64url_encode(hmac_signature(alg, secret, signing_input))
|
|
124
170
|
segments.join(".")
|
|
125
171
|
end
|
|
126
172
|
|
|
127
|
-
# Decode and verify
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
signing_input = "#{parts[0]}.#{parts[1]}"
|
|
138
|
-
expected_sig = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
|
|
139
|
-
actual_sig = base64url_decode(parts[2])
|
|
140
|
-
|
|
141
|
-
# Constant-time comparison to prevent timing attacks
|
|
142
|
-
return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
|
|
143
|
-
|
|
144
|
-
payload = JSON.parse(base64url_decode(parts[1]))
|
|
145
|
-
|
|
146
|
-
# Check expiry
|
|
147
|
-
now = Time.now.to_i
|
|
148
|
-
return nil if payload["exp"] && now >= payload["exp"]
|
|
149
|
-
return nil if payload["nbf"] && now < payload["nbf"]
|
|
173
|
+
# Decode and verify an HMAC-signed JWT. Returns the payload hash or nil.
|
|
174
|
+
#
|
|
175
|
+
# The algorithm is PINNED to our configured one rather than trusted from the
|
|
176
|
+
# token: a header asking to be verified as anything else — "none", a
|
|
177
|
+
# different HMAC, or an RSA alg we do not implement here — is rejected before
|
|
178
|
+
# any signature work.
|
|
179
|
+
def hmac_decode(token, secret, algorithm: nil)
|
|
180
|
+
# Resolved OUTSIDE the rescue below: an unsupported algorithm is a
|
|
181
|
+
# configuration error that must surface, not become a nil "invalid token".
|
|
182
|
+
alg = resolve_algorithm(algorithm)
|
|
150
183
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
184
|
+
begin
|
|
185
|
+
parts = token.split(".")
|
|
186
|
+
return nil unless parts.length == 3
|
|
187
|
+
|
|
188
|
+
header_json = base64url_decode(parts[0])
|
|
189
|
+
header = JSON.parse(header_json)
|
|
190
|
+
return nil unless header["alg"] == alg
|
|
191
|
+
|
|
192
|
+
# Verify signature
|
|
193
|
+
signing_input = "#{parts[0]}.#{parts[1]}"
|
|
194
|
+
expected_sig = hmac_signature(alg, secret, signing_input)
|
|
195
|
+
actual_sig = base64url_decode(parts[2])
|
|
196
|
+
|
|
197
|
+
# Constant-time comparison to prevent timing attacks. Lengths must match
|
|
198
|
+
# first — fixed_length_secure_compare raises on a length mismatch, which
|
|
199
|
+
# a forged signature of the wrong digest size would trigger.
|
|
200
|
+
return nil unless expected_sig.bytesize == actual_sig.bytesize
|
|
201
|
+
return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig)
|
|
202
|
+
|
|
203
|
+
payload = JSON.parse(base64url_decode(parts[1]))
|
|
204
|
+
|
|
205
|
+
# Check expiry
|
|
206
|
+
now = Time.now.to_i
|
|
207
|
+
return nil if payload["exp"] && now >= payload["exp"]
|
|
208
|
+
|
|
209
|
+
# "nbf" (not-before): a post-dated token is not valid yet. Tolerate
|
|
210
|
+
# JWT_LEEWAY_SECONDS of clock skew so a token minted on a host a second
|
|
211
|
+
# ahead is not rejected for nothing.
|
|
212
|
+
return nil if payload["nbf"] && now + JWT_LEEWAY_SECONDS < payload["nbf"]
|
|
213
|
+
|
|
214
|
+
payload
|
|
215
|
+
rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
154
218
|
end
|
|
155
219
|
|
|
156
220
|
# ── Token API (auto-selects HS256 or RS256) ─────────────────
|
|
157
221
|
|
|
158
|
-
|
|
222
|
+
# Mint a signed JWT.
|
|
223
|
+
#
|
|
224
|
+
# `algorithm:` selects the HMAC algorithm (else TINA4_JWT_ALGORITHM, else
|
|
225
|
+
# HS256); an unsupported one raises ArgumentError rather than quietly
|
|
226
|
+
# downgrading. It applies to the HMAC path only — the legacy RS256 path
|
|
227
|
+
# (RSA keys present in .keys/) is unaffected.
|
|
228
|
+
#
|
|
229
|
+
# BREAKING (deliberate): no "nbf" claim is stamped. It duplicated "iat",
|
|
230
|
+
# added no security, and created clock-skew rejections; RFC 7519 nbf is for
|
|
231
|
+
# deliberately post-dated tokens, which stays fully supported when the caller
|
|
232
|
+
# passes its own "nbf" in the payload. Python/PHP/Node never auto-stamped it,
|
|
233
|
+
# so Ruby doing so was the parity break.
|
|
234
|
+
def get_token(payload, expires_in: 60, secret: nil, algorithm: nil)
|
|
159
235
|
now = Time.now.to_i
|
|
160
236
|
claims = payload.merge(
|
|
161
237
|
"iat" => now,
|
|
162
|
-
"exp" => now + (expires_in * 60).to_i
|
|
163
|
-
"nbf" => now
|
|
238
|
+
"exp" => now + (expires_in * 60).to_i
|
|
164
239
|
)
|
|
165
240
|
|
|
166
241
|
if secret
|
|
167
|
-
hmac_encode(claims, secret)
|
|
242
|
+
hmac_encode(claims, secret, algorithm: algorithm)
|
|
168
243
|
elsif use_hmac?
|
|
169
|
-
hmac_encode(claims, hmac_secret)
|
|
244
|
+
hmac_encode(claims, hmac_secret, algorithm: algorithm)
|
|
170
245
|
else
|
|
171
246
|
ensure_keys
|
|
172
247
|
require "jwt"
|
|
@@ -247,16 +322,28 @@ module Tina4
|
|
|
247
322
|
nil
|
|
248
323
|
end
|
|
249
324
|
|
|
325
|
+
# Validate and re-issue a token with the same claims.
|
|
326
|
+
#
|
|
327
|
+
# Only "iat"/"exp" are dropped (they are re-stamped). A caller-supplied "nbf"
|
|
328
|
+
# is PRESERVED — matching the Python master, and keeping the promise made
|
|
329
|
+
# when auto-nbf was removed: a deliberately post-dated claim is the issuer's,
|
|
330
|
+
# and a refresh must not quietly erase it.
|
|
250
331
|
def refresh_token(token, expires_in: 60)
|
|
251
332
|
return nil unless valid_token(token)
|
|
252
333
|
|
|
253
334
|
payload = get_payload(token)
|
|
254
335
|
return nil unless payload
|
|
255
|
-
payload = payload.reject { |k, _| %w[iat exp
|
|
336
|
+
payload = payload.reject { |k, _| %w[iat exp].include?(k) }
|
|
256
337
|
get_token(payload, expires_in: expires_in)
|
|
257
338
|
end
|
|
258
339
|
|
|
259
|
-
|
|
340
|
+
# Extract and validate auth from request headers.
|
|
341
|
+
#
|
|
342
|
+
# `secret:` and `algorithm:` are real overrides. `algorithm:` used to be
|
|
343
|
+
# accepted with a hardcoded "HS256" default and then dropped on the floor, so
|
|
344
|
+
# an explicit argument silently lost to TINA4_JWT_ALGORITHM — the precedence
|
|
345
|
+
# is now honoured here too (explicit > env > HS256).
|
|
346
|
+
def authenticate_request(headers, secret: nil, algorithm: nil)
|
|
260
347
|
auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
|
|
261
348
|
return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
262
349
|
|
|
@@ -271,9 +358,10 @@ module Tina4
|
|
|
271
358
|
return { "api_key" => true }
|
|
272
359
|
end
|
|
273
360
|
|
|
274
|
-
# If a custom secret is provided, validate against
|
|
275
|
-
|
|
276
|
-
|
|
361
|
+
# If a custom secret and/or algorithm is provided, validate against those
|
|
362
|
+
# directly rather than this process's env-resolved defaults.
|
|
363
|
+
if secret || algorithm
|
|
364
|
+
payload = hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
|
|
277
365
|
return payload ? payload : nil
|
|
278
366
|
end
|
|
279
367
|
|
data/lib/tina4/version.rb
CHANGED