Skip to content
Open
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions lib/workos/base_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,22 @@ 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<String>, 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
@timeout = timeout
@max_retries = max_retries
@logger = logger
@log_level = log_level
@jwt_issuer = jwt_issuer
@random = random
end

Expand Down
5 changes: 3 additions & 2 deletions lib/workos/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
17 changes: 11 additions & 6 deletions lib/workos/session_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,23 +174,28 @@ 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
# these claims. See commit 9ce069f for the rationale behind dropping the
# 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
Expand Down
63 changes: 63 additions & 0 deletions test/workos/test_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,69 @@ 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
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")
.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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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
Expand Down
Loading