Module: Tina4::Auth

Defined in:
lib/tina4/auth.rb

Constant Summary collapse

KEYS_DIR =
".keys"

Class Method Summary collapse

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_authObject



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_authObject

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_secretObject



31
32
33
# File 'lib/tina4/auth.rb', line 31

def hmac_secret
  ENV["TINA4_SECRET"]
end

.private_keyObject



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_keyObject



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/

Returns:

  • (Boolean)


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.message }
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