Merge pull request #538 from luckyPipewrench/sso-upgrades

Multi-provider SSO with admin UI and SAML support
This commit is contained in:
soky srm
2026-01-12 15:38:59 +01:00
committed by GitHub
50 changed files with 3273 additions and 34 deletions

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
module Admin
class BaseController < ApplicationController
before_action :require_super_admin!
layout "settings"
private
def require_super_admin!
unless Current.user&.super_admin?
redirect_to root_path, alert: t("admin.unauthorized")
end
end
end
end

View File

@@ -0,0 +1,157 @@
# frozen_string_literal: true
module Admin
class SsoProvidersController < Admin::BaseController
before_action :set_sso_provider, only: %i[show edit update destroy toggle test_connection]
def index
authorize SsoProvider
@sso_providers = policy_scope(SsoProvider).order(:name)
end
def show
authorize @sso_provider
end
def new
@sso_provider = SsoProvider.new
authorize @sso_provider
end
def create
@sso_provider = SsoProvider.new(processed_params)
authorize @sso_provider
# Auto-generate redirect_uri if not provided
if @sso_provider.redirect_uri.blank? && @sso_provider.name.present?
@sso_provider.redirect_uri = "#{request.base_url}/auth/#{@sso_provider.name}/callback"
end
if @sso_provider.save
log_provider_change(:create, @sso_provider)
clear_provider_cache
redirect_to admin_sso_providers_path, notice: t(".success")
else
render :new, status: :unprocessable_entity
end
end
def edit
authorize @sso_provider
end
def update
authorize @sso_provider
# Auto-update redirect_uri if name changed
params_hash = processed_params.to_h
if params_hash[:name].present? && params_hash[:name] != @sso_provider.name
params_hash[:redirect_uri] = "#{request.base_url}/auth/#{params_hash[:name]}/callback"
end
if @sso_provider.update(params_hash)
log_provider_change(:update, @sso_provider)
clear_provider_cache
redirect_to admin_sso_providers_path, notice: t(".success")
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @sso_provider
@sso_provider.destroy!
log_provider_change(:destroy, @sso_provider)
clear_provider_cache
redirect_to admin_sso_providers_path, notice: t(".success")
end
def toggle
authorize @sso_provider
@sso_provider.update!(enabled: !@sso_provider.enabled)
log_provider_change(:toggle, @sso_provider)
clear_provider_cache
notice = @sso_provider.enabled? ? t(".success_enabled") : t(".success_disabled")
redirect_to admin_sso_providers_path, notice: notice
end
def test_connection
authorize @sso_provider
tester = SsoProviderTester.new(@sso_provider)
result = tester.test!
render json: {
success: result.success?,
message: result.message,
details: result.details
}
end
private
def set_sso_provider
@sso_provider = SsoProvider.find(params[:id])
end
def sso_provider_params
params.require(:sso_provider).permit(
:strategy,
:name,
:label,
:icon,
:enabled,
:issuer,
:client_id,
:client_secret,
:redirect_uri,
:scopes,
:prompt,
settings: [
:default_role, :scopes, :prompt,
# SAML settings
:idp_metadata_url, :idp_sso_url, :idp_slo_url,
:idp_certificate, :idp_cert_fingerprint, :name_id_format,
role_mapping: {}
]
)
end
# Process params to convert role_mapping comma-separated strings to arrays
def processed_params
result = sso_provider_params.to_h
if result[:settings].present? && result[:settings][:role_mapping].present?
result[:settings][:role_mapping] = result[:settings][:role_mapping].transform_values do |v|
# Convert comma-separated string to array, removing empty values
v.to_s.split(",").map(&:strip).reject(&:blank?)
end
# Remove empty role mappings
result[:settings][:role_mapping] = result[:settings][:role_mapping].reject { |_, v| v.empty? }
result[:settings].delete(:role_mapping) if result[:settings][:role_mapping].empty?
end
result
end
def log_provider_change(action, provider)
Rails.logger.info(
"[Admin::SsoProviders] #{action.to_s.upcase} - " \
"user_id=#{Current.user.id} " \
"provider_id=#{provider.id} " \
"provider_name=#{provider.name} " \
"strategy=#{provider.strategy} " \
"enabled=#{provider.enabled}"
)
end
def clear_provider_cache
ProviderLoader.clear_cache
Rails.logger.info("[Admin::SsoProviders] Provider cache cleared by user_id=#{Current.user.id}")
end
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Admin
class UsersController < Admin::BaseController
before_action :set_user, only: %i[update]
def index
authorize User
@users = policy_scope(User).order(:email)
end
def update
authorize @user
if @user.update(user_params)
Rails.logger.info(
"[Admin::Users] Role changed - " \
"by_user_id=#{Current.user.id} " \
"target_user_id=#{@user.id} " \
"new_role=#{@user.role}"
)
redirect_to admin_users_path, notice: t(".success")
else
redirect_to admin_users_path, alert: t(".failure")
end
end
private
def set_user
@user = User.find(params[:id])
end
def user_params
params.require(:user).permit(:role)
end
end
end

View File

@@ -2,9 +2,15 @@ class ApplicationController < ActionController::Base
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
FeatureGuardable, Notifiable
include Pundit::Authorization
include Pagy::Backend
# Pundit uses current_user by default, but this app uses Current.user
def pundit_user
Current.user
end
before_action :detect_os
before_action :set_default_chat
before_action :set_active_storage_url_options

View File

