Skip to content

Commit 8a99c45

Browse files
authored
Fix token expiration
1 parent 0336dfd commit 8a99c45

3 files changed

Lines changed: 77 additions & 44 deletions

File tree

lib/lara/auth_token.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# frozen_string_literal: true
22

3+
require "json"
4+
require "base64"
5+
36
module Lara
47
# JWT authentication token for API access
58
class AuthToken
@@ -8,10 +11,34 @@ class AuthToken
811
def initialize(token, refresh_token)
912
@token = token
1013
@refresh_token = refresh_token
14+
@expires_at_ms = parse_expires_at_ms(token)
1115
end
1216

1317
def to_s
1418
token
1519
end
20+
21+
def token_expired?
22+
@expires_at_ms <= (Time.now.to_f * 1000).to_i + 5_000 # 5 seconds buffer
23+
end
24+
25+
private
26+
27+
def parse_expires_at_ms(token)
28+
return 0 if token.nil? || token.empty?
29+
30+
parts = token.split(".")
31+
return 0 if parts.length != 3
32+
33+
b64 = parts[1].tr("-_", "+/")
34+
b64 += "=" * (4 - (b64.length % 4)) if b64.length % 4 != 0
35+
36+
exp = JSON.parse(Base64.decode64(b64))["exp"]
37+
return 0 unless exp.is_a?(Numeric)
38+
39+
(exp * 1000).to_i
40+
rescue StandardError
41+
0
42+
end
1643
end
1744
end

lib/lara/client.rb

Lines changed: 46 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require "base64"
88
require "digest"
99
require "uri"
10+
require "monitor"
1011

1112
module Lara
1213
# This class is used to interact with Lara via the REST API.
@@ -31,7 +32,7 @@ def initialize(auth_method, base_url: DEFAULT_BASE_URL, connection_timeout: nil,
3132
@connection_timeout = connection_timeout
3233
@read_timeout = read_timeout
3334
@extra_headers = {}
34-
@auth_mutex = Mutex.new
35+
@auth_mutex = Monitor.new
3536

3637
@connection = build_connection
3738
end
@@ -93,23 +94,37 @@ def request(method, path, body: nil, files: nil, headers: nil, params: nil, raw_
9394
make_request(method, path, body: body, files: files, headers: headers, params: params,
9495
raw_response: raw_response, &callback)
9596
rescue LaraApiError => e
96-
# Auto-refresh token on 401 with jwt expired
97-
raise unless e.status_code == 401 && e.message.include?("jwt expired")
97+
raise unless e.status_code == 401
9898

99-
refresh_token
100-
# Retry once with new token
99+
@auth_mutex.synchronize { refresh_or_reauthenticate }
101100
make_request(method, path, body: body, files: files, headers: headers, params: params,
102101
raw_response: raw_response, &callback)
103102
end
104103

105104
def ensure_valid_token
106105
@auth_mutex.synchronize do
107-
return if @auth_token
106+
return if @auth_token && !@auth_token.token_expired?
108107

109-
raise LaraError, "No authentication method available" unless @credentials
108+
refresh_or_reauthenticate
109+
end
110+
end
111+
112+
def refresh_or_reauthenticate
113+
if @auth_token&.refresh_token && !@auth_token.refresh_token.empty?
114+
begin
115+
do_refresh
116+
return
117+
rescue StandardError
118+
raise unless @credentials
119+
end
120+
end
110121

122+
if @credentials
111123
@auth_token = authenticate
124+
return
112125
end
126+
127+
raise LaraError, "No authentication method available for token renewal"
113128
end
114129

115130
def authenticate
@@ -143,47 +158,37 @@ def authenticate
143158
raise LaraApiError.from_response(response) unless response.success?
144159

145160
data = JSON.parse(response.body)
146-
refresh_token = response.headers["x-lara-refresh-token"]
161+
refresh_token_value = response.headers["x-lara-refresh-token"]
147162

148-
raise LaraError, "Missing refresh token in authentication response" unless refresh_token
163+
raise LaraError, "Missing refresh token in authentication response" unless refresh_token_value
149164

150-
AuthToken.new(data["token"], refresh_token)
165+
AuthToken.new(data["token"], refresh_token_value)
151166
end
152167

153-
def refresh_token
154-
@auth_mutex.synchronize do
155-
return unless @auth_token
168+
def do_refresh
169+
raise LaraError, "No refresh token available" unless @auth_token&.refresh_token
156170

157-
conn = Faraday.new(url: @base_url) do |c|
158-
c.adapter Faraday.default_adapter
159-
end
160-
161-
response = conn.post("/v2/auth/refresh") do |req|
162-
req.headers = {
163-
"Date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
164-
"X-Lara-SDK-Name" => "lara-ruby",
165-
"X-Lara-SDK-Version" => Lara::VERSION,
166-
"Authorization" => "Bearer #{@auth_token&.refresh_token}"
167-
}
168-
end
171+
conn = Faraday.new(url: @base_url) do |c|
172+
c.adapter Faraday.default_adapter
173+
end
169174

170-
if response.success?
171-
data = JSON.parse(response.body)
172-
refresh_token = response.headers["x-lara-refresh-token"]
173-
174-
if refresh_token
175-
@auth_token = AuthToken.new(data["token"], refresh_token)
176-
else
177-
# Refresh failed, force re-authentication
178-
@auth_token = nil
179-
ensure_valid_token
180-
end
181-
else
182-
# Refresh failed, force re-authentication
183-
@auth_token = nil
184-
ensure_valid_token
185-
end
175+
response = conn.post("/v2/auth/refresh") do |req|
176+
req.headers = {
177+
"Date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
178+
"X-Lara-SDK-Name" => "lara-ruby",
179+
"X-Lara-SDK-Version" => Lara::VERSION,
180+
"Authorization" => "Bearer #{@auth_token.refresh_token}"
181+
}
186182
end
183+
184+
raise LaraApiError.from_response(response) unless response.success?
185+
186+
data = JSON.parse(response.body)
187+
refresh_token_value = response.headers["x-lara-refresh-token"]
188+
189+
raise LaraError, "Missing refresh token in refresh response" unless refresh_token_value
190+
191+
@auth_token = AuthToken.new(data["token"], refresh_token_value)
187192
end
188193

189194
def make_request(method, path, body: nil, files: nil, headers: nil, params: nil, raw_response: false, &callback)

lib/lara/errors.rb

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@ def initialize(message, status_code = nil)
1717
class LaraApiError < LaraError
1818
attr_reader :type
1919

20-
# Builds an error from an HTTP response with JSON body:
21-
# { "error": { "type": "...", "message": "..." } }
20+
# Builds an error from an HTTP response with JSON body.
21+
# Supports both { "error": { "type": "...", "message": "..." } }
22+
# and { "type": "...", "message": "..." } response formats.
2223
def self.from_response(response)
2324
data = begin
2425
JSON.parse(response.body)
2526
rescue StandardError
2627
{}
2728
end
28-
error = data["error"] || {}
29+
error = data["error"] || data
2930
error_type = error["type"] || "UnknownError"
3031
error_message = error["message"] || "An unknown error occurred"
3132
new(response.status, error_type, error_message)

0 commit comments

Comments
 (0)