tina4ruby 3.13.91 → 3.13.93
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/README.md +1 -1
- data/lib/tina4/auth.rb +129 -41
- data/lib/tina4/cli.rb +14 -7
- data/lib/tina4/health.rb +10 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +16 -0
- 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: f7b916f1f99717b6933e8d4376b760d2c975daefec3b5507d9827480522cfe8f
|
|
4
|
+
data.tar.gz: e800d0a184690267eb84976193744593eea98b2ec355405f15a32214d12e31a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6428126904b059c914f08ca0fc446622a07447075c23e4a20ba12b01f93cf6a5c48454cab6cebfdc98da615f9fbb9ba57d4e3021d6b26d688cb92cdf5a553cb3
|
|
7
|
+
data.tar.gz: 7fb13ab4b8cae5c60ad932ed1a9de7750881381451b294747b6f90441b682dd5658f49394fd29707144fe2e4ae95a5639cb47cc50a6a1a87aae6b4d60eab0e29
|
data/README.md
CHANGED
|
@@ -105,7 +105,7 @@ Tina4 Ruby outperforms Sinatra while delivering **98 features vs ~4**, with zero
|
|
|
105
105
|
|
|
106
106
|
## Cross-Framework Parity
|
|
107
107
|
|
|
108
|
-
Tina4 ships identical features across four languages: same architecture, same conventions, same
|
|
108
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 98 features:
|
|
109
109
|
|
|
110
110
|
| | Python | PHP | Ruby | Node.js |
|
|
111
111
|
|---|--------|-----|------|---------|
|
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/cli.rb
CHANGED
|
@@ -467,12 +467,21 @@ module Tina4
|
|
|
467
467
|
# ── start ─────────────────────────────────────────────────────────────
|
|
468
468
|
|
|
469
469
|
def cmd_start(argv)
|
|
470
|
-
options = { port: nil, host: nil, dev: false, no_browser: false, no_reload: false, production: false
|
|
470
|
+
options = { port: nil, host: nil, dev: false, no_browser: false, no_reload: false, production: false,
|
|
471
|
+
managed: false }
|
|
471
472
|
parser = OptionParser.new do |opts|
|
|
472
473
|
opts.banner = "Usage: tina4ruby start [options]"
|
|
473
474
|
opts.on("-p", "--port PORT", Integer, "Port (default: 7147)") { |v| options[:port] = v }
|
|
474
475
|
opts.on("-h", "--host HOST", "Host (default: 0.0.0.0)") { |v| options[:host] = v }
|
|
475
476
|
opts.on("-d", "--dev", "Enable dev mode with auto-reload") { options[:dev] = true }
|
|
477
|
+
# --managed says the Rust CLI owns this process (it supervises, watches
|
|
478
|
+
# files, and compiles SCSS, so the framework must not duplicate any of
|
|
479
|
+
# it). "managed" was already declared in boolean_flags above, but this
|
|
480
|
+
# parser never accepted it, so `tina4ruby serve --managed` died with
|
|
481
|
+
# OptionParser::InvalidOption -- which is precisely how the Rust CLI
|
|
482
|
+
# invokes PHP. That mismatch is why Ruby could not be launched through
|
|
483
|
+
# the shared launcher the other frameworks use.
|
|
484
|
+
opts.on("--managed", "Running under the tina4 CLI supervisor") { options[:managed] = true }
|
|
476
485
|
opts.on("--production", "Use production server (Puma)") { options[:production] = true }
|
|
477
486
|
opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
|
|
478
487
|
opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
|
|
@@ -502,12 +511,10 @@ module Tina4
|
|
|
502
511
|
root_dir = Dir.pwd
|
|
503
512
|
Tina4.initialize!(root_dir)
|
|
504
513
|
|
|
505
|
-
#
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
# (GET /__frond/live/{name}) so server-rendered live blocks can poll/SSE.
|
|
510
|
-
Tina4::Frond.register_live_endpoint!
|
|
514
|
+
# Built-in routes (health, Frond live). Shared with Tina4.run! so the two
|
|
515
|
+
# entry points cannot drift again -- they did, and app.rb served 404 on
|
|
516
|
+
# /health for it. register! is idempotent, so calling it here is safe.
|
|
517
|
+
Tina4.register_builtin_routes!
|
|
511
518
|
|
|
512
519
|
# Load route files
|
|
513
520
|
load_routes(root_dir)
|
data/lib/tina4/health.rb
CHANGED
|
@@ -16,12 +16,22 @@ module Tina4
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def register!
|
|
19
|
+
# Idempotent by ASKING THE ROUTER, not by remembering. run! and the CLI
|
|
20
|
+
# both call this, and it can run more than once per process. A boolean
|
|
21
|
+
# flag looked equivalent and was not: Router.clear! (every spec, and
|
|
22
|
+
# any re-init) wipes the routes while the flag stays true, so /health
|
|
23
|
+
# silently vanishes for the rest of the run. Querying the router is
|
|
24
|
+
# self-healing -- cleared routes re-register, present ones don't
|
|
25
|
+
# duplicate.
|
|
26
|
+
return if Tina4::Router.find_route("/health", "GET")
|
|
27
|
+
|
|
19
28
|
# Register at the configured path. The legacy "/health" path stays
|
|
20
29
|
# registered for backward-compat.
|
|
21
30
|
Tina4::Router.add("GET", path, method(:handle))
|
|
22
31
|
Tina4::Router.add("GET", "/health", method(:handle)) unless path == "/health"
|
|
23
32
|
end
|
|
24
33
|
|
|
34
|
+
|
|
25
35
|
def handle(_request, response)
|
|
26
36
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
27
37
|
uptime = (now - START_TIME).round(2)
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
|
@@ -309,6 +309,14 @@ module Tina4
|
|
|
309
309
|
end
|
|
310
310
|
end
|
|
311
311
|
|
|
312
|
+
# The framework's own routes, in ONE place. Both entry points call this:
|
|
313
|
+
# Tina4.run! (app.rb) and the CLI's cmd_start. Anything added here is
|
|
314
|
+
# available however the app was launched -- which is the whole point.
|
|
315
|
+
def register_builtin_routes!
|
|
316
|
+
Tina4::Health.register!
|
|
317
|
+
Tina4::Frond.register_live_endpoint!
|
|
318
|
+
end
|
|
319
|
+
|
|
312
320
|
def run!(root_dir = nil, port: nil, host: nil, debug: nil)
|
|
313
321
|
# Handle legacy call: run!(port: 7147) where root_dir receives the hash
|
|
314
322
|
if root_dir.is_a?(Hash)
|
|
@@ -325,6 +333,14 @@ module Tina4
|
|
|
325
333
|
|
|
326
334
|
initialize!(root_dir) unless @root_dir
|
|
327
335
|
|
|
336
|
+
# Built-in routes. These used to be registered ONLY by the CLI's
|
|
337
|
+
# cmd_start, so an app booted the documented way -- `Tina4.run!` from
|
|
338
|
+
# app.rb, which is exactly what `tina4 init ruby` scaffolds -- served 404
|
|
339
|
+
# on /health while the identical code under `tina4ruby serve` served 200.
|
|
340
|
+
# A container health check pointed at /health therefore failed against a
|
|
341
|
+
# perfectly healthy app.
|
|
342
|
+
register_builtin_routes!
|
|
343
|
+
|
|
328
344
|
host = ENV.fetch("HOST", ENV.fetch("TINA4_HOST", "0.0.0.0"))
|
|
329
345
|
port = ENV.fetch("PORT", ENV.fetch("TINA4_PORT", "7147")).to_i
|
|
330
346
|
|