@@ -37,6 +37,13 @@ class OidcAccountsController < ApplicationController
user
)
# Log account linking
SsoAuditLog.log_link!(
user: user,
provider: @pending_auth["provider"],
request: request
)
# Clear pending auth from session
session.delete(:pending_oidc_auth)
@@ -104,15 +111,28 @@ class OidcAccountsController < ApplicationController
# Create new family for this user
@user.family = Family.new
@user.role = :admin
# Use provider-configured default role, or fall back to member (not admin)
provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] }
default_role = provider_config&.dig(:settings, :default_role) || "member"
@user.role = default_role
if @user.save
# Create the OIDC (or other SSO) identity
OidcIdentity.create_from_omniauth(
identity = OidcIdentity.create_from_omniauth(
build_auth_hash(@pending_auth),
@user
)
# Only log JIT account creation if identity was successfully created
if identity.persisted?
SsoAuditLog.log_jit_account_created!(
user: @user,
provider: @pending_auth["provider"],
request: request
)
end
# Clear pending auth from session
session.delete(:pending_oidc_auth)

View File

@@ -1,9 +1,14 @@
class SessionsController < ApplicationController
before_action :set_session, only: :destroy
skip_authentication only: %i[new create openid_connect failure]
skip_authentication only: %i[index new create openid_connect failure post_logout]
layout "auth"
# Handle GET /sessions (usually from browser back button)
def index
redirect_to new_session_path
end
def new
begin
demo = Rails.application.config_for(:demo)
@@ -62,7 +67,40 @@ class SessionsController < ApplicationController
end
def destroy
user = Current.user
id_token = session[:id_token_hint]
login_provider = session[:sso_login_provider]
# Find the identity for the provider used during login, with fallback to first if session data lost
oidc_identity = if login_provider.present?
user.oidc_identities.find_by(provider: login_provider)
else
user.oidc_identities.first
end
# Destroy local session
@session.destroy
session.delete(:id_token_hint)
session.delete(:sso_login_provider)
# Check if we should redirect to IdP for federated logout
if oidc_identity && id_token.present?
idp_logout_url = build_idp_logout_url(oidc_identity, id_token)
if idp_logout_url
SsoAuditLog.log_logout_idp!(user: user, provider: oidc_identity.provider, request: request)
redirect_to idp_logout_url, allow_other_host: true
return
end
end
# Standard local logout
SsoAuditLog.log_logout!(user: user, request: request)
redirect_to new_session_path, notice: t(".logout_successful")
end
# Handle redirect back from IdP after federated logout
def post_logout
redirect_to new_session_path, notice: t(".logout_successful")
end
@@ -82,6 +120,14 @@ class SessionsController < ApplicationController
# Existing OIDC identity found - authenticate the user
user = oidc_identity.user
oidc_identity.record_authentication!
oidc_identity.sync_user_attributes!(auth)
# Store id_token and provider for RP-initiated logout
session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token
session[:sso_login_provider] = auth.provider
# Log successful SSO login
SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request)
# MFA check: If user has MFA enabled, require verification
if user.otp_required?
@@ -107,7 +153,27 @@ class SessionsController < ApplicationController
end
def failure
redirect_to new_session_path, alert: t("sessions.failure.failed")
# Sanitize reason to known values only
known_reasons = %w[sso_provider_unavailable sso_invalid_response sso_failed]
sanitized_reason = known_reasons.include?(params[:message]) ? params[:message] : "sso_failed"
# Log failed SSO attempt
SsoAuditLog.log_login_failed!(
provider: params[:strategy],
request: request,
reason: sanitized_reason
)
message = case sanitized_reason
when "sso_provider_unavailable"
t("sessions.failure.sso_provider_unavailable")
when "sso_invalid_response"
t("sessions.failure.sso_invalid_response")
else
t("sessions.failure.sso_failed")
end
redirect_to new_session_path, alert: message
end
private
@@ -130,4 +196,53 @@ class SessionsController < ApplicationController
demo["hosts"].include?(request.host)
end
def build_idp_logout_url(oidc_identity, id_token)
# Find the provider configuration using unified loader (supports both YAML and DB providers)
provider_config = ProviderLoader.load_providers.find do |p|
p[:name] == oidc_identity.provider
end
return nil unless provider_config
# For OIDC providers, fetch end_session_endpoint from discovery
if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present?
begin
discovery_url = discovery_url_for(provider_config[:issuer])
response = Faraday.get(discovery_url) do |req|
req.options.timeout = 5
req.options.open_timeout = 3
end
return nil unless response.success?
discovery = JSON.parse(response.body)
end_session_endpoint = discovery["end_session_endpoint"]
return nil unless end_session_endpoint.present?
# Build the logout URL with post_logout_redirect_uri
post_logout_redirect = "#{request.base_url}/auth/logout/callback"
params = {
id_token_hint: id_token,
post_logout_redirect_uri: post_logout_redirect
}
"#{end_session_endpoint}?#{params.to_query}"
rescue Faraday::Error, JSON::ParserError, StandardError => e
Rails.logger.warn("[SSO] Failed to fetch OIDC discovery for logout: #{e.message}")
nil
end
else
nil
end
end
def discovery_url_for(issuer)
if issuer.end_with?("/")
"#{issuer}.well-known/openid-configuration"
else
"#{issuer}/.well-known/openid-configuration"
end
end
end

View File

@@ -6,5 +6,6 @@ class Settings::SecuritiesController < ApplicationController
[ "Home", root_path ],
[ "Security", nil ]
]
@oidc_identities = Current.user.oidc_identities.order(:provider)
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
class Settings::SsoIdentitiesController < ApplicationController
layout "settings"
def destroy
@identity = Current.user.oidc_identities.find(params[:id])
# Prevent unlinking last identity if user has no password
if Current.user.oidc_identities.count == 1 && Current.user.password_digest.blank?
redirect_to settings_security_path, alert: t(".cannot_unlink_last")
return
end
provider_name = @identity.provider
@identity.destroy!
# Log account unlinking
SsoAuditLog.log_unlink!(
user: Current.user,
provider: provider_name,
request: request
)
redirect_to settings_security_path, notice: t(".success", provider: provider_name)
end
end

View File

