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
-> bool.
- .valid_token_detail(token) ⇒ Object (also: validate_token)
- .validate_api_key(provided, expected: nil) ⇒ Object
Class Method Details
.auth_handler(&block) ⇒ Object
217 218 219 220 221 222 223 |
# File 'lib/tina4/auth.rb', line 217 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
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/tina4/auth.rb', line 187 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"] || ENV["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
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 |
# File 'lib/tina4/auth.rb', line 225 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"] || ENV["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
153 154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/tina4/auth.rb', line 153 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
250 251 252 |
# File 'lib/tina4/auth.rb', line 250 def default_secure_auth @default_secure_auth ||= bearer_auth end |
.get_payload(token) ⇒ Object
168 169 170 171 172 173 174 175 176 |
# File 'lib/tina4/auth.rb', line 168 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
147 148 149 150 151 |
# File 'lib/tina4/auth.rb', line 147 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["SECRET"] end |
.private_key ⇒ Object
258 259 260 |
# File 'lib/tina4/auth.rb', line 258 def private_key @private_key ||= OpenSSL::PKey::RSA.new(File.read(private_key_path)) end |
.public_key ⇒ Object
262 263 264 |
# File 'lib/tina4/auth.rb', line 262 def public_key @public_key ||= OpenSSL::PKey::RSA.new(File.read(public_key_path)) end |
.refresh_token(token, expires_in: 60) ⇒ Object
178 179 180 181 182 183 184 185 |
# File 'lib/tina4/auth.rb', line 178 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["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
-> bool
112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# File 'lib/tina4/auth.rb', line 112 def valid_token(token) # -> bool if use_hmac? !hmac_decode(token, hmac_secret).nil? else ensure_keys require "jwt" JWT.decode(token, public_key, true, algorithm: "RS256") true end rescue JWT::ExpiredSignature false rescue JWT::DecodeError false end |
.valid_token_detail(token) ⇒ Object Also known as: validate_token
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/tina4/auth.rb', line 127 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
208 209 210 211 212 213 214 215 |
# File 'lib/tina4/auth.rb', line 208 def validate_api_key(provided, expected: nil) expected ||= ENV["TINA4_API_KEY"] || ENV["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 |