mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
feat: comprehensive SSO/OIDC upgrade with enterprise features
Multi-provider SSO support: - Database-backed SSO provider management with admin UI - Support for OpenID Connect, Google OAuth2, GitHub, and SAML 2.0 - Flipper feature flag (db_sso_providers) for dynamic provider loading - ProviderLoader service for YAML or database configuration Admin functionality: - Admin::SsoProvidersController for CRUD operations - Admin::UsersController for super_admin role management - Pundit policies for authorization - Test connection endpoint for validating provider config User provisioning improvements: - JIT (just-in-time) account creation with configurable default role - Changed default JIT role from admin to member (security) - User attribute sync on each SSO login - Group/role mapping from IdP claims SSO identity management: - Settings::SsoIdentitiesController for users to manage connected accounts - Issuer validation for OIDC identities - Unlink protection when no password set Audit logging: - SsoAuditLog model tracking login, logout, link, unlink, JIT creation - Captures IP address, user agent, and metadata Advanced OIDC features: - Custom scopes per provider - Configurable prompt parameter (login, consent, select_account, none) - RP-initiated logout (federated logout to IdP) - id_token storage for logout SAML 2.0 support: - omniauth-saml gem integration - IdP metadata URL or manual configuration - Certificate and fingerprint validation - NameID format configuration
This commit is contained in:
@@ -166,7 +166,7 @@ class OidcAccountsControllerTest < ActionController::TestCase
|
||||
assert_not_nil new_user
|
||||
assert_equal new_user_auth["first_name"], new_user.first_name
|
||||
assert_equal new_user_auth["last_name"], new_user.last_name
|
||||
assert_equal "admin", new_user.role
|
||||
assert_equal "member", new_user.role
|
||||
|
||||
# Verify OIDC identity was created
|
||||
oidc_identity = new_user.oidc_identities.first
|
||||
|
||||
263
test/models/sso_provider_test.rb
Normal file
263
test/models/sso_provider_test.rb
Normal file
@@ -0,0 +1,263 @@
|
||||
require "test_helper"
|
||||
|
||||
class SsoProviderTest < ActiveSupport::TestCase
|
||||
test "valid provider with all required fields" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "openid_connect",
|
||||
name: "test_oidc",
|
||||
label: "Test OIDC",
|
||||
enabled: true,
|
||||
issuer: "https://test.example.com",
|
||||
client_id: "test_client",
|
||||
client_secret: "test_secret"
|
||||
)
|
||||
assert provider.valid?
|
||||
end
|
||||
|
||||
test "requires strategy" do
|
||||
provider = SsoProvider.new(name: "test", label: "Test")
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:strategy], "can't be blank"
|
||||
end
|
||||
|
||||
test "requires name" do
|
||||
provider = SsoProvider.new(strategy: "openid_connect", label: "Test")
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "requires label" do
|
||||
provider = SsoProvider.new(strategy: "openid_connect", name: "test")
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:label], "can't be blank"
|
||||
end
|
||||
|
||||
test "requires unique name" do
|
||||
SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
name: "duplicate",
|
||||
label: "First",
|
||||
client_id: "id1",
|
||||
client_secret: "secret1",
|
||||
issuer: "https://first.example.com"
|
||||
)
|
||||
|
||||
provider = SsoProvider.new(
|
||||
strategy: "google_oauth2",
|
||||
name: "duplicate",
|
||||
label: "Second",
|
||||
client_id: "id2",
|
||||
client_secret: "secret2"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:name], "has already been taken"
|
||||
end
|
||||
|
||||
test "validates name format" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "openid_connect",
|
||||
name: "Invalid-Name!",
|
||||
label: "Test",
|
||||
client_id: "test",
|
||||
client_secret: "secret",
|
||||
issuer: "https://test.example.com"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:name], "must contain only lowercase letters, numbers, and underscores"
|
||||
end
|
||||
|
||||
test "validates strategy inclusion" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "invalid_strategy",
|
||||
name: "test",
|
||||
label: "Test"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:strategy], "invalid_strategy is not a supported strategy"
|
||||
end
|
||||
|
||||
test "encrypts client_secret" do
|
||||
provider = SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
name: "encrypted_test",
|
||||
label: "Encrypted Test",
|
||||
client_id: "test_client",
|
||||
client_secret: "super_secret_value",
|
||||
issuer: "https://test.example.com"
|
||||
)
|
||||
|
||||
# Reload from database
|
||||
provider.reload
|
||||
|
||||
# Should be able to read decrypted value
|
||||
assert_equal "super_secret_value", provider.client_secret
|
||||
|
||||
# Raw database value should be encrypted (not plain text)
|
||||
raw_value = ActiveRecord::Base.connection.execute(
|
||||
"SELECT client_secret FROM sso_providers WHERE id = '#{provider.id}'"
|
||||
).first["client_secret"]
|
||||
|
||||
assert_not_equal "super_secret_value", raw_value
|
||||
end
|
||||
|
||||
test "OIDC provider requires issuer" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "openid_connect",
|
||||
name: "test_oidc",
|
||||
label: "Test",
|
||||
client_id: "test",
|
||||
client_secret: "secret"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:issuer], "is required for OpenID Connect providers"
|
||||
end
|
||||
|
||||
test "OIDC provider requires client_id" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "openid_connect",
|
||||
name: "test_oidc",
|
||||
label: "Test",
|
||||
issuer: "https://test.example.com",
|
||||
client_secret: "secret"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:client_id], "is required for OpenID Connect providers"
|
||||
end
|
||||
|
||||
test "OIDC provider requires client_secret" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "openid_connect",
|
||||
name: "test_oidc",
|
||||
label: "Test",
|
||||
issuer: "https://test.example.com",
|
||||
client_id: "test"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:client_secret], "is required for OpenID Connect providers"
|
||||
end
|
||||
|
||||
test "OIDC provider validates issuer URL format" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "openid_connect",
|
||||
name: "test_oidc",
|
||||
label: "Test",
|
||||
issuer: "not-a-valid-url",
|
||||
client_id: "test",
|
||||
client_secret: "secret"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:issuer], "must be a valid URL"
|
||||
end
|
||||
|
||||
test "OAuth provider requires client_id" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "google_oauth2",
|
||||
name: "test_google",
|
||||
label: "Test",
|
||||
client_secret: "secret"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:client_id], "is required for OAuth providers"
|
||||
end
|
||||
|
||||
test "OAuth provider requires client_secret" do
|
||||
provider = SsoProvider.new(
|
||||
strategy: "google_oauth2",
|
||||
name: "test_google",
|
||||
label: "Test",
|
||||
client_id: "test"
|
||||
)
|
||||
|
||||
assert_not provider.valid?
|
||||
assert_includes provider.errors[:client_secret], "is required for OAuth providers"
|
||||
end
|
||||
|
||||
test "enabled scope returns only enabled providers" do
|
||||
enabled = SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
name: "enabled_provider",
|
||||
label: "Enabled",
|
||||
enabled: true,
|
||||
client_id: "test",
|
||||
client_secret: "secret",
|
||||
issuer: "https://enabled.example.com"
|
||||
)
|
||||
|
||||
SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
name: "disabled_provider",
|
||||
label: "Disabled",
|
||||
enabled: false,
|
||||
client_id: "test",
|
||||
client_secret: "secret",
|
||||
issuer: "https://disabled.example.com"
|
||||
)
|
||||
|
||||
assert_includes SsoProvider.enabled, enabled
|
||||
assert_equal 1, SsoProvider.enabled.count
|
||||
end
|
||||
|
||||
test "by_strategy scope filters by strategy" do
|
||||
oidc = SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
name: "oidc_provider",
|
||||
label: "OIDC",
|
||||
client_id: "test",
|
||||
client_secret: "secret",
|
||||
issuer: "https://oidc.example.com"
|
||||
)
|
||||
|
||||
SsoProvider.create!(
|
||||
strategy: "google_oauth2",
|
||||
name: "google_provider",
|
||||
label: "Google",
|
||||
client_id: "test",
|
||||
client_secret: "secret"
|
||||
)
|
||||
|
||||
oidc_providers = SsoProvider.by_strategy("openid_connect")
|
||||
assert_includes oidc_providers, oidc
|
||||
assert_equal 1, oidc_providers.count
|
||||
end
|
||||
|
||||
test "to_omniauth_config returns correct hash" do
|
||||
provider = SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
name: "test_oidc",
|
||||
label: "Test OIDC",
|
||||
icon: "key",
|
||||
enabled: true,
|
||||
issuer: "https://test.example.com",
|
||||
client_id: "test_client",
|
||||
client_secret: "test_secret",
|
||||
redirect_uri: "https://app.example.com/callback",
|
||||
settings: { scope: "openid email" }
|
||||
)
|
||||
|
||||
config = provider.to_omniauth_config
|
||||
|
||||
assert_equal "test_oidc", config[:id]
|
||||
assert_equal "openid_connect", config[:strategy]
|
||||
assert_equal "test_oidc", config[:name]
|
||||
assert_equal "Test OIDC", config[:label]
|
||||
assert_equal "key", config[:icon]
|
||||
assert_equal "https://test.example.com", config[:issuer]
|
||||
assert_equal "test_client", config[:client_id]
|
||||
assert_equal "test_secret", config[:client_secret]
|
||||
assert_equal "https://app.example.com/callback", config[:redirect_uri]
|
||||
assert_equal({ "scope" => "openid email" }, config[:settings])
|
||||
end
|
||||
|
||||
# Note: OIDC discovery validation tests are skipped in test environment
|
||||
# Discovery validation is disabled in test mode to avoid VCR cassette requirements
|
||||
# In production, the validate_oidc_discovery method will validate the issuer's
|
||||
# .well-known/openid-configuration endpoint
|
||||
end
|
||||
111
test/policies/sso_provider_policy_test.rb
Normal file
111
test/policies/sso_provider_policy_test.rb
Normal file
@@ -0,0 +1,111 @@
|
||||
require "test_helper"
|
||||
|
||||
class SsoProviderPolicyTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@super_admin = users(:family_admin) # Assuming this fixture has super_admin role
|
||||
@super_admin.update!(role: :super_admin)
|
||||
|
||||
@regular_user = users(:family_member)
|
||||
@regular_user.update!(role: :member)
|
||||
|
||||
@provider = SsoProvider.create!(
|
||||
strategy: "openid_connect",
|
||||
name: "test_provider",
|
||||
label: "Test Provider",
|
||||
client_id: "test",
|
||||
client_secret: "secret",
|
||||
issuer: "https://test.example.com"
|
||||
)
|
||||
end
|
||||
|
||||
test "super admin can view index" do
|
||||
assert SsoProviderPolicy.new(@super_admin, SsoProvider).index?
|
||||
end
|
||||
|
||||
test "regular user cannot view index" do
|
||||
assert_not SsoProviderPolicy.new(@regular_user, SsoProvider).index?
|
||||
end
|
||||
|
||||
test "nil user cannot view index" do
|
||||
assert_not SsoProviderPolicy.new(nil, SsoProvider).index?
|
||||
end
|
||||
|
||||
test "super admin can show provider" do
|
||||
assert SsoProviderPolicy.new(@super_admin, @provider).show?
|
||||
end
|
||||
|
||||
test "regular user cannot show provider" do
|
||||
assert_not SsoProviderPolicy.new(@regular_user, @provider).show?
|
||||
end
|
||||
|
||||
test "super admin can create provider" do
|
||||
assert SsoProviderPolicy.new(@super_admin, SsoProvider.new).create?
|
||||
end
|
||||
|
||||
test "regular user cannot create provider" do
|
||||
assert_not SsoProviderPolicy.new(@regular_user, SsoProvider.new).create?
|
||||
end
|
||||
|
||||
test "super admin can access new" do
|
||||
assert SsoProviderPolicy.new(@super_admin, SsoProvider.new).new?
|
||||
end
|
||||
|
||||
test "regular user cannot access new" do
|
||||
assert_not SsoProviderPolicy.new(@regular_user, SsoProvider.new).new?
|
||||
end
|
||||
|
||||
test "super admin can update provider" do
|
||||
assert SsoProviderPolicy.new(@super_admin, @provider).update?
|
||||
end
|
||||
|
||||
test "regular user cannot update provider" do
|
||||
assert_not SsoProviderPolicy.new(@regular_user, @provider).update?
|
||||
end
|
||||
|
||||
test "super admin can access edit" do
|
||||
assert SsoProviderPolicy.new(@super_admin, @provider).edit?
|
||||
end
|
||||
|
||||
test "regular user cannot access edit" do
|
||||
assert_not SsoProviderPolicy.new(@regular_user, @provider).edit?
|
||||
end
|
||||
|
||||
test "super admin can destroy provider" do
|
||||
assert SsoProviderPolicy.new(@super_admin, @provider).destroy?
|
||||
end
|
||||
|
||||
test "regular user cannot destroy provider" do
|
||||
assert_not SsoProviderPolicy.new(@regular_user, @provider).destroy?
|
||||
end
|
||||
|
||||
test "super admin can toggle provider" do
|
||||
assert SsoProviderPolicy.new(@super_admin, @provider).toggle?
|
||||
end
|
||||
|
||||
test "regular user cannot toggle provider" do
|
||||
assert_not SsoProviderPolicy.new(@regular_user, @provider).toggle?
|
||||
end
|
||||
|
||||
test "scope returns all providers for super admin" do
|
||||
SsoProvider.create!(
|
||||
strategy: "google_oauth2",
|
||||
name: "google",
|
||||
label: "Google",
|
||||
client_id: "test",
|
||||
client_secret: "secret"
|
||||
)
|
||||
|
||||
scope = SsoProviderPolicy::Scope.new(@super_admin, SsoProvider).resolve
|
||||
assert_equal 2, scope.count
|
||||
end
|
||||
|
||||
test "scope returns no providers for regular user" do
|
||||
scope = SsoProviderPolicy::Scope.new(@regular_user, SsoProvider).resolve
|
||||
assert_equal 0, scope.count
|
||||
end
|
||||
|
||||
test "scope returns no providers for nil user" do
|
||||
scope = SsoProviderPolicy::Scope.new(nil, SsoProvider).resolve
|
||||
assert_equal 0, scope.count
|
||||
end
|
||||
end
|
||||
59
test/policies/user_policy_test.rb
Normal file
59
test/policies/user_policy_test.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class UserPolicyTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@super_admin = users(:family_admin)
|
||||
@super_admin.update!(role: :super_admin)
|
||||
|
||||
@regular_user = users(:family_member)
|
||||
@regular_user.update!(role: :member)
|
||||
|
||||
@other_user = users(:sure_support_staff)
|
||||
@other_user.update!(role: :member)
|
||||
end
|
||||
|
||||
test "super admin can view index" do
|
||||
assert UserPolicy.new(@super_admin, User).index?
|
||||
end
|
||||
|
||||
test "regular user cannot view index" do
|
||||
assert_not UserPolicy.new(@regular_user, User).index?
|
||||
end
|
||||
|
||||
test "nil user cannot view index" do
|
||||
assert_not UserPolicy.new(nil, User).index?
|
||||
end
|
||||
|
||||
test "super admin can update another user" do
|
||||
assert UserPolicy.new(@super_admin, @regular_user).update?
|
||||
end
|
||||
|
||||
test "super admin cannot update themselves" do
|
||||
assert_not UserPolicy.new(@super_admin, @super_admin).update?
|
||||
end
|
||||
|
||||
test "regular user cannot update anyone" do
|
||||
assert_not UserPolicy.new(@regular_user, @other_user).update?
|
||||
end
|
||||
|
||||
test "nil user cannot update anyone" do
|
||||
assert_not UserPolicy.new(nil, @regular_user).update?
|
||||
end
|
||||
|
||||
test "scope returns all users for super admin" do
|
||||
scope = UserPolicy::Scope.new(@super_admin, User).resolve
|
||||
assert_equal User.count, scope.count
|
||||
end
|
||||
|
||||
test "scope returns no users for regular user" do
|
||||
scope = UserPolicy::Scope.new(@regular_user, User).resolve
|
||||
assert_equal 0, scope.count
|
||||
end
|
||||
|
||||
test "scope returns no users for nil user" do
|
||||
scope = UserPolicy::Scope.new(nil, User).resolve
|
||||
assert_equal 0, scope.count
|
||||
end
|
||||
end
|
||||
@@ -22,6 +22,7 @@ require "minitest/mock"
|
||||
require "minitest/autorun"
|
||||
require "mocha/minitest"
|
||||
require "aasm/minitest"
|
||||
require "webmock/minitest"
|
||||
|
||||
VCR.configure do |config|
|
||||
config.cassette_library_dir = "test/vcr_cassettes"
|
||||
|
||||
Reference in New Issue
Block a user