From 76cb1f3c0d093882babfe9930c4ceeb313e2869a Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Thu, 13 Aug 2026 10:30:07 +0100 Subject: [PATCH 1/5] fix(auth): block cross-org Okta sign-in for existing users ## Context Okta sign-in resolves the integration from the email domain and fetches the user identity from the integration's admin-configurable host. An admin of any Okta-enabled organization could point that host at a server they control and, because login just-in-time provisioned a membership for the returned email, receive a session for an unrelated existing user (cross-organization account takeover). Entra ID is unaffected as it fetches identity from a fixed Microsoft endpoint. ## Description Reject the sign-in when the resolved email belongs to an existing user who is not an active member of the integration's organization. Brand-new users are still provisioned on the fly, and existing members keep signing in, so enabling Okta on an existing organization is unaffected. Adding an existing user to another organization must now go through an invite. --- app/services/auth/okta/login_service.rb | 11 +++++ spec/services/auth/okta/login_service_spec.rb | 40 +++++++++++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/app/services/auth/okta/login_service.rb b/app/services/auth/okta/login_service.rb index 56222b17bea3..c118a405df37 100644 --- a/app/services/auth/okta/login_service.rb +++ b/app/services/auth/okta/login_service.rb @@ -20,6 +20,10 @@ def call query_okta_access_token check_userinfo(result.email) + if existing_user_outside_organization? + return result.single_validation_failure!(error_code: "user_does_not_belong_to_organization") + end + find_or_create_user find_or_create_membership @@ -41,6 +45,13 @@ def call attr_reader :code, :state + def existing_user_outside_organization? + user = User.find_by(email: result.email) + return false if user.nil? + + !user.memberships.active.exists?(organization_id: result.okta_integration.organization_id) + end + def generate_token result.token = Utils::AuthToken.encode(user: result.user, login_method: Organizations::AuthenticationMethods::OKTA) result diff --git a/spec/services/auth/okta/login_service_spec.rb b/spec/services/auth/okta/login_service_spec.rb index b207d270467e..1c7d96a1ed5d 100644 --- a/spec/services/auth/okta/login_service_spec.rb +++ b/spec/services/auth/okta/login_service_spec.rb @@ -104,21 +104,47 @@ end end - context "when user already exists" do + context "when the user exists but does not belong to the organization" do + before { create(:user, email: "foo@bar.com") } + + it "does not authenticate the user" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("user_does_not_belong_to_organization") + expect(result.token).to be_nil + end + + it "does not create a membership" do + expect { service.call }.not_to change(Membership, :count) + end + end + + context "when the user exists with a revoked membership in the organization" do let(:user) { create(:user, email: "foo@bar.com") } - before { user } + before { create(:membership, :revoked, user:, organization: okta_integration.organization) } + + it "does not authenticate the user" do + result = service.call - it "does not create a new user" do - expect { service.call }.not_to change(User, :count) + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("user_does_not_belong_to_organization") end end - context "when membership already exists" do + context "when the user already belongs to the organization" do let(:user) { create(:user, email: "foo@bar.com") } - let(:membership) { create(:membership, user:, organization: okta_integration.organization) } - before { membership } + before { create(:membership, user:, organization: okta_integration.organization) } + + it "authenticates without creating a new user" do + result = nil + + expect { result = service.call }.not_to change(User, :count) + expect(result).to be_success + expect(result.token).to be_present + end it "does not create a new membership" do expect { service.call }.not_to change(Membership, :count) From e7ffca0fb4f3bf4cfe5f4c83de0e432f668acdf1 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Thu, 13 Aug 2026 11:47:55 +0100 Subject: [PATCH 2/5] fix(auth): compare Okta userinfo email case-insensitively ## Context Okta does not guarantee the casing of the email it returns from the userinfo endpoint, so a user whose Okta email differs only in case from the address they typed was wrongly rejected with okta_userinfo_error. Entra ID already compares case-insensitively. ## Description Match the userinfo email against the expected address with a case-insensitive comparison, aligning the Okta flow with Entra ID. --- app/services/auth/okta/base_service.rb | 2 +- spec/services/auth/okta/login_service_spec.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/services/auth/okta/base_service.rb b/app/services/auth/okta/base_service.rb index de5601cb1d1d..ebfba4842c78 100644 --- a/app/services/auth/okta/base_service.rb +++ b/app/services/auth/okta/base_service.rb @@ -60,7 +60,7 @@ def check_userinfo(email) userinfo_headers = {"Authorization" => "Bearer #{result.okta_access_token}"} response = userinfo_client.get(headers: userinfo_headers) - raise ValidationError, "okta_userinfo_error" if response["email"] != email + raise ValidationError, "okta_userinfo_error" unless response["email"]&.casecmp?(email) result.userinfo = response end diff --git a/spec/services/auth/okta/login_service_spec.rb b/spec/services/auth/okta/login_service_spec.rb index 1c7d96a1ed5d..8a867739da97 100644 --- a/spec/services/auth/okta/login_service_spec.rb +++ b/spec/services/auth/okta/login_service_spec.rb @@ -104,6 +104,17 @@ end end + context "when okta userinfo email only differs in casing" do + let(:okta_userinfo_response) { {"email" => "FOO@BAR.COM"} } + + it "authenticates the user" do + result = service.call + + expect(result).to be_success + expect(result.token).to be_present + end + end + context "when the user exists but does not belong to the organization" do before { create(:user, email: "foo@bar.com") } From b429abf7c50f6407d70667ccbb31e77585515fe9 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Thu, 13 Aug 2026 12:49:27 +0100 Subject: [PATCH 3/5] fix(auth): validate configured Okta host format ## Context The Okta integration host is admin-configurable and interpolated directly into the authorize, token and userinfo URLs (https:///oauth2/v1/...). Without validation an admin could set a value such as "victim.okta.com@evil.com" or an internal address, sending the OAuth requests somewhere other than the intended Okta host (URL injection / SSRF). This mirrors the format validation Entra ID already enforces. ## Description Reject a configured host that is not a bare URL host segment (alphanumerics, dots and hyphens). Only the explicitly configured host is validated; when none is set the derived .okta.com default is used and left untouched, so custom Okta domains such as login.acme.com remain valid. This is defense-in-depth, not the primary fix for the cross-organization takeover. --- app/models/integrations/okta_integration.rb | 9 +++++++++ .../integrations/okta_integration_spec.rb | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/models/integrations/okta_integration.rb b/app/models/integrations/okta_integration.rb index b66dee5fde51..d049f153c545 100644 --- a/app/models/integrations/okta_integration.rb +++ b/app/models/integrations/okta_integration.rb @@ -4,6 +4,7 @@ module Integrations class OktaIntegration < BaseIntegration validates :client_secret, :client_id, :domain, :organization_name, presence: true validate :domain_uniqueness + validate :host_format settings_accessors :client_id, :domain, :organization_name, :host secrets_accessors :client_secret @@ -25,6 +26,14 @@ def domain_uniqueness errors.add(:domain, "domain_not_unique") if okta_integration end + + def host_format + configured_host = get_from_settings("host") + return if configured_host.blank? + + url_segment = /\A[a-zA-Z0-9.-]+\z/ + errors.add(:host, "host_invalid") unless configured_host.match?(url_segment) + end end end diff --git a/spec/models/integrations/okta_integration_spec.rb b/spec/models/integrations/okta_integration_spec.rb index 08363715c275..14c3a4755fd4 100644 --- a/spec/models/integrations/okta_integration_spec.rb +++ b/spec/models/integrations/okta_integration_spec.rb @@ -46,5 +46,22 @@ expect(okta_integration.errors).to include(:domain) end end + + context "when host contains unsafe URL characters" do + before { subject.host = "evil.com/path" } + + it "is invalid" do + expect(subject).not_to be_valid + expect(subject.errors).to include(:host) + end + end + + context "when host is a custom domain" do + before { subject.host = "login.acme.com" } + + it "is valid" do + expect(subject).to be_valid + end + end end end From e941ecdfe286db4c79bac4e094616f67d12aafd0 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 14 Aug 2026 13:00:01 +0100 Subject: [PATCH 4/5] fix(auth): bind SSO login to the integration's org ## Context The SSO login-method authorization checked whether the user belonged to any organization with the method enabled, rather than the organization that configured the integration being used. A user who belonged to one organization with the method enabled could therefore sign in through a different organization's integration even when that organization had the method disabled. This affected both Okta and Entra ID. ## Description Scope the check to the integration's organization: require the user to be an active member of that organization and that organization to have the login method enabled. Raised while reviewing the Okta cross-organization fix and applied to Entra ID for parity. --- app/services/auth/entra_id/login_service.rb | 9 +++++++- app/services/auth/okta/login_service.rb | 9 +++++++- .../auth/entra_id/login_service_spec.rb | 21 +++++++++++++++++++ spec/services/auth/okta/login_service_spec.rb | 21 +++++++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/app/services/auth/entra_id/login_service.rb b/app/services/auth/entra_id/login_service.rb index 2987b7dc5db8..e45bf26fc2b0 100644 --- a/app/services/auth/entra_id/login_service.rb +++ b/app/services/auth/entra_id/login_service.rb @@ -23,7 +23,7 @@ def call find_or_create_user find_or_create_membership - unless result.user.active_organizations.pluck(:authentication_methods).flatten.uniq.include?(Organizations::AuthenticationMethods::ENTRA_ID) + unless entra_id_enabled_for_login? return result.single_validation_failure!( error_code: "login_method_not_authorized", field: Organizations::AuthenticationMethods::ENTRA_ID @@ -41,6 +41,13 @@ def call attr_reader :code, :state + def entra_id_enabled_for_login? + organization = result.entra_id_integration.organization + + result.user.active_organizations.include?(organization) && + organization.authentication_methods.include?(Organizations::AuthenticationMethods::ENTRA_ID) + end + def generate_token result.token = Utils::AuthToken.encode(user: result.user, login_method: Organizations::AuthenticationMethods::ENTRA_ID) result diff --git a/app/services/auth/okta/login_service.rb b/app/services/auth/okta/login_service.rb index c118a405df37..eef94fe34aae 100644 --- a/app/services/auth/okta/login_service.rb +++ b/app/services/auth/okta/login_service.rb @@ -27,7 +27,7 @@ def call find_or_create_user find_or_create_membership - unless result.user.active_organizations.pluck(:authentication_methods).flatten.uniq.include?(Organizations::AuthenticationMethods::OKTA) + unless okta_enabled_for_login? return result.single_validation_failure!( error_code: "login_method_not_authorized", field: Organizations::AuthenticationMethods::OKTA @@ -52,6 +52,13 @@ def existing_user_outside_organization? !user.memberships.active.exists?(organization_id: result.okta_integration.organization_id) end + def okta_enabled_for_login? + organization = result.okta_integration.organization + + result.user.active_organizations.include?(organization) && + organization.authentication_methods.include?(Organizations::AuthenticationMethods::OKTA) + end + def generate_token result.token = Utils::AuthToken.encode(user: result.user, login_method: Organizations::AuthenticationMethods::OKTA) result diff --git a/spec/services/auth/entra_id/login_service_spec.rb b/spec/services/auth/entra_id/login_service_spec.rb index b1caf85a1480..c7578928be00 100644 --- a/spec/services/auth/entra_id/login_service_spec.rb +++ b/spec/services/auth/entra_id/login_service_spec.rb @@ -82,6 +82,27 @@ end end + context "when entra id is disabled on the integration's organization but enabled on another of the user's organizations" do + let(:user) { create(:user, email: "foo@bar.com") } + + before do + create(:membership, user:, organization: entra_id_integration.organization) + entra_id_integration.organization.disable_entra_id_authentication! + + other_organization = create(:organization) + other_organization.update!(premium_integrations: ["entra_id"]) + other_organization.enable_entra_id_authentication! + create(:membership, user:, organization: other_organization) + end + + it "returns error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages).to match(entra_id: ["login_method_not_authorized"]) + end + end + context "when domain is not configured with an integration" do let(:entra_id_integration) { nil } diff --git a/spec/services/auth/okta/login_service_spec.rb b/spec/services/auth/okta/login_service_spec.rb index 8a867739da97..9c23a300e5aa 100644 --- a/spec/services/auth/okta/login_service_spec.rb +++ b/spec/services/auth/okta/login_service_spec.rb @@ -82,6 +82,27 @@ end end + context "when okta is disabled on the integration's organization but enabled on another of the user's organizations" do + let(:user) { create(:user, email: "foo@bar.com") } + + before do + create(:membership, user:, organization: okta_integration.organization) + okta_integration.organization.disable_okta_authentication! + + other_organization = create(:organization) + other_organization.update!(premium_integrations: ["okta"]) + other_organization.enable_okta_authentication! + create(:membership, user:, organization: other_organization) + end + + it "does not authenticate the user" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages).to match(okta: ["login_method_not_authorized"]) + end + end + context "when domain is not configured with an integration" do let(:okta_integration) { nil } From 76177cd03be97370aacfed303ea49c1e50c25858 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Tue, 18 Aug 2026 13:39:12 +0100 Subject: [PATCH 5/5] fix(auth): block existing-user Okta invite takeover ## Context Auth::Okta::AcceptInviteService fetches the user identity from the integration's admin-configurable host, then accepts the invite through Invites::AcceptService, which mints a session for the invited email. An admin could create an invite for an unrelated existing user's email in their own organization and, using a host they control, accept it to receive a session as that user - the same cross-organization takeover already fixed in the login flow. Entra ID is unaffected because it fetches identity from a fixed Microsoft endpoint. ## Description Reject the Okta invite acceptance when the invited email belongs to an existing user who is not already an active member of the invite's organization, before contacting the host or minting a session. Brand-new users are still onboarded through the invite; adding an existing user to another organization must go through an authenticated flow. --- app/services/auth/okta/accept_invite_service.rb | 10 ++++++++++ .../auth/okta/accept_invite_service_spec.rb | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/app/services/auth/okta/accept_invite_service.rb b/app/services/auth/okta/accept_invite_service.rb index ae4376f3585a..91f7b00f275c 100644 --- a/app/services/auth/okta/accept_invite_service.rb +++ b/app/services/auth/okta/accept_invite_service.rb @@ -18,6 +18,9 @@ def call check_code check_okta_integration(result.email) check_invite(result.email) + + raise ValidationError, "existing_user_must_authenticate" if existing_user_outside_organization? + query_okta_access_token check_userinfo(result.email) @@ -38,6 +41,13 @@ def call private attr_reader :invite_token, :code, :state + + def existing_user_outside_organization? + user = User.find_by(email: result.email) + return false if user.nil? + + !user.memberships.active.exists?(organization_id: result.invite.organization_id) + end end end end diff --git a/spec/services/auth/okta/accept_invite_service_spec.rb b/spec/services/auth/okta/accept_invite_service_spec.rb index 5e21c0463e35..470bb1e3daf4 100644 --- a/spec/services/auth/okta/accept_invite_service_spec.rb +++ b/spec/services/auth/okta/accept_invite_service_spec.rb @@ -108,5 +108,22 @@ expect(result.error.messages.values.flatten).to include("okta_userinfo_error") end end + + context "when the invited email already belongs to an existing user" do + before { create(:user, email: "foo@bar.com") } + + it "does not authenticate the user" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("existing_user_must_authenticate") + end + + it "does not accept the invite" do + service.call + + expect(invite.reload).to be_pending + end + end end end