@@ -0,0 +1,226 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="admin-sso-form"
export default class extends Controller {
static targets = ["callbackUrl", "testResult", "samlCallbackUrl"]
connect() {
// Initialize field visibility on page load
this.toggleFields()
// Initialize callback URL
this.updateCallbackUrl()
}
updateCallbackUrl() {
const nameInput = this.element.querySelector('input[name*="[name]"]')
const callbackDisplay = this.callbackUrlTarget
if (!nameInput || !callbackDisplay) return
const providerName = nameInput.value.trim() || 'PROVIDER_NAME'
const baseUrl = window.location.origin
callbackDisplay.textContent = `${baseUrl}/auth/${providerName}/callback`
}
toggleFields() {
const strategySelect = this.element.querySelector('select[name*="[strategy]"]')
if (!strategySelect) return
const strategy = strategySelect.value
const isOidc = strategy === "openid_connect"
const isSaml = strategy === "saml"
// Toggle OIDC fields
const oidcFields = this.element.querySelectorAll('[data-oidc-field]')
oidcFields.forEach(field => {
if (isOidc) {
field.classList.remove('hidden')
} else {
field.classList.add('hidden')
}
})
// Toggle SAML fields
const samlFields = this.element.querySelectorAll('[data-saml-field]')
samlFields.forEach(field => {
if (isSaml) {
field.classList.remove('hidden')
} else {
field.classList.add('hidden')
}
})
// Update SAML callback URL if present
if (this.hasSamlCallbackUrlTarget) {
this.updateSamlCallbackUrl()
}
}
updateSamlCallbackUrl() {
const nameInput = this.element.querySelector('input[name*="[name]"]')
if (!nameInput || !this.hasSamlCallbackUrlTarget) return
const providerName = nameInput.value.trim() || 'PROVIDER_NAME'
const baseUrl = window.location.origin
this.samlCallbackUrlTarget.textContent = `${baseUrl}/auth/${providerName}/callback`
}
copySamlCallback(event) {
event.preventDefault()
if (!this.hasSamlCallbackUrlTarget) return
const callbackUrl = this.samlCallbackUrlTarget.textContent
navigator.clipboard.writeText(callbackUrl).then(() => {
const button = event.currentTarget
const originalText = button.innerHTML
button.innerHTML = '<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!'
button.classList.add('text-green-600')
setTimeout(() => {
button.innerHTML = originalText
button.classList.remove('text-green-600')
}, 2000)
}).catch(err => {
console.error('Failed to copy:', err)
alert('Failed to copy to clipboard')
})
}
async validateIssuer(event) {
const issuerInput = event.target
const issuer = issuerInput.value.trim()
if (!issuer) return
try {
// Construct discovery URL
const discoveryUrl = issuer.endsWith('/')
? `${issuer}.well-known/openid-configuration`
: `${issuer}/.well-known/openid-configuration`
// Show loading state
issuerInput.classList.add('border-yellow-300')
const response = await fetch(discoveryUrl, {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
if (data.issuer) {
// Valid OIDC discovery endpoint
issuerInput.classList.remove('border-yellow-300', 'border-red-300')
issuerInput.classList.add('border-green-300')
this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success')
} else {
throw new Error('Invalid discovery response')
}
} else {
throw new Error(`Discovery endpoint returned ${response.status}`)
}
} catch (error) {
// CORS errors are expected when validating from browser - show as warning not error
issuerInput.classList.remove('border-yellow-300', 'border-green-300')
issuerInput.classList.add('border-amber-300')
this.showValidationMessage(issuerInput, "Could not validate from browser (CORS). Provider can still be saved.", 'warning')
}
}
copyCallback(event) {
event.preventDefault()
const callbackDisplay = this.callbackUrlTarget
if (!callbackDisplay) return
const callbackUrl = callbackDisplay.textContent
// Copy to clipboard
navigator.clipboard.writeText(callbackUrl).then(() => {
// Show success feedback
const button = event.currentTarget
const originalText = button.innerHTML
button.innerHTML = '<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!'
button.classList.add('text-green-600')
setTimeout(() => {
button.innerHTML = originalText
button.classList.remove('text-green-600')
}, 2000)
}).catch(err => {
console.error('Failed to copy:', err)
alert('Failed to copy to clipboard')
})
}
showValidationMessage(input, message, type) {
// Remove any existing validation message
const existingMessage = input.parentElement.querySelector('.validation-message')
if (existingMessage) {
existingMessage.remove()
}
// Create new validation message
const messageEl = document.createElement('p')
const colorClass = type === 'success' ? 'text-green-600' : type === 'warning' ? 'text-amber-600' : 'text-red-600'
messageEl.className = `validation-message mt-1 text-sm ${colorClass}`
messageEl.textContent = message
input.parentElement.appendChild(messageEl)
// Auto-remove after 5 seconds (except warnings which stay)
if (type !== 'warning') {
setTimeout(() => {
messageEl.remove()
input.classList.remove('border-green-300', 'border-red-300', 'border-amber-300')
}, 5000)
}
}
async testConnection(event) {
const button = event.currentTarget
const testUrl = button.dataset.adminSsoFormTestUrlValue
const resultEl = this.testResultTarget
if (!testUrl) return
// Show loading state
button.disabled = true
button.textContent = 'Testing...'
resultEl.textContent = ''
resultEl.className = 'ml-2 text-sm'
try {
const response = await fetch(testUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
}
})
const data = await response.json()
if (data.success) {
resultEl.textContent = `${data.message}`
resultEl.classList.add('text-green-600')
} else {
resultEl.textContent = `${data.message}`
resultEl.classList.add('text-red-600')
}
// Show details in console for debugging
if (data.details && Object.keys(data.details).length > 0) {
console.log('SSO Test Connection Details:', data.details)
}
} catch (error) {
resultEl.textContent = `✗ Request failed: ${error.message}`
resultEl.classList.add('text-red-600')
} finally {
button.disabled = false
button.textContent = 'Test Connection'
}
}
}

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
# Middleware to catch OmniAuth/OIDC errors and redirect gracefully
# instead of showing ugly error pages
class OmniauthErrorHandler
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
rescue OpenIDConnect::Discovery::DiscoveryFailed => e
Rails.logger.error("[OmniAuth] OIDC Discovery failed: #{e.message}")
redirect_to_failure(env, "sso_provider_unavailable")
rescue OmniAuth::Error => e
Rails.logger.error("[OmniAuth] Authentication error: #{e.message}")
redirect_to_failure(env, "sso_failed")
end
private
def redirect_to_failure(env, message)
[
302,
{ "Location" => "/auth/failure?message=#{message}", "Content-Type" => "text/html" },
[ "Redirecting..." ]
]
end
end

View File

