From ce199a4cb125bb71319f0c153f5da92644cf41fb Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 29 Apr 2026 08:45:34 -0400 Subject: [PATCH 1/2] fix: seal session client-side in Session#refresh (#469) Session#refresh was sending a `session` param to the API expecting it to return a `sealed_session`, but the API does not populate that field for refresh-token grants. This left `sealed_session` as an empty string. Seal the refreshed tokens client-side via `SessionManager#seal_session_from_auth_response`, consistent with how every other v7 auth flow handles sealing. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/workos/session.rb | 12 +++-- test/workos/test_session.rb | 99 +++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/lib/workos/session.rb b/lib/workos/session.rb index d2d49d9b..a70c3900 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -90,14 +90,20 @@ def refresh(organization_id: nil, cookie_password: nil) body = { "grant_type" => "refresh_token", "client_id" => @client.client_id, - "refresh_token" => session["refresh_token"], - "session" => {"seal_session" => true, "cookie_password" => effective_password} + "refresh_token" => session["refresh_token"] } body["organization_id"] = organization_id if organization_id response = @client.request(method: :post, path: "/user_management/authenticate", auth: true, body: body) auth_response = JSON.parse(response.body) - sealed = auth_response["sealed_session"].to_s + + sealed = @manager.seal_session_from_auth_response( + access_token: auth_response["access_token"], + refresh_token: auth_response["refresh_token"], + cookie_password: effective_password, + user: auth_response["user"], + impersonator: auth_response["impersonator"] + ) @seal_data = sealed @cookie_password = effective_password diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index ad9db804..f63f2100 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -206,6 +206,105 @@ def test_get_logout_url_includes_session_id_from_authenticate assert_equal "https://app/cb", params["return_to"] end + # --- Session#refresh ------------------------------------------------------- + + def test_refresh_seals_session_client_side_and_returns_refresh_success + rsa, pub = signing_key_pair + old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa) + sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_old", "user" => {"id" => "u_1"}}, PASSWORD) + + new_access = make_jwt({"sid" => "session_new", "org_id" => "org_1", "role" => "admin", "exp" => Time.now.to_i + 300}, rsa) + api_response = { + "access_token" => new_access, + "refresh_token" => "rt_new", + "user" => {"id" => "u_1", "email" => "a@b.com"}, + "impersonator" => nil + } + + stub_request(:post, "https://api.workos.com/user_management/authenticate") + .with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "rt_old")) + .to_return(status: 200, body: api_response.to_json) + stub_request(:get, "https://api.workos.com/sso/jwks/client_001") + .to_return(status: 200, body: jwks_payload(pub).to_json) + + session = @sm.load(seal_data: sealed, cookie_password: PASSWORD) + result = session.refresh + + assert_kind_of WorkOS::SessionManager::RefreshSuccess, result + assert result.authenticated + assert_equal "session_new", result.session_id + assert_equal "org_1", result.organization_id + assert_equal "admin", result.role + assert_equal "u_1", result.user["id"] + + # sealed_session should be a non-empty string that round-trips + refute_empty result.sealed_session + unsealed = @sm.unseal_data(result.sealed_session, PASSWORD) + assert_equal new_access, unsealed["access_token"] + assert_equal "rt_new", unsealed["refresh_token"] + end + + def test_refresh_updates_internal_seal_data_for_subsequent_authenticate + rsa, pub = signing_key_pair + old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa) + sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_old", "user" => {"id" => "u_1"}}, PASSWORD) + + new_access = make_jwt({"sid" => "session_refreshed", "org_id" => "org_2", "exp" => Time.now.to_i + 300}, rsa) + api_response = { + "access_token" => new_access, + "refresh_token" => "rt_new", + "user" => {"id" => "u_1"} + } + + stub_request(:post, "https://api.workos.com/user_management/authenticate") + .to_return(status: 200, body: api_response.to_json) + stub_request(:get, "https://api.workos.com/sso/jwks/client_001") + .to_return(status: 200, body: jwks_payload(pub).to_json) + + session = @sm.load(seal_data: sealed, cookie_password: PASSWORD) + session.refresh + + # A subsequent authenticate should use the refreshed token + auth = session.authenticate + assert_kind_of WorkOS::SessionManager::AuthSuccess, auth + assert auth.authenticated + assert_equal "session_refreshed", auth.session_id + end + + def test_refresh_returns_error_on_invalid_cookie + result = @sm.refresh(seal_data: "garbage", cookie_password: PASSWORD) + assert_kind_of WorkOS::SessionManager::RefreshError, result + refute result.authenticated + assert_equal WorkOS::SessionManager::INVALID_SESSION_COOKIE, result.reason + end + + def test_refresh_returns_error_when_no_refresh_token + sealed = @sm.seal_data({"access_token" => "at_only"}, PASSWORD) + result = @sm.refresh(seal_data: sealed, cookie_password: PASSWORD) + assert_kind_of WorkOS::SessionManager::RefreshError, result + assert_equal WorkOS::SessionManager::INVALID_SESSION_COOKIE, result.reason + end + + def test_refresh_does_not_send_session_param_to_api + rsa, pub = signing_key_pair + old_access = make_jwt({"sid" => "s", "exp" => Time.now.to_i - 60}, rsa) + sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_x", "user" => {"id" => "u"}}, PASSWORD) + + new_access = make_jwt({"sid" => "s2", "exp" => Time.now.to_i + 300}, rsa) + api_response = {"access_token" => new_access, "refresh_token" => "rt_y", "user" => {"id" => "u"}} + + stub = stub_request(:post, "https://api.workos.com/user_management/authenticate") + .with { |req| !req.body.include?("seal_session") } + .to_return(status: 200, body: api_response.to_json) + stub_request(:get, "https://api.workos.com/sso/jwks/client_001") + .to_return(status: 200, body: jwks_payload(pub).to_json) + + session = @sm.load(seal_data: sealed, cookie_password: PASSWORD) + session.refresh + + assert_requested(stub) + end + # --- Session constructor validation --------------------------------------- def test_session_load_requires_cookie_password From 7d6a0d63bd2eb280d9562cc943deb0efba3d31d2 Mon Sep 17 00:00:00 2001 From: Garen Torikian Date: Wed, 29 Apr 2026 10:59:46 -0400 Subject: [PATCH 2/2] fix: prevent partial state mutation in Session#refresh (#471) Co-authored-by: Claude Opus 4.6 (1M context) --- lib/workos/session.rb | 9 +++++++-- test/workos/test_session.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/workos/session.rb b/lib/workos/session.rb index a70c3900..5165fb32 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -104,10 +104,13 @@ def refresh(organization_id: nil, cookie_password: nil) user: auth_response["user"], impersonator: auth_response["impersonator"] ) - @seal_data = sealed - @cookie_password = effective_password + # Decode before mutating session state so a malformed access_token + # doesn't leave the Session half-updated. decoded = @manager.decode_jwt(auth_response["access_token"]) + + @seal_data = sealed + @cookie_password = effective_password SessionManager::RefreshSuccess.new( authenticated: true, sealed_session: sealed, @@ -123,6 +126,8 @@ def refresh(organization_id: nil, cookie_password: nil) ) rescue WorkOS::AuthenticationError, WorkOS::InvalidRequestError => e SessionManager::RefreshError.new(authenticated: false, reason: e.message) + rescue JWT::DecodeError => e + SessionManager::RefreshError.new(authenticated: false, reason: e.message) end # Build the WorkOS session-logout URL for the currently authenticated session. diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index f63f2100..1fc6d116 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -305,6 +305,32 @@ def test_refresh_does_not_send_session_param_to_api assert_requested(stub) end + def test_refresh_returns_error_on_malformed_access_token_without_mutating_state + rsa, pub = signing_key_pair + old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa) + sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_old", "user" => {"id" => "u_1"}}, PASSWORD) + + api_response = { + "access_token" => "not-a-valid-jwt", + "refresh_token" => "rt_new", + "user" => {"id" => "u_1"} + } + + stub_request(:post, "https://api.workos.com/user_management/authenticate") + .to_return(status: 200, body: api_response.to_json) + stub_request(:get, "https://api.workos.com/sso/jwks/client_001") + .to_return(status: 200, body: jwks_payload(pub).to_json) + + session = @sm.load(seal_data: sealed, cookie_password: PASSWORD) + result = session.refresh + + assert_kind_of WorkOS::SessionManager::RefreshError, result + refute result.authenticated + + # Session state should not have been mutated + assert_equal sealed, session.seal_data + end + # --- Session constructor validation --------------------------------------- def test_session_load_requires_cookie_password