Module: Tina4::Auth
- Defined in:
- lib/tina4/auth.rb
Constant Summary collapse
- KEYS_DIR =
".keys"
Class Method Summary collapse
- .auth_handler(&block) ⇒ Object
- .authenticate_request(headers, secret: nil, algorithm: "HS256") ⇒ Object
-
.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.
- .get_payload(token) ⇒ Object
-
.get_token(payload, expires_in: 60, secret: nil) ⇒ Object
(also: create_token)
── Token API (auto-selects HS256 or RS256) ─────────────────.
- .hash_password(password, salt = nil, iterations = 260000) ⇒ Object
-
.hmac_decode(token, secret) ⇒ Object
Decode and verify a JWT signed with HS256.
-
.hmac_encode(claims, secret) ⇒ Object
Build a JWT using HS256 with Ruby’s OpenSSL::HMAC (no gem needed).
- .hmac_secret ⇒ Object
- .private_key ⇒ Object
- .public_key ⇒ Object
- .refresh_token(token, expires_in: 60) ⇒ Object
- .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
223 224 225 226 227 228 229 |
# File 'lib/tina4/auth.rb', line 223 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: "HS256") ⇒ Object
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/tina4/auth.rb', line 193 def authenticate_request(headers, secret: nil, algorithm: "HS256") auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || "" return nil unless auth_header =~ /\ABearer\s+(.+)\z/i token = Regexp.last_match(1) # API_KEY bypass — matches tina4_python behavior api_key = ENV["TINA4_API_KEY"] if api_key && !api_key.empty? && token == api_key return { "api_key" => true } end # If a custom secret is provided, validate against it directly if secret payload = hmac_decode(token, secret) return payload ? payload : nil end valid_token(token) ? get_payload(token) : nil end |
.base64url_decode(str) ⇒ Object
Base64url-decode (handles missing padding)
41 42 43 44 45 46 |
# File 'lib/tina4/auth.rb', line 41 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)
36 37 38 |
# File 'lib/tina4/auth.rb', line 36 def base64url_encode(data) Base64.urlsafe_encode64(data, padding: false) end |
.bearer_auth ⇒ Object
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/tina4/auth.rb', line 231 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 — matches tina4_python behavior api_key = ENV["TINA4_API_KEY"] if api_key && !api_key.empty? && token == api_key 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
159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/tina4/auth.rb', line 159 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
256 257 258 |
# File 'lib/tina4/auth.rb', line 256 def default_secure_auth @default_secure_auth ||= bearer_auth end |
.get_payload(token) ⇒ Object
174 175 176 177 178 179 180 181 182 |
# File 'lib/tina4/auth.rb', line 174 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) ⇒ Object Also known as: create_token
── Token API (auto-selects HS256 or RS256) ─────────────────
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/tina4/auth.rb', line 92 def get_token(payload, expires_in: 60, secret: nil) now = Time.now.to_i claims = payload.merge( "iat" => now, "exp" => now + (expires_in * 60).to_i, "nbf" => now ) if secret hmac_encode(claims, secret) elsif use_hmac? hmac_encode(claims, hmac_secret) else ensure_keys require "jwt" JWT.encode(claims, private_key, "RS256") end end |
.hash_password(password, salt = nil, iterations = 260000) ⇒ Object
153 154 155 156 157 |
# File 'lib/tina4/auth.rb', line 153 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) ⇒ Object
Decode and verify a JWT signed with HS256. Returns the payload hash or nil.
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/tina4/auth.rb', line 62 def hmac_decode(token, secret) 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"] == "HS256" # Verify signature signing_input = "#{parts[0]}.#{parts[1]}" expected_sig = OpenSSL::HMAC.digest("SHA256", secret, signing_input) actual_sig = base64url_decode(parts[2]) # Constant-time comparison to prevent timing attacks 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"] return nil if payload["nbf"] && now < payload["nbf"] payload rescue ArgumentError, JSON::ParserError, OpenSSL::HMACError nil end |
.hmac_encode(claims, secret) ⇒ Object
Build a JWT using HS256 with Ruby’s OpenSSL::HMAC (no gem needed)
49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/tina4/auth.rb', line 49 def hmac_encode(claims, secret) header = { "alg" => "HS256", "typ" => "JWT" } segments = [ base64url_encode(JSON.generate(header)), base64url_encode(JSON.generate(claims)) ] signing_input = segments.join(".") signature = OpenSSL::HMAC.digest("SHA256", secret, signing_input) segments << base64url_encode(signature) segments.join(".") end |
.hmac_secret ⇒ Object
31 32 33 |
# File 'lib/tina4/auth.rb', line 31 def hmac_secret ENV["TINA4_SECRET"] end |
.private_key ⇒ Object
264 265 266 |
# File 'lib/tina4/auth.rb', line 264 def private_key @private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path)) end |
.public_key ⇒ Object
268 269 270 |
# File 'lib/tina4/auth.rb', line 268 def public_key @public_key ||= OpenSSL::PKey::RSA.new(File.read(public_key_path)) end |
.refresh_token(token, expires_in: 60) ⇒ Object
184 185 186 187 188 189 190 191 |
# File 'lib/tina4/auth.rb', line 184 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 nbf].include?(k) } get_token(payload, expires_in: expires_in) end |
.setup(root_dir = Dir.pwd) ⇒ Object
12 13 14 15 16 |
# File 'lib/tina4/auth.rb', line 12 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/
21 22 23 24 25 26 27 28 29 |
# File 'lib/tina4/auth.rb', line 21 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.
120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/tina4/auth.rb', line 120 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
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/tina4/auth.rb', line 133 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
214 215 216 217 218 219 220 221 |
# File 'lib/tina4/auth.rb', line 214 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 |