From 9a4e945772fa2972f9ded50f5c3c3df000233fcb Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Wed, 4 Nov 2026 22:08:04 +0000 Subject: [PATCH 1/3] fix(session): Reject non-user access tokens in session cookies Session#authenticate now requires the verified access token to carry sid and auth_time, a sub_profile that is absent or user, and no sub_type, so other token types signed by the same environment key do not authenticate a session. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/workos/session.rb | 12 ++++++++++++ test/workos/test_session.rb | 32 +++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/workos/session.rb b/lib/workos/session.rb index 28371731..c210dbfe 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -64,6 +64,7 @@ def authenticate(include_expired: false, &claim_extractor) rescue JWT::DecodeError return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT) end + return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT) unless user_access_token?(decoded) is_expired = decoded["exp"].nil? || decoded["exp"] < Time.now.to_i @@ -163,5 +164,16 @@ def get_logout_url(return_to: nil) uri.query = URI.encode_www_form(params) uri.to_s end + + private + + # Other token types signed by the same environment key (agent and widget + # tokens) must not authenticate a session cookie. + def user_access_token?(claims) + claims["sid"].is_a?(String) && + claims["auth_time"].is_a?(Numeric) && + [nil, "user"].include?(claims["sub_profile"]) && + claims["sub_type"].nil? + end end end diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index e63a9fde..86712a19 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -76,7 +76,7 @@ def signing_key_pair end def make_jwt(claims, rsa, kid: "test-key") - JWT.encode(claims, rsa, "RS256", {kid: kid}) + JWT.encode({"auth_time" => Time.now.to_i}.merge(claims), rsa, "RS256", {kid: kid}) end def jwks_payload(public_key, kid: "test-key") @@ -101,6 +101,36 @@ def test_authenticate_returns_success_with_decoded_claims assert_equal "u_1", result.user["id"] end + [ + {"sub_profile" => "ai_agent"}, + {"sub_type" => "authkit"}, + {"auth_time" => nil} + ].each_with_index do |claims, index| + define_method(:"test_authenticate_rejects_non_user_access_token_#{index}") do + rsa, pub = signing_key_pair + access_token = make_jwt({"sid" => "session_42", "exp" => Time.now.to_i + 60}.merge(claims), rsa) + sealed = @sm.seal_data({"access_token" => access_token}, PASSWORD) + stub_request(:get, "https://api.workos.com/sso/jwks/client_001") + .to_return(status: 200, body: jwks_payload(pub).to_json) + + result = @sm.authenticate(seal_data: sealed, cookie_password: PASSWORD) + assert_kind_of WorkOS::SessionManager::AuthError, result + assert_equal WorkOS::SessionManager::INVALID_JWT, result.reason + end + end + + def test_authenticate_accepts_user_sub_profile + rsa, pub = signing_key_pair + access_token = make_jwt({"sid" => "session_42", "sub_profile" => "user", "exp" => Time.now.to_i + 60}, rsa) + sealed = @sm.seal_data({"access_token" => access_token}, PASSWORD) + stub_request(:get, "https://api.workos.com/sso/jwks/client_001") + .to_return(status: 200, body: jwks_payload(pub).to_json) + + result = @sm.authenticate(seal_data: sealed, cookie_password: PASSWORD) + assert_kind_of WorkOS::SessionManager::AuthSuccess, result + assert result.authenticated + end + def test_authenticate_reads_legacy_v6_sealed_session rsa, pub = signing_key_pair access_token = make_jwt({"sid" => "session_v6", "org_id" => "org_legacy", "exp" => Time.now.to_i + 60}, rsa) From 4bbc32322960831b18bc93fc380e6629a4563bc7 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Wed, 4 Nov 2026 22:09:43 +0000 Subject: [PATCH 2/3] test(session): Cover invalid sid and auth_time claim types Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- test/workos/test_session.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index 86712a19..031aa604 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -104,7 +104,10 @@ def test_authenticate_returns_success_with_decoded_claims [ {"sub_profile" => "ai_agent"}, {"sub_type" => "authkit"}, - {"auth_time" => nil} + {"auth_time" => nil}, + {"auth_time" => "1700000000"}, + {"sid" => nil}, + {"sid" => 123} ].each_with_index do |claims, index| define_method(:"test_authenticate_rejects_non_user_access_token_#{index}") do rsa, pub = signing_key_pair From 32205a1d5dcaca4f7797e1c7ce1ea6ff262ba8e4 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Wed, 4 Nov 2026 22:14:00 +0000 Subject: [PATCH 3/3] test(session): Cover expired non-user token with include_expired Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- test/workos/test_session.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index 031aa604..8bd5b1e5 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -122,6 +122,18 @@ def test_authenticate_returns_success_with_decoded_claims end end + def test_authenticate_rejects_expired_non_user_access_token_when_include_expired_is_true + rsa, pub = signing_key_pair + access_token = make_jwt({"sid" => "session_42", "sub_profile" => "ai_agent", "exp" => Time.now.to_i - 60}, rsa) + sealed = @sm.seal_data({"access_token" => access_token}, PASSWORD) + stub_request(:get, "https://api.workos.com/sso/jwks/client_001") + .to_return(status: 200, body: jwks_payload(pub).to_json) + + result = @sm.authenticate(seal_data: sealed, cookie_password: PASSWORD, include_expired: true) + assert_kind_of WorkOS::SessionManager::AuthError, result + assert_equal WorkOS::SessionManager::INVALID_JWT, result.reason + end + def test_authenticate_accepts_user_sub_profile rsa, pub = signing_key_pair access_token = make_jwt({"sid" => "session_42", "sub_profile" => "user", "exp" => Time.now.to_i + 60}, rsa)