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



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_authObject



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_authObject

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_secretObject



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

def hmac_secret
  ENV["SECRET"]
end

.private_keyObject



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_keyObject



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/

Returns:

  • (Boolean)


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