Skip to content

Commit 32662ab

Browse files
gjtorikianclaude
andauthored
fix: seal session client-side in Session#refresh (#470)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a4febaf commit 32662ab

2 files changed

Lines changed: 141 additions & 5 deletions

File tree

lib/workos/session.rb

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,18 +90,27 @@ def refresh(organization_id: nil, cookie_password: nil)
9090
body = {
9191
"grant_type" => "refresh_token",
9292
"client_id" => @client.client_id,
93-
"refresh_token" => session["refresh_token"],
94-
"session" => {"seal_session" => true, "cookie_password" => effective_password}
93+
"refresh_token" => session["refresh_token"]
9594
}
9695
body["organization_id"] = organization_id if organization_id
9796

9897
response = @client.request(method: :post, path: "/user_management/authenticate", auth: true, body: body)
9998
auth_response = JSON.parse(response.body)
100-
sealed = auth_response["sealed_session"].to_s
101-
@seal_data = sealed
102-
@cookie_password = effective_password
10399

100+
sealed = @manager.seal_session_from_auth_response(
101+
access_token: auth_response["access_token"],
102+
refresh_token: auth_response["refresh_token"],
103+
cookie_password: effective_password,
104+
user: auth_response["user"],
105+
impersonator: auth_response["impersonator"]
106+
)
107+
108+
# Decode before mutating session state so a malformed access_token
109+
# doesn't leave the Session half-updated.
104110
decoded = @manager.decode_jwt(auth_response["access_token"])
111+
112+
@seal_data = sealed
113+
@cookie_password = effective_password
105114
SessionManager::RefreshSuccess.new(
106115
authenticated: true,
107116
sealed_session: sealed,
@@ -117,6 +126,8 @@ def refresh(organization_id: nil, cookie_password: nil)
117126
)
118127
rescue WorkOS::AuthenticationError, WorkOS::InvalidRequestError => e
119128
SessionManager::RefreshError.new(authenticated: false, reason: e.message)
129+
rescue JWT::DecodeError => e
130+
SessionManager::RefreshError.new(authenticated: false, reason: e.message)
120131
end
121132

122133
# Build the WorkOS session-logout URL for the currently authenticated session.

test/workos/test_session.rb

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,131 @@ def test_get_logout_url_includes_session_id_from_authenticate
206206
assert_equal "https://app/cb", params["return_to"]
207207
end
208208

