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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/tina4/auth.rb +129 -41
  3. data/lib/tina4/version.rb +1 -1
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3df8f30ccbf4fc6d984ec4af8fa3262045b16ac1f05c7516f6a6818691fad547
4
- data.tar.gz: 1236689bdee9821f1994bc27d1a69684fd312cc774f33a2bba6b10fa57bd86d8
3
+ metadata.gz: '099963e70978ad017383430b404b9cd1a2ff28f0563ca431f1e4adeec9b063fe'
4
+ data.tar.gz: 190a787d77c3e73a4e48177c865f0e2bdb5c529c4483781ca59e2cdfe4303efa
5
5
  SHA512:
6
- metadata.gz: c51b11ed1b2c9361c162ab6d54f999381d13b8b6be74f4cd94ebfa0d72492f0805f9428e9080ee06b2eb6efd52dc5fd72e1a9d073743d5ad03ec9582c3894b8b
7
- data.tar.gz: 4f8265ec9da1f6bd427ed844d808e4e2c67ee55ec934a0d24ea37f40b1a88861ce86bc9c09b162cd534f6d879825ed31a05e0baf32e73322ea8584097d9b8346
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
- # Build a JWT using HS256 with Ruby's OpenSSL::HMAC (no gem needed)
115
- def hmac_encode(claims, secret)
116
- header = { "alg" => "HS256", "typ" => "JWT" }
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
- signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input)
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 a JWT signed with HS256. Returns the payload hash or nil.
128
- def hmac_decode(token, secret)
129
- parts = token.split(".")
130
- return nil unless parts.length == 3
131
-
132
- header_json = base64url_decode(parts[0])
133
- header = JSON.parse(header_json)
134
- return nil unless header["alg"] == "HS256"
135
-
136
- # Verify signature
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
- payload
152
- rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError
153
- nil
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
- def get_token(payload, expires_in: 60, secret: nil)
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 nbf].include?(k) }
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
- def authenticate_request(headers, secret: nil, algorithm: "HS256")
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 it directly
275
- if secret
276
- payload = hmac_decode(token, secret)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.91"
4
+ VERSION = "3.13.92"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.91
4
+ version: 3.13.92
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team