@@ -10,12 +10,79 @@ class OidcIdentity < ApplicationRecord
update!(last_authenticated_at: Time.current)
end
# Sync user attributes from IdP on each login
# Updates stored identity info and syncs name to user (not email - that's identity)
def sync_user_attributes!(auth)
# Extract groups from claims (various common claim names)
groups = extract_groups(auth)
# Update stored identity info with latest from IdP
update!(info: {
email: auth.info&.email,
name: auth.info&.name,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name,
groups: groups
})
# Sync name to user if provided (keep existing if IdP doesn't provide)
user.update!(
first_name: auth.info&.first_name.presence || user.first_name,
last_name: auth.info&.last_name.presence || user.last_name
)
# Apply role mapping based on group membership
apply_role_mapping!(groups)
end
# Extract groups from various common IdP claim formats
def extract_groups(auth)
# Try various common group claim locations
groups = auth.extra&.raw_info&.groups ||
auth.extra&.raw_info&.[]("groups") ||
auth.extra&.raw_info&.[]("Group") ||
auth.info&.groups ||
auth.extra&.raw_info&.[]("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups") ||
auth.extra&.raw_info&.[]("cognito:groups") ||
[]
# Normalize to array of strings
Array(groups).map(&:to_s)
end
# Apply role mapping based on IdP group membership
def apply_role_mapping!(groups)
config = provider_config
return unless config.present?
role_mapping = config.dig(:settings, :role_mapping) || config.dig(:settings, "role_mapping")
return unless role_mapping.present?
# Check roles in order of precedence (highest to lowest)
%w[super_admin admin member].each do |role|
mapped_groups = role_mapping[role] || role_mapping[role.to_sym] || []
mapped_groups = Array(mapped_groups)
# Check if user is in any of the mapped groups
if mapped_groups.include?("*") || (mapped_groups & groups).any?
# Only update if different to avoid unnecessary writes
user.update!(role: role) unless user.role == role
Rails.logger.info("[SSO] Applied role mapping: user_id=#{user.id} role=#{role} groups=#{groups}")
return
end
end
end
# Extract and store relevant info from OmniAuth auth hash
def self.create_from_omniauth(auth, user)
# Extract issuer from OIDC auth response if available
issuer = auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss")
create!(
user: user,
provider: auth.provider,
uid: auth.uid,
issuer: issuer,
info: {
email: auth.info&.email,
name: auth.info&.name,
@@ -25,4 +92,20 @@ class OidcIdentity < ApplicationRecord
last_authenticated_at: Time.current
)
end
# Find the configured provider for this identity
def provider_config
Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == provider || p[:id] == provider }
end
# Validate that the stored issuer matches the configured provider's issuer
# Returns true if valid, false if mismatch (security concern)
def issuer_matches_config?
return true if issuer.blank? # Backward compatibility for old records
config = provider_config
return true if config.blank? || config[:issuer].blank? # No config to validate against
issuer == config[:issuer]
end
end

108
app/models/sso_audit_log.rb Normal file
View File

