From 713601a2fc243a87627f44975fc8a7bb512de438 Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Sat, 5 Sep 2026 20:08:13 +0000 Subject: [PATCH 1/2] Add optional jwt_issuer option for session token validation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 23 +++++++++++++ lib/workos/base_client.rb | 8 +++-- lib/workos/configuration.rb | 5 +-- lib/workos/session_manager.rb | 17 ++++++---- test/workos/test_session.rb | 61 +++++++++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c21e188f..d44284fe 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,29 @@ Anything shorter than 32 bytes (including `nil` or `""`) raises `ArgumentError` as soon as you load, seal, or unseal a session — sealing or unsealing will not silently proceed with a weakened key. +### Validating the access token issuer + +By default the SDK verifies the signature and expiry of session access tokens +but does not check the `iss` claim. To also require a specific issuer (or one +of several), set `jwt_issuer` on the client — either a single string or an +array of accepted issuers: + +```ruby +WorkOS.configure do |config| + config.jwt_issuer = "https://api.workos.com/user_management/#{ENV["WORKOS_CLIENT_ID"]}" +end + +# or per client +client = WorkOS::Client.new( + api_key: ENV.fetch("WORKOS_API_KEY"), + client_id: ENV["WORKOS_CLIENT_ID"], + jwt_issuer: ["https://api.workos.com", "https://auth.example.com"] +) +``` + +Tokens whose `iss` is not in the configured set fail authentication with +reason `WorkOS::SessionManager::INVALID_JWT`. + ### Verify a webhook ```ruby diff --git a/lib/workos/base_client.rb b/lib/workos/base_client.rb index 5be4f261..4b4af432 100644 --- a/lib/workos/base_client.rb +++ b/lib/workos/base_client.rb @@ -41,11 +41,14 @@ class BaseClient "v#{WorkOS::VERSION}" ].join("; ").freeze - attr_reader :api_key, :base_url, :client_id, :timeout, :max_retries, :logger, :log_level + attr_reader :api_key, :base_url, :client_id, :timeout, :max_retries, :logger, :log_level, :jwt_issuer + # @param jwt_issuer [String, Array, nil] Expected `iss` claim of + # session access tokens (one issuer or a list of accepted issuers). + # When nil, the issuer is not validated. def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, client_id: nil, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES, - logger: nil, log_level: nil, random: Random.new) + logger: nil, log_level: nil, jwt_issuer: nil, random: Random.new) @api_key = api_key @base_url = base_url @client_id = client_id @@ -53,6 +56,7 @@ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, client_id: nil, @max_retries = max_retries @logger = logger @log_level = log_level + @jwt_issuer = jwt_issuer @random = random end diff --git a/lib/workos/configuration.rb b/lib/workos/configuration.rb index a96f9e8f..028406cc 100644 --- a/lib/workos/configuration.rb +++ b/lib/workos/configuration.rb @@ -12,7 +12,7 @@ module WorkOS # config.log_level = :info # end class Configuration - attr_accessor :api_key, :base_url, :client_id, :timeout, :max_retries, :logger, :log_level + attr_accessor :api_key, :base_url, :client_id, :timeout, :max_retries, :logger, :log_level, :jwt_issuer def initialize @base_url = WorkOS::BaseClient::DEFAULT_BASE_URL @@ -47,7 +47,8 @@ def client timeout: configuration.timeout, max_retries: configuration.max_retries, logger: configuration.logger, - log_level: configuration.log_level + log_level: configuration.log_level, + jwt_issuer: configuration.jwt_issuer ) end diff --git a/lib/workos/session_manager.rb b/lib/workos/session_manager.rb index 64bbaa53..b4f57229 100644 --- a/lib/workos/session_manager.rb +++ b/lib/workos/session_manager.rb @@ -174,7 +174,10 @@ def seal_session_from_auth_response(access_token:, refresh_token:, cookie_passwo # Verify an access-token JWT against the WorkOS JWKS for this client. # Used by Session#authenticate; exposed publicly for advanced cases. # - # NOTE on iss/aud/required_claims: this method intentionally does not + # The `iss` claim is only checked when the client was built with + # `jwt_issuer:` (a String or an Array of accepted issuers). + # + # NOTE on iss/aud/required_claims: by default this method does not # enforce iss, aud, or required_claims. workos-node's `jose` call and # workos-php's `isset($exp) && $exp < time()` accept exp-less tokens, and # cross-SDK parity is required for the planned coordinated hardening of @@ -182,15 +185,17 @@ def seal_session_from_auth_response(access_token:, refresh_token:, cookie_passwo # required_claims: ['exp'] tightening that was considered here. def decode_jwt(access_token, verify_expiration: true) jwks = fetch_jwks - JWT.decode( - access_token, - nil, - true, + options = { algorithms: JWK_ALGORITHMS, jwks: jwks, verify_aud: false, verify_expiration: verify_expiration - ).first + } + unless client.jwt_issuer.nil? + options[:iss] = client.jwt_issuer + options[:verify_iss] = true + end + JWT.decode(access_token, nil, true, options).first end private diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index 25bfc134..792e80e1 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -220,6 +220,67 @@ def test_authenticate_returns_auth_success_with_authenticated_false_when_expired assert_equal "session_expired", result.session_id end + # --- jwt_issuer ----------------------------------------------------------- + + def authenticate_with_issuer(jwt_issuer, iss) + rsa, pub = signing_key_pair + client = WorkOS::Client.new(api_key: "sk_test_session", client_id: "client_001", jwt_issuer: jwt_issuer) + sm = client.session_manager + access_token = make_jwt({"sid" => "session_iss", "iss" => iss, "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) + + sm.authenticate(seal_data: sealed, cookie_password: PASSWORD) + end + + def test_authenticate_ignores_issuer_when_jwt_issuer_is_not_configured + result = authenticate_with_issuer(nil, "https://other.example.com") + assert_kind_of WorkOS::SessionManager::AuthSuccess, result + assert result.authenticated + end + + def test_authenticate_accepts_matching_jwt_issuer + result = authenticate_with_issuer("https://api.workos.com", "https://api.workos.com") + assert_kind_of WorkOS::SessionManager::AuthSuccess, result + assert result.authenticated + end + + def test_authenticate_rejects_mismatched_jwt_issuer + result = authenticate_with_issuer("https://api.workos.com", "https://other.example.com") + assert_kind_of WorkOS::SessionManager::AuthError, result + assert_equal WorkOS::SessionManager::INVALID_JWT, result.reason + end + + def test_authenticate_rejects_missing_iss_when_jwt_issuer_is_configured + result = authenticate_with_issuer("https://api.workos.com", nil) + assert_kind_of WorkOS::SessionManager::AuthError, result + assert_equal WorkOS::SessionManager::INVALID_JWT, result.reason + end + + def test_authenticate_accepts_any_listed_jwt_issuer + issuers = ["https://api.workos.com", "https://auth.example.com"] + result = authenticate_with_issuer(issuers, "https://auth.example.com") + assert_kind_of WorkOS::SessionManager::AuthSuccess, result + assert result.authenticated + end + + def test_authenticate_rejects_all_tokens_when_jwt_issuer_list_is_empty + result = authenticate_with_issuer([], "https://api.workos.com") + assert_kind_of WorkOS::SessionManager::AuthError, result + assert_equal WorkOS::SessionManager::INVALID_JWT, result.reason + end + + def test_global_configuration_passes_jwt_issuer_to_client + WorkOS.reset_client + WorkOS.configure { |config| config.jwt_issuer = "https://api.workos.com" } + assert_equal "https://api.workos.com", WorkOS.client.jwt_issuer + ensure + WorkOS.configuration.jwt_issuer = nil + WorkOS.reset_client + end + # --- get_logout_url ------------------------------------------------------- def test_get_logout_url_includes_session_id_from_authenticate From 4ffa55e57eebf8304d17f1107fc1cfee279e6dfb Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Sat, 5 Sep 2026 20:11:35 +0000 Subject: [PATCH 2/2] Omit iss claim entirely in missing-issuer test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- test/workos/test_session.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index 792e80e1..e63a9fde 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -226,7 +226,9 @@ def authenticate_with_issuer(jwt_issuer, iss) rsa, pub = signing_key_pair client = WorkOS::Client.new(api_key: "sk_test_session", client_id: "client_001", jwt_issuer: jwt_issuer) sm = client.session_manager - access_token = make_jwt({"sid" => "session_iss", "iss" => iss, "exp" => Time.now.to_i + 60}, rsa) + claims = {"sid" => "session_iss", "exp" => Time.now.to_i + 60} + claims["iss"] = iss unless iss.nil? + access_token = make_jwt(claims, rsa) sealed = sm.seal_data({"access_token" => access_token}, PASSWORD) stub_request(:get, "https://api.workos.com/sso/jwks/client_001")