209+
# --- Session#refresh -------------------------------------------------------
210+
211+
def test_refresh_seals_session_client_side_and_returns_refresh_success
212+
rsa, pub = signing_key_pair
213+
old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa)
214+
sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_old", "user" => {"id" => "u_1"}}, PASSWORD)
215+
216+
new_access = make_jwt({"sid" => "session_new", "org_id" => "org_1", "role" => "admin", "exp" => Time.now.to_i + 300}, rsa)
217+
api_response = {
218+
"access_token" => new_access,
219+
"refresh_token" => "rt_new",
220+
"user" => {"id" => "u_1", "email" => "a@b.com"},
221+
"impersonator" => nil
222+
}
223+
224+
stub_request(:post, "https://api.workos.com/user_management/authenticate")
225+
.with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "rt_old"))
226+
.to_return(status: 200, body: api_response.to_json)
227+
stub_request(:get, "https://api.workos.com/sso/jwks/client_001")
228+
.to_return(status: 200, body: jwks_payload(pub).to_json)
229+
230+
session = @sm.load(seal_data: sealed, cookie_password: PASSWORD)
231+
result = session.refresh
232+
233+
assert_kind_of WorkOS::SessionManager::RefreshSuccess, result
234+
assert result.authenticated
235+
assert_equal "session_new", result.session_id
236+
assert_equal "org_1", result.organization_id
237+
assert_equal "admin", result.role
238+
assert_equal "u_1", result.user["id"]
239+
240+
# sealed_session should be a non-empty string that round-trips
241+
refute_empty result.sealed_session
242+
unsealed = @sm.unseal_data(result.sealed_session, PASSWORD)
243+
assert_equal new_access, unsealed["access_token"]
244+
assert_equal "rt_new", unsealed["refresh_token"]
245+
end
246+
247+
def test_refresh_updates_internal_seal_data_for_subsequent_authenticate
248+
rsa, pub = signing_key_pair
249+
old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa)
250+
sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_old", "user" => {"id" => "u_1"}}, PASSWORD)
251+
252+
new_access = make_jwt({"sid" => "session_refreshed", "org_id" => "org_2", "exp" => Time.now.to_i + 300}, rsa)
253+
api_response = {
254+
"access_token" => new_access,
255+
"refresh_token" => "rt_new",
256+
"user" => {"id" => "u_1"}
257+
}
258+
259+
stub_request(:post, "https://api.workos.com/user_management/authenticate")
260+
.to_return(status: 200, body: api_response.to_json)
261+
stub_request(:get, "https://api.workos.com/sso/jwks/client_001")
262+
.to_return(status: 200, body: jwks_payload(pub).to_json)
263+
264+
session = @sm.load(seal_data: sealed, cookie_password: PASSWORD)
265+
session.refresh
266+
267+
# A subsequent authenticate should use the refreshed token
268+
auth = session.authenticate
269+
assert_kind_of WorkOS::SessionManager::AuthSuccess, auth
270+
assert auth.authenticated
271+
assert_equal "session_refreshed", auth.session_id
272+
end
273+
274+
def test_refresh_returns_error_on_invalid_cookie
275+
result = @sm.refresh(seal_data: "garbage", cookie_password: PASSWORD)
276+
assert_kind_of WorkOS::SessionManager::RefreshError, result
277+
refute result.authenticated
278+
assert_equal WorkOS::SessionManager::INVALID_SESSION_COOKIE, result.reason
279+
end
280+
281+
def test_refresh_returns_error_when_no_refresh_token
282+
sealed = @sm.seal_data({"access_token" => "at_only"}, PASSWORD)
283+
result = @sm.refresh(seal_data: sealed, cookie_password: PASSWORD)
284+
assert_kind_of WorkOS::SessionManager::RefreshError, result
285+
assert_equal WorkOS::SessionManager::INVALID_SESSION_COOKIE, result.reason
286+
end
287+
288+
def test_refresh_does_not_send_session_param_to_api
289+
rsa, pub = signing_key_pair
290+
old_access = make_jwt({"sid" => "s", "exp" => Time.now.to_i - 60}, rsa)
291+
sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_x", "user" => {"id" => "u"}}, PASSWORD)
292+
293+
new_access = make_jwt({"sid" => "s2", "exp" => Time.now.to_i + 300}, rsa)
294+
api_response = {"access_token" => new_access, "refresh_token" => "rt_y", "user" => {"id" => "u"}}
295+
296+
stub = stub_request(:post, "https://api.workos.com/user_management/authenticate")
297+
.with { |req| !req.body.include?("seal_session") }
298+
.to_return(status: 200, body: api_response.to_json)
299+
stub_request(:get, "https://api.workos.com/sso/jwks/client_001")
300+
.to_return(status: 200, body: jwks_payload(pub).to_json)
301+
302+
session = @sm.load(seal_data: sealed, cookie_password: PASSWORD)
303+
session.refresh
304+
305+
assert_requested(stub)
306+
end
307+
308+
def test_refresh_returns_error_on_malformed_access_token_without_mutating_state
309+
rsa, pub = signing_key_pair
310+
old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa)
311+
sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_old", "user" => {"id" => "u_1"}}, PASSWORD)
312+
313+
api_response = {
314+
"access_token" => "not-a-valid-jwt",
315+
"refresh_token" => "rt_new",
316+
"user" => {"id" => "u_1"}
317+
}
318+
319+
stub_request(:post, "https://api.workos.com/user_management/authenticate")
320+
.to_return(status: 200, body: api_response.to_json)
321+
stub_request(:get, "https://api.workos.com/sso/jwks/client_001")
322+
.to_return(status: 200, body: jwks_payload(pub).to_json)
323+
324+
session = @sm.load(seal_data: sealed, cookie_password: PASSWORD)
325+
result = session.refresh
326+
327+
assert_kind_of WorkOS::SessionManager::RefreshError, result
328+
refute result.authenticated
329+
330+
# Session state should not have been mutated
331+
assert_equal sealed, session.seal_data
332+
end
333+
209334
# --- Session constructor validation ---------------------------------------
210335

211336
def test_session_load_requires_cookie_password

0 commit comments

Comments
 (0)