tina4ruby 3.13.90 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bcacb51cc9aec287d69bb3d18d0d5736d4e72fd9f25e4b28145af7a8c3e69245
4
- data.tar.gz: 5cbe29c5b764594b014c386ab1df06e93d67cc8ad79ee321a2d5c03c5383cc73
3
+ metadata.gz: '099963e70978ad017383430b404b9cd1a2ff28f0563ca431f1e4adeec9b063fe'
4
+ data.tar.gz: 190a787d77c3e73a4e48177c865f0e2bdb5c529c4483781ca59e2cdfe4303efa
5
5
  SHA512:
6
- metadata.gz: b01320730d284fb8f8babdefdcd6c26fc3a4ae29e5ee3a2f700320276a1d211d5d3e88fc0c8de4e1722af814a60333a4056041428fd387ec70446b50f066a208
7
- data.tar.gz: 32077d83e0b6c457656915818683193f8a4ef9b3dfd505e3035d97f7b835b979d29a536f6993aa85d987138e9fe1aa229d1d002fec769879fb0c4ca1dbaef2fa
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/metrics.rb CHANGED
@@ -254,7 +254,7 @@ module Tina4
254
254
  end
255
255
 
256
256
  lines = source.lines.map(&:chomp)
257
- loc = lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
257
+ loc = lines.count { |l| _code_line?(l) }
258
258
 
259
259
  # Extract imports (require/require_relative)
260
260
  imports = _extract_imports(lines)
@@ -509,7 +509,7 @@ module Tina4
509
509
  end
510
510
 
511
511
  lines = source.lines.map(&:chomp)
512
- loc = lines.count { |l| !l.strip.empty? && !l.strip.start_with?('#') }
512
+ loc = lines.count { |l| _code_line?(l) }
513
513
 
514
514
  functions = _extract_functions(source, tokens, lines)
515
515
  functions.sort_by! { |f| -f["complexity"] }
@@ -763,6 +763,17 @@ module Tina4
763
763
  buffers
764
764
  end
765
765
 
766
+ # True for a line that counts toward LOC: not blank, not a comment.
767
+ #
768
+ # The single definition of the rule. Method LOC used to ignore it and return a
769
+ # raw line span while file LOC excluded blanks and comments, so `loc` meant
770
+ # two different things in one payload - the dashboard sized bubbles in one
771
+ # unit and printed the method table in the other.
772
+ def self._code_line?(line)
773
+ stripped = line.strip
774
+ !stripped.empty? && !stripped.start_with?("#")
775
+ end
776
+
766
777
  def self._extract_functions(source, _tokens, _lines)
767
778
  functions = []
768
779
  # Operate on a neutralised copy: string/regex/comment CONTENT is blanked
@@ -811,7 +822,9 @@ module Tina4
811
822
  # Find method end and calculate LOC
812
823
  method_start = i
813
824
  method_end = _find_method_end(lines, i)
814
- method_loc = method_end - method_start + 1
825
+ # Code lines over the method's span, by the same rule as file LOC.
826
+ # Floor of 1: a one-line body must never report 0.
827
+ method_loc = [1, lines[method_start..method_end].count { |l| _code_line?(l) }].max
815
828
 
816
829
  # Calculate complexity for this method's body
817
830
  method_lines = lines[method_start..method_end]
@@ -842,6 +855,51 @@ module Tina4
842
855
  i += 1
843
856
  end
844
857
 
858
+ _charge_nested_complexity_to_the_nested_function(functions)
859
+ end
860
+
861
+ # Stop a function being charged for the complexity of the functions nested
862
+ # inside it.
863
+ #
864
+ # Each function's raw score is measured over its whole span, so a branch
865
+ # inside a nested function landed on BOTH that function and every function
866
+ # enclosing it. The over-count compounded with depth: a wrapper around twenty
867
+ # inner handlers absorbed the entire file's complexity and topped the
868
+ # offenders list, hiding the genuine hot spots.
869
+ #
870
+ # The correction is exact. A raw score is 1 + every decision in the span, so
871
+ # (raw - 1) is the total decision count of a function's whole subtree.
872
+ # Subtracting that for each DIRECT child leaves the function's own branches:
873
+ #
874
+ # own(F) = raw(F) - sum over direct children C of (raw(C) - 1)
875
+ #
876
+ # Blocks and lambdas are deliberately unaffected: they are not reported as
877
+ # functions of their own, so nothing subtracts them and their decisions stay
878
+ # with the method that contains them - moved, never lost.
879
+ def self._charge_nested_complexity_to_the_nested_function(functions)
880
+ return functions if functions.length < 2
881
+
882
+ last_line = ->(f) { f["line"] + [1, f["loc"]].max - 1 }
883
+ contains = ->(outer, inner) do
884
+ inner["line"] > outer["line"] && last_line.call(inner) <= last_line.call(outer)
885
+ end
886
+
887
+ raw = functions.map { |f| f["complexity"] }
888
+ functions.each_with_index do |outer, i|
889
+ subtract = 0
890
+ functions.each_with_index do |inner, j|
891
+ next if i == j || !contains.call(outer, inner)
892
+
893
+ # Direct child only: skip it if another function sits between the two,
894
+ # or its complexity would be subtracted twice.
895
+ nested_deeper = functions.each_with_index.any? do |mid, k|
896
+ k != i && k != j && contains.call(outer, mid) && contains.call(mid, inner)
897
+ end
898
+ subtract += raw[j] - 1 unless nested_deeper
899
+ end
900
+ outer["complexity"] = [1, raw[i] - subtract].max
901
+ end
902
+
845
903
  functions
846
904
  end
847
905