@@ -0,0 +1,108 @@
# frozen_string_literal: true
class SsoAuditLog < ApplicationRecord
belongs_to :user, optional: true
# Event types for SSO audit logging
EVENT_TYPES = %w[
login
login_failed
logout
logout_idp
link
unlink
jit_account_created
].freeze
validates :event_type, presence: true, inclusion: { in: EVENT_TYPES }
scope :recent, -> { order(created_at: :desc) }
scope :for_user, ->(user) { where(user: user) }
scope :by_event, ->(event) { where(event_type: event) }
class << self
# Log a successful SSO login
def log_login!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "login",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log a failed SSO login attempt
def log_login_failed!(provider:, request:, reason:, metadata: {})
create!(
user: nil,
event_type: "login_failed",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata.merge(reason: reason)
)
end
# Log a logout (local only)
def log_logout!(user:, request:, metadata: {})
create!(
user: user,
event_type: "logout",
provider: nil,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log a federated logout (to IdP)
def log_logout_idp!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "logout_idp",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log an account link (existing user links SSO identity)
def log_link!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "link",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log an account unlink (user disconnects SSO identity)
def log_unlink!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "unlink",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
# Log JIT account creation via SSO
def log_jit_account_created!(user:, provider:, request:, metadata: {})
create!(
user: user,
event_type: "jit_account_created",
provider: provider,
ip_address: request.remote_ip,
user_agent: request.user_agent&.truncate(500),
metadata: metadata
)
end
end
end

144
app/models/sso_provider.rb Normal file
View File

@@ -0,0 +1,144 @@
# frozen_string_literal: true
class SsoProvider < ApplicationRecord
# Encrypt sensitive credentials using Rails 7.2 built-in encryption
encrypts :client_secret, deterministic: false
# Default enabled to true for new providers
attribute :enabled, :boolean, default: true
# Validations
validates :strategy, presence: true, inclusion: {
in: %w[openid_connect google_oauth2 github saml],
message: "%{value} is not a supported strategy"
}
validates :name, presence: true, uniqueness: true, format: {
with: /\A[a-z0-9_]+\z/,
message: "must contain only lowercase letters, numbers, and underscores"
}
validates :label, presence: true
validates :enabled, inclusion: { in: [ true, false ] }
# Strategy-specific validations
validate :validate_oidc_fields, if: -> { strategy == "openid_connect" }
validate :validate_oauth_fields, if: -> { strategy.in?(%w[google_oauth2 github]) }
validate :validate_saml_fields, if: -> { strategy == "saml" }
validate :validate_default_role_setting
# Note: OIDC discovery validation is done client-side via Stimulus
# Server-side validation can fail due to network issues, so we skip it
# validate :validate_oidc_discovery, if: -> { strategy == "openid_connect" && issuer.present? && will_save_change_to_issuer? }
# Scopes
scope :enabled, -> { where(enabled: true) }
scope :by_strategy, ->(strategy) { where(strategy: strategy) }
# Convert to hash format compatible with OmniAuth initializer
def to_omniauth_config
{
id: name,
strategy: strategy,
name: name,
label: label,
icon: icon,
issuer: issuer,
client_id: client_id,
client_secret: client_secret,
redirect_uri: redirect_uri,
settings: settings || {}
}.compact
end
private
def validate_oidc_fields
if issuer.blank?
errors.add(:issuer, "is required for OpenID Connect providers")
elsif issuer.present? && !valid_url?(issuer)
errors.add(:issuer, "must be a valid URL")
end
errors.add(:client_id, "is required for OpenID Connect providers") if client_id.blank?
errors.add(:client_secret, "is required for OpenID Connect providers") if client_secret.blank?
if redirect_uri.present? && !valid_url?(redirect_uri)
errors.add(:redirect_uri, "must be a valid URL")
end
end
def validate_oauth_fields
errors.add(:client_id, "is required for OAuth providers") if client_id.blank?
errors.add(:client_secret, "is required for OAuth providers") if client_secret.blank?
end
def validate_saml_fields
# SAML requires either a metadata URL or manual configuration
idp_metadata_url = settings&.dig("idp_metadata_url")
idp_sso_url = settings&.dig("idp_sso_url")
if idp_metadata_url.blank? && idp_sso_url.blank?
errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers")
end
# If using manual config, require certificate
if idp_metadata_url.blank? && idp_sso_url.present?
idp_cert = settings&.dig("idp_certificate")
idp_fingerprint = settings&.dig("idp_cert_fingerprint")
if idp_cert.blank? && idp_fingerprint.blank?
errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL")
end
end
# Validate URL formats if provided
if idp_metadata_url.present? && !valid_url?(idp_metadata_url)
errors.add(:settings, "IdP Metadata URL must be a valid URL")
end
if idp_sso_url.present? && !valid_url?(idp_sso_url)
errors.add(:settings, "IdP SSO URL must be a valid URL")
end
end
def validate_default_role_setting
default_role = settings&.dig("default_role")
return if default_role.blank?
unless User.roles.key?(default_role)
errors.add(:settings, "default_role must be member, admin, or super_admin")
end
end
def validate_oidc_discovery
return unless issuer.present?
begin
discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration"
response = Faraday.get(discovery_url) do |req|
req.options.timeout = 5
req.options.open_timeout = 3
end
unless response.success?
errors.add(:issuer, "discovery endpoint returned #{response.status}")
return
end
discovery_data = JSON.parse(response.body)
unless discovery_data["issuer"].present?
errors.add(:issuer, "discovery endpoint did not return valid issuer")
end
rescue Faraday::Error => e
errors.add(:issuer, "could not connect to discovery endpoint: #{e.message}")
rescue JSON::ParserError
errors.add(:issuer, "discovery endpoint returned invalid JSON")
rescue StandardError => e
errors.add(:issuer, "discovery validation failed: #{e.message}")
end
end
def valid_url?(url)
uri = URI.parse(url)
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
rescue URI::InvalidURIError
false
end
end

View File

@@ -0,0 +1,201 @@
# frozen_string_literal: true
# Tests SSO provider configuration by validating discovery endpoints
class SsoProviderTester
attr_reader :provider, :result
Result = Struct.new(:success?, :message, :details, keyword_init: true)
def initialize(provider)
@provider = provider
@result = nil
end
def test!
@result = case provider.strategy
when "openid_connect"
test_oidc_discovery
when "google_oauth2"
test_google_oauth
when "github"
test_github_oauth
when "saml"
test_saml_metadata
else
Result.new(success?: false, message: "Unknown strategy: #{provider.strategy}", details: {})
end
end
private
def test_oidc_discovery
return Result.new(success?: false, message: "Issuer URL is required", details: {}) if provider.issuer.blank?
discovery_url = build_discovery_url(provider.issuer)
begin
response = Faraday.get(discovery_url) do |req|
req.options.timeout = 10
req.options.open_timeout = 5
end
unless response.success?
return Result.new(
success?: false,
message: "Discovery endpoint returned HTTP #{response.status}",
details: { url: discovery_url, status: response.status }
)
end
discovery = JSON.parse(response.body)
# Validate required OIDC fields
required_fields = %w[issuer authorization_endpoint token_endpoint]
missing = required_fields.select { |f| discovery[f].blank? }
if missing.any?
return Result.new(
success?: false,
message: "Discovery document missing required fields: #{missing.join(", ")}",
details: { url: discovery_url, missing_fields: missing }
)
end
# Check if issuer matches
if discovery["issuer"] != provider.issuer && discovery["issuer"] != provider.issuer.chomp("/")
return Result.new(
success?: false,
message: "Issuer mismatch: expected #{provider.issuer}, got #{discovery["issuer"]}",
details: { expected: provider.issuer, actual: discovery["issuer"] }
)
end
Result.new(
success?: true,
message: "OIDC discovery validated successfully",
details: {
issuer: discovery["issuer"],
authorization_endpoint: discovery["authorization_endpoint"],
token_endpoint: discovery["token_endpoint"],
end_session_endpoint: discovery["end_session_endpoint"],
scopes_supported: discovery["scopes_supported"]
}
)
rescue Faraday::TimeoutError
Result.new(success?: false, message: "Connection timed out", details: { url: discovery_url })
rescue Faraday::ConnectionFailed => e
Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: discovery_url })
rescue JSON::ParserError
Result.new(success?: false, message: "Invalid JSON response from discovery endpoint", details: { url: discovery_url })
rescue StandardError => e
Result.new(success?: false, message: "Error: #{e.message}", details: { url: discovery_url })
end
end
def test_google_oauth
# Google OAuth doesn't require discovery validation - just check credentials present
if provider.client_id.blank?
return Result.new(success?: false, message: "Client ID is required", details: {})
end
if provider.client_secret.blank?
return Result.new(success?: false, message: "Client Secret is required", details: {})
end
Result.new(
success?: true,
message: "Google OAuth2 configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def test_github_oauth
# GitHub OAuth doesn't require discovery validation - just check credentials present
if provider.client_id.blank?
return Result.new(success?: false, message: "Client ID is required", details: {})
end
if provider.client_secret.blank?
return Result.new(success?: false, message: "Client Secret is required", details: {})
end
Result.new(
success?: true,
message: "GitHub OAuth configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def test_saml_metadata
# SAML testing - check for IdP metadata or SSO URL
if provider.settings&.dig("idp_metadata_url").blank? &&
provider.settings&.dig("idp_sso_url").blank?
return Result.new(
success?: false,
message: "Either IdP Metadata URL or IdP SSO URL is required",
details: {}
)
end
# If metadata URL is provided, try to fetch it
metadata_url = provider.settings&.dig("idp_metadata_url")
if metadata_url.present?
begin
response = Faraday.get(metadata_url) do |req|
req.options.timeout = 10
req.options.open_timeout = 5
end
unless response.success?
return Result.new(
success?: false,
message: "Metadata endpoint returned HTTP #{response.status}",
details: { url: metadata_url, status: response.status }
)
end
# Basic XML validation
unless response.body.include?("<") && response.body.include?("EntityDescriptor")
return Result.new(
success?: false,
message: "Response does not appear to be valid SAML metadata",
details: { url: metadata_url }
)
end
return Result.new(
success?: true,
message: "SAML metadata fetched successfully",
details: { url: metadata_url }
)
rescue Faraday::TimeoutError
return Result.new(success?: false, message: "Connection timed out", details: { url: metadata_url })
rescue Faraday::ConnectionFailed => e
return Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: metadata_url })
rescue StandardError => e
return Result.new(success?: false, message: "Error: #{e.message}", details: { url: metadata_url })
end
end
Result.new(
success?: true,
message: "SAML configuration looks valid",
details: {
note: "Full validation occurs during actual authentication"
}
)
end
def build_discovery_url(issuer)
if issuer.end_with?("/")
"#{issuer}.well-known/openid-configuration"
else
"#{issuer}/.well-known/openid-configuration"
end
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
class ApplicationPolicy
attr_reader :user, :record
def initialize(user, record)
@user = user
@record = record
end
def index?
false
end
def show?
false
end
def create?
false
end
def new?
create?
end
def update?
false
end
def edit?
update?
end
def destroy?
false
end
class Scope
def initialize(user, scope)
@user = user
@scope = scope
end
def resolve
raise NoMethodError, "You must define #resolve in #{self.class}"
end
private
attr_reader :user, :scope
end
end

