Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions lib/workos/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,27 @@ 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
@seal_data = sealed
@cookie_password = effective_password

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"]
)

# 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,
Expand All @@ -117,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.
Expand Down
125 changes: 125 additions & 0 deletions test/workos/test_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,131 @@ 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

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
Expand Down