mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 10:12:34 +00:00
feat: add saml model & controller [CW-2958] (#12289)
This PR adds the foundation for account-level SAML SSO configuration in Chatwoot Enterprise. It introduces a new `AccountSamlSettings` model and management API that allows accounts to configure their own SAML identity providers independently, this also includes the certificate generation flow The implementation includes a new controller (`Api::V1::Accounts::SamlSettingsController`) that provides CRUD operations for SAML configuration The feature is properly gated behind the 'saml' feature flag and includes administrator-only authorization via Pundit policies.
This commit is contained in:
@@ -204,3 +204,7 @@
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: saml
|
||||
display_name: SAML
|
||||
enabled: false
|
||||
premium: true
|
||||
|
||||
@@ -55,6 +55,8 @@ en:
|
||||
failed: Signup failed
|
||||
assignment_policy:
|
||||
not_found: Assignment policy not found
|
||||
saml:
|
||||
feature_not_enabled: SAML feature not enabled for this account
|
||||
data_import:
|
||||
data_type:
|
||||
invalid: Invalid data type
|
||||
|
||||
@@ -69,6 +69,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
end
|
||||
resource :saml_settings, only: [:show, :create, :update, :destroy]
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
delete :avatar, on: :member
|
||||
post :reset_access_token, on: :member
|
||||
|
||||
14
db/migrate/20250825070005_create_account_saml_settings.rb
Normal file
14
db/migrate/20250825070005_create_account_saml_settings.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class CreateAccountSamlSettings < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :account_saml_settings do |t|
|
||||
t.references :account, null: false
|
||||
t.string :sso_url
|
||||
t.text :certificate
|
||||
t.string :sp_entity_id
|
||||
t.string :idp_entity_id
|
||||
t.json :role_mappings, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
14
db/schema.rb
14
db/schema.rb
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_08_25_070005) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -28,6 +28,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do
|
||||
t.index ["token"], name: "index_access_tokens_on_token", unique: true
|
||||
end
|
||||
|
||||
create_table "account_saml_settings", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "sso_url"
|
||||
t.text "certificate"
|
||||
t.string "sp_entity_id"
|
||||
t.string "idp_entity_id"
|
||||
t.json "role_mappings", default: {}
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_account_saml_settings_on_account_id"
|
||||
end
|
||||
|
||||
create_table "account_users", force: :cascade do |t|
|
||||
t.bigint "account_id"
|
||||
t.bigint "user_id"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
class Api::V1::Accounts::SamlSettingsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_saml_feature_enabled
|
||||
before_action :check_authorization
|
||||
before_action :set_saml_settings
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@saml_settings = Current.account.build_saml_settings(saml_settings_params)
|
||||
@saml_settings.save!
|
||||
end
|
||||
|
||||
def update
|
||||
@saml_settings.update!(saml_settings_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@saml_settings.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_saml_settings
|
||||
@saml_settings = Current.account.saml_settings ||
|
||||
Current.account.build_saml_settings
|
||||
end
|
||||
|
||||
def saml_settings_params
|
||||
params.require(:saml_settings).permit(
|
||||
:sso_url,
|
||||
:certificate,
|
||||
:idp_entity_id,
|
||||
:sp_entity_id,
|
||||
role_mappings: {}
|
||||
)
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(AccountSamlSettings)
|
||||
end
|
||||
|
||||
def check_saml_feature_enabled
|
||||
return if Current.account.feature_enabled?('saml')
|
||||
|
||||
render json: { error: I18n.t('errors.saml.feature_not_enabled') }, status: :forbidden
|
||||
end
|
||||
end
|
||||
59
enterprise/app/models/account_saml_settings.rb
Normal file
59
enterprise/app/models/account_saml_settings.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_saml_settings
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# certificate :text
|
||||
# role_mappings :json
|
||||
# sso_url :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# idp_entity_id :string
|
||||
# sp_entity_id :string
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_account_saml_settings_on_account_id (account_id)
|
||||
#
|
||||
class AccountSamlSettings < ApplicationRecord
|
||||
belongs_to :account
|
||||
|
||||
validates :account_id, presence: true
|
||||
validates :sso_url, presence: true
|
||||
validates :certificate, presence: true
|
||||
validates :idp_entity_id, presence: true
|
||||
|
||||
before_validation :set_sp_entity_id, if: :sp_entity_id_needs_generation?
|
||||
|
||||
def saml_enabled?
|
||||
sso_url.present? && certificate.present?
|
||||
end
|
||||
|
||||
def certificate_fingerprint
|
||||
return nil if certificate.blank?
|
||||
|
||||
begin
|
||||
cert = OpenSSL::X509::Certificate.new(certificate)
|
||||
OpenSSL::Digest::SHA1.new(cert.to_der).hexdigest
|
||||
.upcase.gsub(/(.{2})(?=.)/, '\1:')
|
||||
rescue OpenSSL::X509::CertificateError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_sp_entity_id
|
||||
base_url = GlobalConfigService.load('FRONTEND_URL', 'http://localhost:3000')
|
||||
self.sp_entity_id = "#{base_url}/saml/sp/#{account_id}"
|
||||
end
|
||||
|
||||
def sp_entity_id_needs_generation?
|
||||
sp_entity_id.blank?
|
||||
end
|
||||
|
||||
def installation_name
|
||||
GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot')
|
||||
end
|
||||
end
|
||||
@@ -27,4 +27,8 @@ module Enterprise::Account
|
||||
def unmark_for_deletion
|
||||
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
|
||||
end
|
||||
|
||||
def saml_enabled?
|
||||
saml_settings&.saml_enabled? || false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,5 +13,7 @@ module Enterprise::Concerns::Account
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
|
||||
has_one :saml_settings, dependent: :destroy_async, class_name: 'AccountSamlSettings'
|
||||
end
|
||||
end
|
||||
|
||||
17
enterprise/app/policies/account_saml_settings_policy.rb
Normal file
17
enterprise/app/policies/account_saml_settings_policy.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class AccountSamlSettingsPolicy < ApplicationPolicy
|
||||
def show?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/account_saml_settings', account_saml_settings: @saml_settings
|
||||
@@ -0,0 +1,10 @@
|
||||
json.id account_saml_settings.id
|
||||
json.account_id account_saml_settings.account_id
|
||||
json.sso_url account_saml_settings.sso_url
|
||||
json.certificate account_saml_settings.certificate
|
||||
json.fingerprint account_saml_settings.certificate_fingerprint
|
||||
json.idp_entity_id account_saml_settings.idp_entity_id
|
||||
json.sp_entity_id account_saml_settings.sp_entity_id
|
||||
json.role_mappings account_saml_settings.role_mappings || {}
|
||||
json.created_at account_saml_settings.created_at
|
||||
json.updated_at account_saml_settings.updated_at
|
||||
@@ -0,0 +1,265 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::SamlSettings', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
account.enable_features('saml')
|
||||
account.save!
|
||||
end
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/saml_settings' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as administrator' do
|
||||
context 'when SAML settings exist' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: 'https://idp.example.com/saml/sso',
|
||||
role_mappings: { 'Admins' => { 'role' => 1 } })
|
||||
end
|
||||
|
||||
before do
|
||||
saml_settings # Ensure the record exists
|
||||
end
|
||||
|
||||
it 'returns the SAML settings' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:sso_url]).to eq('https://idp.example.com/saml/sso')
|
||||
expect(json_response[:role_mappings]).to eq({ Admins: { role: 1 } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SAML settings do not exist' do
|
||||
it 'returns default SAML settings' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:role_mappings]).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SAML feature is not enabled' do
|
||||
before do
|
||||
account.disable_features('saml')
|
||||
account.save!
|
||||
end
|
||||
|
||||
it 'returns forbidden with feature not enabled message' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/saml_settings' do
|
||||
let(:valid_params) do
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
cert = OpenSSL::X509::Certificate.new
|
||||
cert.version = 2
|
||||
cert.serial = 1
|
||||
cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=test.example.com')
|
||||
cert.issuer = cert.subject
|
||||
cert.public_key = key.public_key
|
||||
cert.not_before = Time.zone.now
|
||||
cert.not_after = cert.not_before + (365 * 24 * 60 * 60)
|
||||
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
||||
|
||||
{
|
||||
saml_settings: {
|
||||
sso_url: 'https://idp.example.com/saml/sso',
|
||||
certificate: cert.to_pem,
|
||||
idp_entity_id: 'https://idp.example.com/saml/metadata',
|
||||
role_mappings: { 'Admins' => { 'role' => 1 }, 'Users' => { 'role' => 0 } }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/saml_settings", params: valid_params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as administrator' do
|
||||
context 'with valid parameters' do
|
||||
it 'creates SAML settings' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: valid_params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(AccountSamlSettings, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
saml_settings = AccountSamlSettings.find_by(account: account)
|
||||
expect(saml_settings.sso_url).to eq('https://idp.example.com/saml/sso')
|
||||
expect(saml_settings.role_mappings).to eq({ 'Admins' => { 'role' => 1 }, 'Users' => { 'role' => 0 } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_params) do
|
||||
valid_params.tap do |params|
|
||||
params[:saml_settings][:sso_url] = nil
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity' do
|
||||
post "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: invalid_params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(AccountSamlSettings.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(AccountSamlSettings.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}/saml_settings' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: 'https://old.example.com/saml')
|
||||
end
|
||||
let(:update_params) do
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
cert = OpenSSL::X509::Certificate.new
|
||||
cert.version = 2
|
||||
cert.serial = 3
|
||||
cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=update.example.com')
|
||||
cert.issuer = cert.subject
|
||||
cert.public_key = key.public_key
|
||||
cert.not_before = Time.zone.now
|
||||
cert.not_after = cert.not_before + (365 * 24 * 60 * 60)
|
||||
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
||||
|
||||
{
|
||||
saml_settings: {
|
||||
sso_url: 'https://new.example.com/saml/sso',
|
||||
certificate: cert.to_pem,
|
||||
role_mappings: { 'NewGroup' => { 'custom_role_id' => 5 } }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
saml_settings # Ensure the record exists
|
||||
end
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/saml_settings", params: update_params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as administrator' do
|
||||
it 'updates SAML settings' do
|
||||
put "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: update_params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
saml_settings.reload
|
||||
expect(saml_settings.sso_url).to eq('https://new.example.com/saml/sso')
|
||||
expect(saml_settings.role_mappings).to eq({ 'NewGroup' => { 'custom_role_id' => 5 } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/saml_settings' do
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
|
||||
before do
|
||||
saml_settings # Ensure the record exists
|
||||
end
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/saml_settings"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as administrator' do
|
||||
it 'destroys SAML settings' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: administrator.create_new_auth_token
|
||||
end.to change(AccountSamlSettings, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(AccountSamlSettings.count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
117
spec/enterprise/models/account_saml_settings_spec.rb
Normal file
117
spec/enterprise/models/account_saml_settings_spec.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AccountSamlSettings, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:saml_settings) { build(:account_saml_settings, account: account) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'requires sso_url' do
|
||||
settings = build(:account_saml_settings, account: account, sso_url: nil)
|
||||
expect(settings).not_to be_valid
|
||||
expect(settings.errors[:sso_url]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'requires certificate' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: nil)
|
||||
expect(settings).not_to be_valid
|
||||
expect(settings.errors[:certificate]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'requires idp_entity_id' do
|
||||
settings = build(:account_saml_settings, account: account, idp_entity_id: nil)
|
||||
expect(settings).not_to be_valid
|
||||
expect(settings.errors[:idp_entity_id]).to include("can't be blank")
|
||||
end
|
||||
end
|
||||
|
||||
describe '#saml_enabled?' do
|
||||
it 'returns true when required fields are present' do
|
||||
settings = build(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: 'https://example.com/sso',
|
||||
certificate: 'valid-certificate')
|
||||
expect(settings.saml_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when sso_url is missing' do
|
||||
settings = build(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: nil,
|
||||
certificate: 'valid-certificate')
|
||||
expect(settings.saml_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when certificate is missing' do
|
||||
settings = build(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: 'https://example.com/sso',
|
||||
certificate: nil)
|
||||
expect(settings.saml_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sp_entity_id auto-generation' do
|
||||
it 'automatically generates sp_entity_id when creating' do
|
||||
settings = build(:account_saml_settings, account: account, sp_entity_id: nil)
|
||||
expect(settings).to be_valid
|
||||
settings.save!
|
||||
expect(settings.sp_entity_id).to eq("http://localhost:3000/saml/sp/#{account.id}")
|
||||
end
|
||||
|
||||
it 'does not override existing sp_entity_id' do
|
||||
custom_id = 'https://custom.example.com/saml/sp/123'
|
||||
settings = build(:account_saml_settings, account: account, sp_entity_id: custom_id)
|
||||
settings.save!
|
||||
expect(settings.sp_entity_id).to eq(custom_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#certificate_fingerprint' do
|
||||
let(:valid_cert_pem) do
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
cert = OpenSSL::X509::Certificate.new
|
||||
cert.version = 2
|
||||
cert.serial = 1
|
||||
cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=test.example.com')
|
||||
cert.issuer = cert.subject
|
||||
cert.public_key = key.public_key
|
||||
cert.not_before = Time.zone.now
|
||||
cert.not_after = cert.not_before + (365 * 24 * 60 * 60)
|
||||
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
||||
cert.to_pem
|
||||
end
|
||||
|
||||
it 'returns fingerprint for valid certificate' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: valid_cert_pem)
|
||||
fingerprint = settings.certificate_fingerprint
|
||||
|
||||
expect(fingerprint).to be_present
|
||||
expect(fingerprint).to match(/^[A-F0-9]{2}(:[A-F0-9]{2}){19}$/) # SHA1 fingerprint format
|
||||
end
|
||||
|
||||
it 'returns nil for blank certificate' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: '')
|
||||
expect(settings.certificate_fingerprint).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for invalid certificate' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: 'invalid-cert-data')
|
||||
expect(settings.certificate_fingerprint).to be_nil
|
||||
end
|
||||
|
||||
it 'formats fingerprint correctly' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: valid_cert_pem)
|
||||
fingerprint = settings.certificate_fingerprint
|
||||
|
||||
# Should be uppercase with colons separating each byte
|
||||
expect(fingerprint).to match(/^[A-F0-9:]+$/)
|
||||
expect(fingerprint.count(':')).to eq(19) # 20 bytes = 19 colons
|
||||
end
|
||||
end
|
||||
end
|
||||
31
spec/factories/account_saml_settings.rb
Normal file
31
spec/factories/account_saml_settings.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
FactoryBot.define do
|
||||
factory :account_saml_settings do
|
||||
account
|
||||
sso_url { 'https://idp.example.com/saml/sso' }
|
||||
certificate do
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
cert = OpenSSL::X509::Certificate.new
|
||||
cert.version = 2
|
||||
cert.serial = 1
|
||||
cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=test.example.com')
|
||||
cert.issuer = cert.subject
|
||||
cert.public_key = key.public_key
|
||||
cert.not_before = Time.zone.now
|
||||
cert.not_after = cert.not_before + (365 * 24 * 60 * 60)
|
||||
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
||||
cert.to_pem
|
||||
end
|
||||
idp_entity_id { 'https://idp.example.com/saml/metadata' }
|
||||
role_mappings { {} }
|
||||
|
||||
trait :with_role_mappings do
|
||||
role_mappings do
|
||||
{
|
||||
'Administrators' => { 'role' => 1 },
|
||||
'Agents' => { 'role' => 0 },
|
||||
'Custom-Team' => { 'custom_role_id' => 5 }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user