217 lines
6.5 KiB
Ruby
217 lines
6.5 KiB
Ruby
class Application < ApplicationRecord
|
|
has_secure_password :client_secret, validations: false
|
|
|
|
has_many :application_groups, dependent: :destroy
|
|
has_many :allowed_groups, through: :application_groups, source: :group
|
|
has_many :application_user_claims, dependent: :destroy
|
|
has_many :oidc_authorization_codes, dependent: :destroy
|
|
has_many :oidc_access_tokens, dependent: :destroy
|
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
|
has_many :oidc_user_consents, dependent: :destroy
|
|
|
|
validates :name, presence: true
|
|
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
|
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
|
validates :app_type, presence: true,
|
|
inclusion: { in: %w[oidc forward_auth] }
|
|
validates :client_id, uniqueness: { allow_nil: true }
|
|
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
|
|
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
|
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
|
|
|
|
# Token TTL validations (for OIDC apps)
|
|
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
|
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
|
|
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
|
|
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
|
normalizes :domain_pattern, with: ->(pattern) {
|
|
normalized = pattern&.strip&.downcase
|
|
normalized.blank? ? nil : normalized
|
|
}
|
|
|
|
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
|
|
|
# Default header configuration for ForwardAuth
|
|
DEFAULT_HEADERS = {
|
|
user: 'X-Remote-User',
|
|
email: 'X-Remote-Email',
|
|
name: 'X-Remote-Name',
|
|
groups: 'X-Remote-Groups',
|
|
admin: 'X-Remote-Admin'
|
|
}.freeze
|
|
|
|
# Scopes
|
|
scope :active, -> { where(active: true) }
|
|
scope :oidc, -> { where(app_type: "oidc") }
|
|
scope :forward_auth, -> { where(app_type: "forward_auth") }
|
|
scope :ordered, -> { order(domain_pattern: :asc) }
|
|
|
|
# Type checks
|
|
def oidc?
|
|
app_type == "oidc"
|
|
end
|
|
|
|
def forward_auth?
|
|
app_type == "forward_auth"
|
|
end
|
|
|
|
# Access control
|
|
def user_allowed?(user)
|
|
return false unless active?
|
|
return false unless user.active?
|
|
|
|
# If no groups are specified, allow all active users
|
|
return true if allowed_groups.empty?
|
|
|
|
# Otherwise, user must be in at least one of the allowed groups
|
|
(user.groups & allowed_groups).any?
|
|
end
|
|
|
|
# OIDC helpers
|
|
def parsed_redirect_uris
|
|
return [] unless redirect_uris.present?
|
|
JSON.parse(redirect_uris)
|
|
rescue JSON::ParserError
|
|
redirect_uris.split("\n").map(&:strip).reject(&:blank?)
|
|
end
|
|
|
|
def parsed_metadata
|
|
return {} unless metadata.present?
|
|
JSON.parse(metadata)
|
|
rescue JSON::ParserError
|
|
{}
|
|
end
|
|
|
|
# ForwardAuth helpers
|
|
def parsed_headers_config
|
|
return {} unless headers_config.present?
|
|
headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
|
|
rescue JSON::ParserError
|
|
{}
|
|
end
|
|
|
|
# Check if a domain matches this application's pattern (for ForwardAuth)
|
|
def matches_domain?(domain)
|
|
return false if domain.blank? || !forward_auth?
|
|
|
|
pattern = domain_pattern.gsub('.', '\.')
|
|
pattern = pattern.gsub('*', '[^.]*')
|
|
|
|
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
|
regex.match?(domain.downcase)
|
|
end
|
|
|
|
# Policy determination based on user status (for ForwardAuth)
|
|
def policy_for_user(user)
|
|
return 'deny' unless active?
|
|
return 'deny' unless user.active?
|
|
|
|
# If no groups specified, bypass authentication
|
|
return 'bypass' if allowed_groups.empty?
|
|
|
|
# If user is in allowed groups, determine auth level
|
|
if user_allowed?(user)
|
|
# Require 2FA if user has TOTP configured, otherwise one factor
|
|
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
|
else
|
|
'deny'
|
|
end
|
|
end
|
|
|
|
# Get effective header configuration (for ForwardAuth)
|
|
def effective_headers
|
|
DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
|
|
end
|
|
|
|
# Generate headers for a specific user (for ForwardAuth)
|
|
def headers_for_user(user)
|
|
headers = {}
|
|
effective = effective_headers
|
|
|
|
# Only generate headers that are configured (not set to nil/false)
|
|
effective.each do |key, header_name|
|
|
next unless header_name.present? # Skip disabled headers
|
|
|
|
case key
|
|
when :user, :email
|
|
headers[header_name] = user.email_address
|
|
when :name
|
|
headers[header_name] = user.name.presence || user.email_address
|
|
when :groups
|
|
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
|
|
when :admin
|
|
headers[header_name] = user.admin? ? "true" : "false"
|
|
end
|
|
end
|
|
|
|
headers
|
|
end
|
|
|
|
# Check if all headers are disabled (for ForwardAuth)
|
|
def headers_disabled?
|
|
headers_config.present? && effective_headers.values.all?(&:blank?)
|
|
end
|
|
|
|
# Generate and return a new client secret
|
|
def generate_new_client_secret!
|
|
secret = SecureRandom.urlsafe_base64(48)
|
|
self.client_secret = secret
|
|
self.save!
|
|
secret
|
|
end
|
|
|
|
# Token TTL helper methods (for OIDC)
|
|
def access_token_expiry
|
|
(access_token_ttl || 3600).seconds.from_now
|
|
end
|
|
|
|
def refresh_token_expiry
|
|
(refresh_token_ttl || 2592000).seconds.from_now
|
|
end
|
|
|
|
def id_token_expiry_seconds
|
|
id_token_ttl || 3600
|
|
end
|
|
|
|
# Human-readable TTL for display
|
|
def access_token_ttl_human
|
|
duration_to_human(access_token_ttl || 3600)
|
|
end
|
|
|
|
def refresh_token_ttl_human
|
|
duration_to_human(refresh_token_ttl || 2592000)
|
|
end
|
|
|
|
def id_token_ttl_human
|
|
duration_to_human(id_token_ttl || 3600)
|
|
end
|
|
|
|
# Get app-specific custom claims for a user
|
|
def custom_claims_for_user(user)
|
|
app_claim = application_user_claims.find_by(user: user)
|
|
app_claim&.parsed_custom_claims || {}
|
|
end
|
|
|
|
private
|
|
|
|
def duration_to_human(seconds)
|
|
if seconds < 3600
|
|
"#{seconds / 60} minutes"
|
|
elsif seconds < 86400
|
|
"#{seconds / 3600} hours"
|
|
else
|
|
"#{seconds / 86400} days"
|
|
end
|
|
end
|
|
|
|
def generate_client_credentials
|
|
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
|
# Generate and hash the client secret
|
|
if new_record? && client_secret.blank?
|
|
secret = SecureRandom.urlsafe_base64(48)
|
|
self.client_secret = secret
|
|
end
|
|
end
|
|
end
|