View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
class SsoProviderPolicy < ApplicationPolicy
# Only super admins can manage SSO providers (instance-wide auth config)
def index?
user&.super_admin?
end
def show?
user&.super_admin?
end
def create?
user&.super_admin?
end
def new?
create?
end
def update?
user&.super_admin?
end
def edit?
update?
end
def destroy?
user&.super_admin?
end
def toggle?
update?
end
def test_connection?
user&.super_admin?
end
class Scope < ApplicationPolicy::Scope
def resolve
if user&.super_admin?
scope.all
else
scope.none
end
end
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
class UserPolicy < ApplicationPolicy
# Only super_admins can manage user roles
def index?
user&.super_admin?
end
def update?
return false unless user&.super_admin?
# Prevent users from changing their own role (must be done by another super_admin)
user.id != record.id
end
class Scope < ApplicationPolicy::Scope
def resolve
if user&.super_admin?
scope.all
else
scope.none
end
end
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
# Service class to load SSO provider configurations from either YAML or database
# based on the :db_sso_providers feature flag.
#
# Usage:
# providers = ProviderLoader.load_providers
#
class ProviderLoader
CACHE_KEY = "sso_providers_config"
CACHE_EXPIRES_IN = 5.minutes
class << self
# Load providers from either DB or YAML based on feature flag
# Returns an array of provider configuration hashes
def load_providers
# Check cache first for performance
cached = Rails.cache.read(CACHE_KEY)
return cached if cached.present?
providers = if use_database_providers?
load_from_database
else
load_from_yaml
end
# Cache the result
Rails.cache.write(CACHE_KEY, providers, expires_in: CACHE_EXPIRES_IN)
providers
end
# Clear the provider cache (call after updating providers in admin)
def clear_cache
Rails.cache.delete(CACHE_KEY)
end
private
def use_database_providers?
return false if Rails.env.test?
begin
# Check if feature exists, create if not (defaults to disabled)
unless Flipper.exist?(:db_sso_providers)
Flipper.add(:db_sso_providers)
end
Flipper.enabled?(:db_sso_providers)
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, StandardError => e
# Database not ready or other error, fall back to YAML
Rails.logger.warn("[ProviderLoader] Could not check feature flag (#{e.class}), falling back to YAML providers")
false
end
end
def load_from_database
begin
providers = SsoProvider.enabled.order(:name).map(&:to_omniauth_config)
if providers.empty?
Rails.logger.info("[ProviderLoader] No enabled providers in database, falling back to YAML")
return load_from_yaml
end
Rails.logger.info("[ProviderLoader] Loaded #{providers.count} provider(s) from database")
providers
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError => e
Rails.logger.error("[ProviderLoader] Database error loading providers: #{e.message}, falling back to YAML")
load_from_yaml
rescue StandardError => e
Rails.logger.error("[ProviderLoader] Unexpected error loading providers from database: #{e.message}, falling back to YAML")
load_from_yaml
end
end
def load_from_yaml
begin
auth_config = Rails.application.config_for(:auth)
providers = auth_config.dig("providers") || []
Rails.logger.info("[ProviderLoader] Loaded #{providers.count} provider(s) from YAML")
providers
rescue RuntimeError, Errno::ENOENT => e
Rails.logger.error("[ProviderLoader] Error loading auth.yml: #{e.message}")
[]
end
end
end
end

View File

