Module: Tina4::Auth
- Defined in:
- lib/tina4/auth.rb
Constant Summary collapse
- KEYS_DIR =
".keys"- BLANK_SECRET_WARNING =
Single source of truth for the blank-secret warning, emitted identically from both the CI/prod boot path (ensure_dev_secret) and the lazy per-call resolver (hmac_secret). Actionable: names exactly what to set.
"Auth: TINA4_SECRET is not set — JWT signing is insecure. Set TINA4_SECRET " \ "to a random value (e.g. `openssl rand -hex 32`) in your environment or " \ ".env before serving traffic. " \ "For LOCAL DEV, set TINA4_DEBUG=true and a per-machine secret is generated " \ "automatically into .env.local (gitignored). Seeing this warning means the " \ "run was NOT detected as dev - typically a container or CI without " \ "TINA4_DEBUG set, or TINA4_ENV=production."
- HMAC_ALGORITHMS =
Supported JWT algorithms. HMAC only — the whole family ships in OpenSSL, so this stays zero-gem. The header's "alg" is now always the one we actually sign with: the digest is looked up here rather than hardcoded to SHA256. Mirrors the Python master's _HMAC_ALGORITHMS (a name -> digest map); the value is the digest CLASS and a fresh instance is made per signature, so nothing about the digest state is shared between calls or threads.
{ "HS256" => OpenSSL::Digest::SHA256, "HS384" => OpenSSL::Digest::SHA384, "HS512" => OpenSSL::Digest::SHA512 }.freeze
- JWT_LEEWAY_SECONDS =
Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without this a token minted on one host and validated on another a second behind is rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
60
Class Method Summary collapse
- .auth_handler(&block) ⇒ Object
-
.authenticate_request(headers, secret: nil, algorithm: nil) ⇒ Object
Extract and validate auth from request headers.
-
.base64url_decode(str) ⇒ Object
Base64url-decode (handles missing padding).
-
.base64url_encode(data) ⇒ Object
Base64url-encode without padding (JWT spec).
- .bearer_auth ⇒ Object
- .check_password(password, hash) ⇒ Object
-
.default_secure_auth ⇒ Object
Default auth handler for secured routes (POST/PUT/PATCH/DELETE) Used automatically unless auth: false is passed.
-
.ensure_dev_secret(root_dir = Dir.pwd) ⇒ Object
Boot-time bootstrap (run once after env load, before auth is used).
- .get_payload(token) ⇒ Object
-
.get_token(payload, expires_in: 60, secret: nil, algorithm: nil) ⇒ Object
(also: create_token)
Mint a signed JWT.
- .hash_password(password, salt = nil, iterations = 260000) ⇒ Object
-
.hmac_decode(token, secret, algorithm: nil) ⇒ Object
Decode and verify an HMAC-signed JWT.
-
.hmac_encode(claims, secret, algorithm: nil) ⇒ Object
Build a JWT with Ruby's OpenSSL::HMAC (no gem needed).
-
.hmac_secret ⇒ Object
Lazy per-call secret resolver.
-
.hmac_signature(algorithm, secret, signing_input) ⇒ Object
HMAC the signing input with the digest the algorithm actually names, so the header's "alg" can never disagree with the bytes we produced.
- .private_key ⇒ Object
- .public_key ⇒ Object
-
.refresh_token(token, expires_in: 60) ⇒ Object
Validate and re-issue a token with the same claims.
-
.resolve_algorithm(algorithm = nil) ⇒ Object
Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else HS256.
- .setup(root_dir = Dir.pwd) ⇒ Object
-
.use_hmac? ⇒ Boolean
Returns true when SECRET env var is set and no RSA keys exist in .keys/.
-
.valid_token(token) ⇒ Object
Verify a JWT signature + expiry.
- .valid_token_detail(token) ⇒ Object (also: validate_token)
- .validate_api_key(provided, expected: nil) ⇒ Object
Class Method Details
.auth_handler(&block) ⇒ Object
380 381 382 383 384 385 386 |
# File 'lib/tina4/auth.rb', line 380 def auth_handler(&block) if block_given? @custom_handler = block else @custom_handler || method(:default_auth_handler) end end |
.authenticate_request(headers, secret: nil, algorithm: nil) ⇒ Object
Extract and validate auth from request headers.
secret: and algorithm: are real overrides. algorithm: used to be
accepted with a hardcoded "HS256" default and then dropped on the floor, so
an explicit argument silently lost to TINA4_JWT_ALGORITHM — the precedence
is now honoured here too (explicit > env > HS256).
346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 |
# File 'lib/tina4/auth.rb', line 346 def authenticate_request(headers, secret: nil, algorithm: nil) auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || "" return nil unless auth_header =~ /\ABearer\s+(.+)\z/i token = Regexp.last_match(1) # API_KEY bypass — timing-safe comparison via validate_api_key # (OpenSSL.fixed_length_secure_compare). Parity with Python's # authenticate_request (validate_api_key), PHP (hash_equals) and # Node (timingSafeEqual). Never use a plain `==` here — that leaks the # key length/prefix through comparison timing. if validate_api_key(token) return { "api_key" => true } end # If a custom secret and/or algorithm is provided, validate against those # directly rather than this process's env-resolved defaults. if secret || algorithm payload = hmac_decode(token, secret || hmac_secret, algorithm: algorithm) return payload ? payload : nil end valid_token(token) ? get_payload(token) : nil end |
.base64url_decode(str) ⇒ Object
Base64url-decode (handles missing padding)
124 125 126 127 128 129 |
# File 'lib/tina4/auth.rb', line 124 def base64url_decode(str) # Add back padding remainder = str.length % 4 str += "=" * ((4 - remainder) % 4) if remainder != 0 Base64.urlsafe_decode64(str) end |
.base64url_encode(data) ⇒ Object
Base64url-encode without padding (JWT spec)
119 120 121 |
# File 'lib/tina4/auth.rb', line 119 def base64url_encode(data) Base64.urlsafe_encode64(data, padding: false) end |
.bearer_auth ⇒ Object
388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 |
# File 'lib/tina4/auth.rb', line 388 def bearer_auth lambda do |env| auth_header = env["HTTP_AUTHORIZATION"] || "" return false unless auth_header =~ /\ABearer\s+(.+)\z/i token = Regexp.last_match(1) # API_KEY bypass — timing-safe comparison via validate_api_key # (OpenSSL.fixed_length_secure_compare). Parity with Python's # authenticate_request (validate_api_key), PHP (hash_equals) and # Node (timingSafeEqual). Never use a plain `==` here — that leaks the # key length/prefix through comparison timing. if validate_api_key(token) env["tina4.auth"] = { "api_key" => true } return true end if valid_token(token) env["tina4.auth"] = get_payload(token) true else false end end end |
.check_password(password, hash) ⇒ Object
300 301 302 303 304 305 306 307 308 309 310 311 312 |
# File 'lib/tina4/auth.rb', line 300 def check_password(password, hash) parts = hash.split('$') return false unless parts.length == 4 && parts[0] == 'pbkdf2_sha256' iterations = parts[1].to_i salt = parts[2] expected = parts[3] dk = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: 32, hash: "sha256") actual = dk.unpack1('H*') # Timing-safe comparison OpenSSL.fixed_length_secure_compare(actual, expected) rescue false end |
.default_secure_auth ⇒ Object
Default auth handler for secured routes (POST/PUT/PATCH/DELETE) Used automatically unless auth: false is passed
416 417 418 |
# File 'lib/tina4/auth.rb', line 416 def default_secure_auth @default_secure_auth ||= bearer_auth end |
.ensure_dev_secret(root_dir = Dir.pwd) ⇒ Object
Boot-time bootstrap (run once after env load, before auth is used).
Mirrors the Python master's tina4_python.auth.ensure_dev_secret:
- If TINA4_SECRET is already set → no-op (returns nil).
- Else if NOT dev, OR CI, OR production → emit the actionable
blank-secret warning and return nil. NEVER generates or persists a
secret in CI or production. (Hard security constraint.)
- Else (dev, not CI, not prod, blank secret) → mint a 32-byte
(64 hex char) random secret, set it in the process env immediately,
then APPEND it to <root_dir>/.env.local (gitignored, created if
missing). On ANY write failure keep the in-memory secret and warn —
never raise (boot must not crash).
root_dir exists only so tests can target a temp dir without chdir;
production callers pass nothing (defaults to Dir.pwd).
Returns the generated secret (String) when it mints one, else nil.
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/tina4/auth.rb', line 65 def ensure_dev_secret(root_dir = Dir.pwd) existing = ENV["TINA4_SECRET"] return nil if existing && !existing.empty? unless dev? && !ci? && !production? warn_blank_secret return nil end new_secret = SecureRandom.hex(32) # 32 bytes -> 64 hex chars ENV["TINA4_SECRET"] = new_secret # available for this run immediately begin local_path = File.join(root_dir, ".env.local") # If the file exists and does not end in a newline, prepend one so the # new key lands on its own line rather than gluing onto the last value. prefix = "" if File.exist?(local_path) content = File.read(local_path) prefix = "\n" if !content.empty? && !content.end_with?("\n") end File.open(local_path, "a") { |f| f.write("#{prefix}TINA4_SECRET=#{new_secret}\n") } log_info("Auth: generated a development secret, saved to .env.local (gitignored)") rescue StandardError => e # Keep the in-memory secret for this run; just warn. Never crash boot. log_warning("Auth: generated a development secret but could not write .env.local (#{e.}); using it for this run only") end new_secret end |
.get_payload(token) ⇒ Object
315 316 317 318 319 320 321 322 323 |
# File 'lib/tina4/auth.rb', line 315 def get_payload(token) parts = token.split(".") return nil unless parts.length == 3 payload_json = base64url_decode(parts[1]) JSON.parse(payload_json) rescue ArgumentError, JSON::ParserError nil end |
.get_token(payload, expires_in: 60, secret: nil, algorithm: nil) ⇒ Object Also known as: create_token
Mint a signed JWT.
algorithm: selects the HMAC algorithm (else TINA4_JWT_ALGORITHM, else
HS256); an unsupported one raises ArgumentError rather than quietly
downgrading. It applies to the HMAC path only — the legacy RS256 path
(RSA keys present in .keys/) is unaffected.
BREAKING (deliberate): no "nbf" claim is stamped. It duplicated "iat", added no security, and created clock-skew rejections; RFC 7519 nbf is for deliberately post-dated tokens, which stays fully supported when the caller passes its own "nbf" in the payload. Python/PHP/Node never auto-stamped it, so Ruby doing so was the parity break.
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/tina4/auth.rb', line 234 def get_token(payload, expires_in: 60, secret: nil, algorithm: nil) now = Time.now.to_i claims = payload.merge( "iat" => now, "exp" => now + (expires_in * 60).to_i ) if secret hmac_encode(claims, secret, algorithm: algorithm) elsif use_hmac? hmac_encode(claims, hmac_secret, algorithm: algorithm) else ensure_keys require "jwt" JWT.encode(claims, private_key, "RS256") end end |
.hash_password(password, salt = nil, iterations = 260000) ⇒ Object
294 295 296 297 298 |
# File 'lib/tina4/auth.rb', line 294 def hash_password(password, salt = nil, iterations = 260000) salt ||= SecureRandom.hex(16) dk = OpenSSL::KDF.pbkdf2_hmac(password, salt: salt, iterations: iterations, length: 32, hash: "sha256") "pbkdf2_sha256$#{iterations}$#{salt}$#{dk.unpack1('H*')}" end |
.hmac_decode(token, secret, algorithm: nil) ⇒ Object
Decode and verify an HMAC-signed JWT. Returns the payload hash or nil.
The algorithm is PINNED to our configured one rather than trusted from the token: a header asking to be verified as anything else — "none", a different HMAC, or an RSA alg we do not implement here — is rejected before any signature work.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/tina4/auth.rb', line 179 def hmac_decode(token, secret, algorithm: nil) # Resolved OUTSIDE the rescue below: an unsupported algorithm is a # configuration error that must surface, not become a nil "invalid token". alg = resolve_algorithm(algorithm) begin parts = token.split(".") return nil unless parts.length == 3 header_json = base64url_decode(parts[0]) header = JSON.parse(header_json) return nil unless header["alg"] == alg # Verify signature signing_input = "#{parts[0]}.#{parts[1]}" expected_sig = hmac_signature(alg, secret, signing_input) actual_sig = base64url_decode(parts[2]) # Constant-time comparison to prevent timing attacks. Lengths must match # first — fixed_length_secure_compare raises on a length mismatch, which # a forged signature of the wrong digest size would trigger. return nil unless expected_sig.bytesize == actual_sig.bytesize return nil unless OpenSSL.fixed_length_secure_compare(expected_sig, actual_sig) payload = JSON.parse(base64url_decode(parts[1])) # Check expiry now = Time.now.to_i return nil if payload["exp"] && now >= payload["exp"] # "nbf" (not-before): a post-dated token is not valid yet. Tolerate # JWT_LEEWAY_SECONDS of clock skew so a token minted on a host a second # ahead is not rejected for nothing. return nil if payload["nbf"] && now + JWT_LEEWAY_SECONDS < payload["nbf"] payload rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError nil end end |
.hmac_encode(claims, secret, algorithm: nil) ⇒ Object
Build a JWT with Ruby's OpenSSL::HMAC (no gem needed). The algorithm is the explicit argument, else TINA4_JWT_ALGORITHM, else HS256 — and the header advertises exactly the algorithm that signed.
161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/tina4/auth.rb', line 161 def hmac_encode(claims, secret, algorithm: nil) alg = resolve_algorithm(algorithm) header = { "alg" => alg, "typ" => "JWT" } segments = [ base64url_encode(JSON.generate(header)), base64url_encode(JSON.generate(claims)) ] signing_input = segments.join(".") segments << base64url_encode(hmac_signature(alg, secret, signing_input)) segments.join(".") end |
.hmac_secret ⇒ Object
Lazy per-call secret resolver. When the secret is blank, emit the actionable blank-secret warning (the same text the CI/prod bootstrap path uses) before returning. Parity with Python's _resolve_secret.
112 113 114 115 116 |
# File 'lib/tina4/auth.rb', line 112 def hmac_secret secret = ENV["TINA4_SECRET"] warn_blank_secret if secret.nil? || secret.empty? secret end |
.hmac_signature(algorithm, secret, signing_input) ⇒ Object
HMAC the signing input with the digest the algorithm actually names, so the header's "alg" can never disagree with the bytes we produced.
154 155 156 |
# File 'lib/tina4/auth.rb', line 154 def hmac_signature(algorithm, secret, signing_input) OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input) end |
.private_key ⇒ Object
424 425 426 |
# File 'lib/tina4/auth.rb', line 424 def private_key @private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path)) end |
.public_key ⇒ Object
428 429 430 |
# File 'lib/tina4/auth.rb', line 428 def public_key @public_key ||= OpenSSL::PKey::RSA.new(File.read(public_key_path)) end |
.refresh_token(token, expires_in: 60) ⇒ Object
Validate and re-issue a token with the same claims.
Only "iat"/"exp" are dropped (they are re-stamped). A caller-supplied "nbf" is PRESERVED — matching the Python master, and keeping the promise made when auto-nbf was removed: a deliberately post-dated claim is the issuer's, and a refresh must not quietly erase it.
331 332 333 334 335 336 337 338 |
# File 'lib/tina4/auth.rb', line 331 def refresh_token(token, expires_in: 60) return nil unless valid_token(token) payload = get_payload(token) return nil unless payload payload = payload.reject { |k, _| %w[iat exp].include?(k) } get_token(payload, expires_in: expires_in) end |
.resolve_algorithm(algorithm = nil) ⇒ Object
Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else HS256. A blank value counts as unset (parity with Python, where an empty env string is falsy and falls through).
Raises ArgumentError naming the supported set when asked for one we cannot sign — Ruby's idiomatic equivalent of the master's ValueError. The env var was registered in the CLI's known_vars and then silently ignored here, so a user could set HS512 and still get HS256 tokens.
139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/tina4/auth.rb', line 139 def resolve_algorithm(algorithm = nil) candidate = [algorithm, ENV["TINA4_JWT_ALGORITHM"]] .find { |value| !value.nil? && !value.to_s.strip.empty? } chosen = (candidate || "HS256").to_s.strip unless HMAC_ALGORITHMS.key?(chosen) raise ArgumentError, "Unsupported JWT algorithm #{chosen.inspect}. Tina4 signs with " \ "#{HMAC_ALGORITHMS.keys.sort.join(', ')} (HMAC only, zero-dependency). " \ "Set TINA4_JWT_ALGORITHM to one of those." end chosen end |
.setup(root_dir = Dir.pwd) ⇒ Object
42 43 44 45 46 |
# File 'lib/tina4/auth.rb', line 42 def setup(root_dir = Dir.pwd) @keys_dir = File.join(root_dir, KEYS_DIR) FileUtils.mkdir_p(@keys_dir) ensure_keys end |
.use_hmac? ⇒ Boolean
Returns true when SECRET env var is set and no RSA keys exist in .keys/
99 100 101 102 103 104 105 106 107 |
# File 'lib/tina4/auth.rb', line 99 def use_hmac? secret = ENV["TINA4_SECRET"] return false if secret.nil? || secret.empty? # If RSA keys already exist on disk, prefer RS256 for backward compat @keys_dir ||= File.join(Dir.pwd, KEYS_DIR) !(File.exist?(File.join(@keys_dir, "private.pem")) && File.exist?(File.join(@keys_dir, "public.pem"))) end |
.valid_token(token) ⇒ Object
Verify a JWT signature + expiry.
3.13.0: return type changed from Boolean to Hash | nil. The
decoded payload is returned on success, nil on failure. Matches
firebase/jwt-ruby and Python's Auth.valid_token in 3.13.0.
Legacy if Tina4::Auth.valid_token(t) patterns keep working
because a non-empty Hash is truthy and nil is falsy.
261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/tina4/auth.rb', line 261 def valid_token(token) if use_hmac? hmac_decode(token, hmac_secret) # returns Hash payload or nil else ensure_keys require "jwt" decoded = JWT.decode(token, public_key, true, algorithm: "RS256") decoded[0] # firebase/jwt-ruby returns [payload, header] end rescue JWT::ExpiredSignature, JWT::DecodeError nil end |
.valid_token_detail(token) ⇒ Object Also known as: validate_token
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 |
# File 'lib/tina4/auth.rb', line 274 def valid_token_detail(token) if use_hmac? payload = hmac_decode(token, hmac_secret) if payload { valid: true, payload: payload } else { valid: false, error: "Invalid or expired token" } end else ensure_keys require "jwt" decoded = JWT.decode(token, public_key, true, algorithm: "RS256") { valid: true, payload: decoded[0] } end rescue JWT::ExpiredSignature { valid: false, error: "Token expired" } rescue JWT::DecodeError => e { valid: false, error: e. } end |
.validate_api_key(provided, expected: nil) ⇒ Object
371 372 373 374 375 376 377 378 |
# File 'lib/tina4/auth.rb', line 371 def validate_api_key(provided, expected: nil) expected ||= ENV["TINA4_API_KEY"] return false if expected.nil? || expected.empty? return false if provided.nil? || provided.empty? return false if provided.length != expected.length OpenSSL.fixed_length_secure_compare(provided, expected) end |