@@ -0,0 +1,276 @@
<%# locals: (sso_provider:) %>
<% if sso_provider.errors.any? %>
<div class="bg-destructive/10 border border-destructive rounded-lg p-4 mb-4">
<div class="flex">
<%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>
<div>
<p class="text-sm font-medium text-destructive">
<%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved:
</p>
<ul class="mt-2 text-sm text-destructive list-disc list-inside">
<% sso_provider.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
<div class="space-y-4">
<h3 class="font-medium text-primary">Basic Information</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= form.select :strategy,
options_for_select([
["OpenID Connect", "openid_connect"],
["SAML 2.0", "saml"],
["Google OAuth2", "google_oauth2"],
["GitHub", "github"]
], sso_provider.strategy),
{ label: "Strategy" },
{ data: { action: "change->admin-sso-form#toggleFields" } } %>
<%= form.text_field :name,
label: "Name",
placeholder: "e.g., keycloak, authentik",
required: true,
data: { action: "input->admin-sso-form#updateCallbackUrl" } %>
</div>
<p class="text-xs text-secondary -mt-2">Unique identifier (lowercase, numbers, underscores only)</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= form.text_field :label,
label: "Button Label",
placeholder: "e.g., Sign in with Keycloak",
required: true %>
<div>
<%= form.text_field :icon,
label: "Icon (optional)",
placeholder: "e.g., key, shield" %>
<p class="text-xs text-secondary mt-1">Lucide icon name for the login button</p>
</div>
</div>
<%= form.check_box :enabled,
label: "Enable this provider",
checked: sso_provider.enabled? %>
</div>
<div class="border-t border-primary pt-4 space-y-4">
<h3 class="font-medium text-primary">OAuth/OIDC Configuration</h3>
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<%= form.text_field :issuer,
label: "Issuer URL",
placeholder: "https://your-idp.example.com/realms/your-realm",
data: { action: "blur->admin-sso-form#validateIssuer" } %>
<p class="text-xs text-secondary mt-1">OIDC issuer URL (validates .well-known/openid-configuration)</p>
</div>
<%= form.text_field :client_id,
label: "Client ID",
placeholder: "your-client-id",
required: true %>
<%= form.password_field :client_secret,
label: "Client Secret",
placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret",
required: !sso_provider.persisted? %>
<% if sso_provider.persisted? %>
<p class="text-xs text-secondary -mt-2">Leave blank to keep existing secret</p>
<% end %>
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<label class="block text-sm font-medium text-primary mb-1">Callback URL</label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
data-admin-sso-form-target="callbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
<button type="button"
data-action="click->admin-sso-form#copyCallback"
class="p-2 text-secondary hover:text-primary shrink-0"
title="Copy to clipboard">
<%= icon "copy", class: "w-4 h-4" %>
</button>
</div>
<p class="text-xs text-secondary mt-1">Configure this URL in your identity provider</p>
</div>
</div>
<div data-saml-field class="border-t border-primary pt-4 space-y-4 <%= "hidden" unless sso_provider.strategy == "saml" %>">
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.saml_configuration") %></h3>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_metadata_url") %></label>
<input type="text" name="sso_provider[settings][idp_metadata_url]"
value="<%= sso_provider.settings&.dig("idp_metadata_url") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="https://idp.example.com/metadata"
autocomplete="off">
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.idp_metadata_url_help") %></p>
</div>
<details class="mt-4">
<summary class="cursor-pointer text-sm font-medium text-secondary hover:text-primary"><%= t("admin.sso_providers.form.manual_saml_config") %></summary>
<div class="mt-3 space-y-3 pl-4 border-l-2 border-secondary/30">
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.manual_saml_help") %></p>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_sso_url") %></label>
<input type="text" name="sso_provider[settings][idp_sso_url]"
value="<%= sso_provider.settings&.dig("idp_sso_url") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="https://idp.example.com/sso"
autocomplete="off">
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_slo_url") %></label>
<input type="text" name="sso_provider[settings][idp_slo_url]"
value="<%= sso_provider.settings&.dig("idp_slo_url") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="https://idp.example.com/slo (optional)"
autocomplete="off">
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_certificate") %></label>
<textarea name="sso_provider[settings][idp_certificate]"
rows="4"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono"
placeholder="-----BEGIN CERTIFICATE-----"><%= sso_provider.settings&.dig("idp_certificate") %></textarea>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.idp_certificate_help") %></p>
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_cert_fingerprint") %></label>
<input type="text" name="sso_provider[settings][idp_cert_fingerprint]"
value="<%= sso_provider.settings&.dig("idp_cert_fingerprint") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono"
placeholder="AB:CD:EF:..."
autocomplete="off">
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.name_id_format") %></label>
<select name="sso_provider[settings][name_id_format]"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm">
<option value="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" <%= "selected" if sso_provider.settings&.dig("name_id_format").blank? || sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" %>><%= t("admin.sso_providers.form.name_id_email") %></option>
<option value="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" %>><%= t("admin.sso_providers.form.name_id_persistent") %></option>
<option value="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" %>><%= t("admin.sso_providers.form.name_id_transient") %></option>
<option value="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" %>><%= t("admin.sso_providers.form.name_id_unspecified") %></option>
</select>
</div>
</div>
</details>
<div>
<label class="block text-sm font-medium text-primary mb-1">SP Callback URL (ACS URL)</label>
<div class="flex items-center gap-2">
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
data-admin-sso-form-target="samlCallbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
<button type="button"
data-action="click->admin-sso-form#copySamlCallback"
class="p-2 text-secondary hover:text-primary shrink-0"
title="Copy to clipboard">
<%= icon "copy", class: "w-4 h-4" %>
</button>
</div>
<p class="text-xs text-secondary mt-1">Configure this URL as the Assertion Consumer Service URL in your IdP</p>
</div>
</div>
<div class="border-t border-primary pt-4 space-y-4">
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.provisioning_title") %></h3>
<%= form.select "settings[default_role]",
options_for_select([
[t("admin.sso_providers.form.role_member"), "member"],
[t("admin.sso_providers.form.role_admin"), "admin"],
[t("admin.sso_providers.form.role_super_admin"), "super_admin"]
], sso_provider.settings&.dig("default_role") || "member"),
{ label: t("admin.sso_providers.form.default_role_label"), include_blank: false } %>
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.default_role_help") %></p>
<details class="mt-4">
<summary class="cursor-pointer text-sm font-medium text-secondary hover:text-primary"><%= t("admin.sso_providers.form.role_mapping_title") %></summary>
<div class="mt-3 space-y-3 pl-4 border-l-2 border-secondary/30">
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.role_mapping_help") %></p>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.super_admin_groups") %></label>
<input type="text" name="sso_provider[settings][role_mapping][super_admin]"
value="<%= Array(sso_provider.settings&.dig("role_mapping", "super_admin")).join(", ") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="Platform-Admins, IdP-Superusers"
autocomplete="off">
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.groups_help") %></p>
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.admin_groups") %></label>
<input type="text" name="sso_provider[settings][role_mapping][admin]"
value="<%= Array(sso_provider.settings&.dig("role_mapping", "admin")).join(", ") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="Team-Leads, Managers"
autocomplete="off">
</div>
<div>
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.member_groups") %></label>
<input type="text" name="sso_provider[settings][role_mapping][member]"
value="<%= Array(sso_provider.settings&.dig("role_mapping", "member")).join(", ") %>"
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
placeholder="* (all groups)"
autocomplete="off">
</div>
</div>
</details>
</div>
<div data-oidc-field class="border-t border-primary pt-4 space-y-4 <%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.advanced_title") %></h3>
<div>
<%= form.text_field "settings[scopes]",
label: t("admin.sso_providers.form.scopes_label"),
value: sso_provider.settings&.dig("scopes"),
placeholder: "openid email profile groups" %>
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.scopes_help") %></p>
</div>
<%= form.select "settings[prompt]",
options_for_select([
[t("admin.sso_providers.form.prompt_default"), ""],
[t("admin.sso_providers.form.prompt_login"), "login"],
[t("admin.sso_providers.form.prompt_consent"), "consent"],
[t("admin.sso_providers.form.prompt_select_account"), "select_account"],
[t("admin.sso_providers.form.prompt_none"), "none"]
], sso_provider.settings&.dig("prompt")),
{ label: t("admin.sso_providers.form.prompt_label"), include_blank: false } %>
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.prompt_help") %></p>
</div>
<div class="flex justify-between items-center gap-3 pt-4 border-t border-primary">
<div>
<% if sso_provider.persisted? %>
<button type="button"
data-action="click->admin-sso-form#testConnection"
data-admin-sso-form-test-url-value="<%= test_connection_admin_sso_provider_path(sso_provider) %>"
class="px-4 py-2 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg">
<%= t("admin.sso_providers.form.test_connection") %>
</button>
<span data-admin-sso-form-target="testResult" class="ml-2 text-sm"></span>
<% end %>
</div>
<div class="flex gap-3">
<%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %>
<%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider",
class: "px-4 py-2 bg-primary text-inverse rounded-lg text-sm font-medium hover:bg-primary/90" %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,9 @@
<%= content_for :page_title, "Edit #{@sso_provider.label}" %>
<div class="space-y-4">
<p class="text-secondary">Update configuration for <%= @sso_provider.label %>.</p>
<%= settings_section title: "Provider Configuration" do %>
<%= render "form", sso_provider: @sso_provider %>
<% end %>
</div>

View File

@@ -0,0 +1,88 @@
<%= content_for :page_title, "SSO Providers" %>
<div class="space-y-4">
<p class="text-secondary mb-4">
Manage single sign-on authentication providers for your instance.
<% unless Flipper.enabled?(:db_sso_providers) %>
<span class="text-warning">Changes require a server restart to take effect.</span>
<% end %>
</p>
<%= settings_section title: "Configured Providers" do %>
<% if @sso_providers.any? %>
<div class="divide-y divide-primary">
<% @sso_providers.each do |provider| %>
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<% if provider.icon.present? %>
<%= icon provider.icon, class: "w-5 h-5 text-secondary" %>
<% else %>
<%= icon "key", class: "w-5 h-5 text-secondary" %>
<% end %>
<div>
<p class="font-medium text-primary"><%= provider.label %></p>
<p class="text-sm text-secondary"><%= provider.strategy.titleize %> · <%= provider.name %></p>
</div>
</div>
<div class="flex items-center gap-2">
<% if provider.enabled? %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Enabled
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
Disabled
</span>
<% end %>
<%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %>
<%= icon "pencil", class: "w-4 h-4" %>
<% end %>
<%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %>
<%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %>
<% end %>
<%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %>
<%= icon "trash-2", class: "w-4 h-4" %>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-6">
<%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
<p class="text-secondary">No SSO providers configured yet.</p>
</div>
<% end %>
<div class="pt-4 border-t border-primary">
<%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %>
<%= icon "plus", class: "w-4 h-4" %>
Add Provider
<% end %>
</div>
<% end %>
<%= settings_section title: "Configuration Mode", collapsible: true, open: false do %>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-primary">Database-backed providers</p>
<p class="text-sm text-secondary">Load providers from database instead of YAML config</p>
</div>
<% if Flipper.enabled?(:db_sso_providers) %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Enabled
</span>
<% else %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
Disabled
</span>
<% end %>
</div>
<p class="text-sm text-secondary">
Set <code class="bg-surface px-1 py-0.5 rounded text-xs">AUTH_PROVIDERS_SOURCE=db</code> to enable database-backed providers.
This allows changes without server restarts.
</p>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,9 @@
<%= content_for :page_title, "Add SSO Provider" %>
<div class="space-y-4">
<p class="text-secondary">Configure a new single sign-on authentication provider.</p>
<%= settings_section title: "Provider Configuration" do %>
<%= render "form", sso_provider: @sso_provider %>
<% end %>
</div>

View File

@@ -0,0 +1,73 @@
<%= content_for :page_title, t(".title") %>
<div class="space-y-4">
<p class="text-secondary"><%= t(".description") %></p>
<%= settings_section title: t(".section_title") do %>
<div class="divide-y divide-primary">
<% @users.each do |user| %>
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-surface flex items-center justify-center">
<span class="text-sm font-medium text-primary"><%= user.initials %></span>
</div>
<div>
<p class="font-medium text-primary"><%= user.display_name %></p>
<p class="text-sm text-secondary"><%= user.email %></p>
</div>
</div>
<div class="flex items-center gap-3">
<% if user.id == Current.user.id %>
<span class="text-sm text-secondary"><%= t(".you") %></span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary">
<%= t(".roles.#{user.role}") %>
</span>
<% else %>
<%= form_with model: [:admin, user], method: :patch, class: "flex items-center gap-2" do |form| %>
<%= form.select :role,
options_for_select([
[t(".roles.member"), "member"],
[t(".roles.admin"), "admin"],
[t(".roles.super_admin"), "super_admin"]
], user.role),
{},
class: "text-sm rounded-lg border-primary bg-container text-primary px-2 py-1",
onchange: "this.form.requestSubmit()" %>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<% if @users.empty? %>
<div class="text-center py-6">
<%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
<p class="text-secondary"><%= t(".no_users") %></p>
</div>
<% end %>
<% end %>
<%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %>
<div class="space-y-3 text-sm">
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
<%= t(".roles.member") %>
</span>
<p class="text-secondary"><%= t(".role_descriptions.member") %></p>
</div>
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
<%= t(".roles.admin") %>
</span>
<p class="text-secondary"><%= t(".role_descriptions.admin") %></p>
</div>
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 shrink-0">
<%= t(".roles.super_admin") %>
</span>
<p class="text-secondary"><%= t(".role_descriptions.super_admin") %></p>
</div>
</div>
<% end %>
</div>

View File

@@ -30,7 +30,9 @@ nav_sections = [
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: "Providers", path: settings_providers_path, icon: "plug" },
{ label: t(".imports_label"), path: imports_path, icon: "download" }
{ label: t(".imports_label"), path: imports_path, icon: "download" },
{ label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? },
{ label: "Users", path: admin_users_path, icon: "users", if: Current.user&.super_admin? }
]
} : nil
),

View File

@@ -44,3 +44,58 @@
</div>
</div>
<% end %>
<% if @oidc_identities.any? || AuthConfig.sso_providers.any? %>
<%= settings_section title: t(".sso_title"), subtitle: t(".sso_subtitle") do %>
<% if @oidc_identities.any? %>
<div class="space-y-2">
<% @oidc_identities.each do |identity| %>
<div class="flex items-center justify-between bg-container p-4 shadow-border-xs rounded-lg">
<div class="flex items-center gap-3">
<div class="w-9 h-9 shrink-0 bg-surface rounded-full flex items-center justify-center">
<%= icon identity.provider_config&.dig(:icon) || "key", class: "w-5 h-5 text-secondary" %>
</div>
<div>
<p class="font-medium text-primary"><%= identity.provider_config&.dig(:label) || identity.provider.titleize %></p>
<p class="text-sm text-secondary"><%= identity.info&.dig("email") || t(".sso_no_email") %></p>
<p class="text-xs text-secondary">
<%= t(".sso_last_used") %>:
<%= identity.last_authenticated_at&.to_fs(:short) || t(".sso_never") %>
</p>
</div>
</div>
<% if @oidc_identities.count > 1 || Current.user.password_digest.present? %>
<%= render DS::Button.new(
text: t(".sso_disconnect"),
variant: "outline",
size: "sm",
href: settings_sso_identity_path(identity),
method: :delete,
confirm: CustomConfirm.new(
title: t(".sso_confirm_title"),
body: t(".sso_confirm_body", provider: identity.provider_config&.dig(:label) || identity.provider.titleize),
btn_text: t(".sso_confirm_button"),
destructive: true
)
) %>
<% end %>
</div>
<% end %>
</div>
<% if @oidc_identities.count == 1 && Current.user.password_digest.blank? %>
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div class="flex items-start gap-2">
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5" %>
<p class="text-sm text-amber-800"><%= t(".sso_warning_message") %></p>
</div>
</div>
<% end %>
<% else %>
<div class="text-center py-6">
<%= icon "link", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
<p class="text-secondary"><%= t(".sso_no_identities") %></p>
<p class="text-sm text-secondary mt-2"><%= t(".sso_connect_hint") %></p>
</div>
<% end %>
<% end %>
<% end %>