Adds three OAuth surfaces to the OIDC provider so CLIs, terminal agents, and MCP connectors can authenticate as real users instead of using static API keys. Device Authorization Grant (RFC 8628): - OidcDeviceCode model (HMAC device_code, short plaintext user_code, nullable user until approval, slow_down polling state), mirroring OidcAuthorizationCode - POST /oauth/device_authorization issues the code pair + verification URIs - device_code grant on /oauth/token returns authorization_pending / slow_down / access_denied / expired_token, then the standard token triple; single-use - Authenticated /device approval page, gated by Application#user_allowed? Token Introspection (RFC 7662): - POST /oauth/introspect: confidential-caller-authenticated; returns active, scope, and the user's groups so resource servers can authorize on membership Dynamic Client Registration (RFC 7591): - POST /oauth/register creates public/confidential clients (PKCE required) - Runtime toggle via a new Setting store + admin switch on the Applications page; off by default, env var CLINCH_DCR_ENABLED as bootstrap fallback - New clients are default-deny (no allowed_groups) until an admin grants access - RFC 8414 metadata alias at /.well-known/oauth-authorization-server; registration_endpoint advertised only while the window is open Discovery advertises all three grants/endpoints. Seeds add a clinch-cli public client and a c2a2-introspection confidential client. ADRs in docs/decisions record the opaque-vs-JWT, device-flow, and DCR-security decisions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
424 lines
14 KiB
Ruby
424 lines
14 KiB
Ruby
class Application < ApplicationRecord
|
|
has_secure_password :client_secret, validations: false
|
|
|
|
# Virtual attribute to control client type during creation
|
|
# When true, no client_secret will be generated (public client)
|
|
attr_accessor :is_public_client
|
|
|
|
# Virtual setters for TTL fields - accept human-friendly durations
|
|
# e.g., "1h", "30m", "1d", or plain numbers "3600"
|
|
def access_token_ttl=(value)
|
|
parsed = DurationParser.parse(value)
|
|
super(parsed)
|
|
end
|
|
|
|
def refresh_token_ttl=(value)
|
|
parsed = DurationParser.parse(value)
|
|
super(parsed)
|
|
end
|
|
|
|
def id_token_ttl=(value)
|
|
parsed = DurationParser.parse(value)
|
|
super(parsed)
|
|
end
|
|
|
|
after_commit :bust_forward_auth_cache, if: :forward_auth?
|
|
|
|
has_one_attached :icon
|
|
has_one_attached :icon_dark
|
|
|
|
ICON_ATTACHMENTS = %i[icon icon_dark].freeze
|
|
|
|
before_validation :sanitize_svg_icons
|
|
after_save :fix_icon_content_types
|
|
|
|
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
|
|
has_many :api_keys, 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? && confidential_client? }
|
|
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
|
|
validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
|
|
validates :backchannel_logout_uri, format: {
|
|
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
|
|
allow_nil: true,
|
|
message: "must be a valid HTTP or HTTPS URL"
|
|
}
|
|
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
|
validate :backchannel_logout_uri_not_internal, if: -> { backchannel_logout_uri.present? }
|
|
|
|
# Icon validation using ActiveStorage validators
|
|
validate :icon_validation
|
|
|
|
# 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: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 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
|
|
}
|
|
normalizes :backchannel_logout_uri, with: ->(uri) {
|
|
normalized = uri&.strip
|
|
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",
|
|
username: "X-Remote-Username",
|
|
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
|
|
|
|
DCR_SETTING_KEY = "dynamic_client_registration".freeze
|
|
|
|
# OAuth 2.0 Dynamic Client Registration (RFC 7591) is opt-in: it lets anyone
|
|
# anonymously create an OIDC client, so it is disabled by default. An admin
|
|
# toggles it at runtime (open the window, let a client self-register, attach it
|
|
# to a group, close the window again). Newly registered clients are still
|
|
# default-deny (no allowed_groups) until an admin grants access.
|
|
#
|
|
# The persisted Setting is authoritative once set; until then we fall back to
|
|
# the CLINCH_DCR_ENABLED env var (bootstrap/headless default, off if unset).
|
|
def self.dynamic_registration_enabled?
|
|
stored = Setting.boolean(DCR_SETTING_KEY)
|
|
return stored unless stored.nil?
|
|
ActiveModel::Type::Boolean.new.cast(ENV["CLINCH_DCR_ENABLED"])
|
|
end
|
|
|
|
# Client type checks (for OIDC)
|
|
def public_client?
|
|
client_secret_digest.blank?
|
|
end
|
|
|
|
def confidential_client?
|
|
!public_client?
|
|
end
|
|
|
|
# PKCE requirement check
|
|
# Public clients MUST use PKCE (no client secret to protect auth code)
|
|
# Confidential clients can optionally require PKCE (OAuth 2.1 recommendation)
|
|
def requires_pkce?
|
|
return false unless oidc?
|
|
return true if public_client? # Always require PKCE for public clients
|
|
require_pkce? # Check the flag for confidential clients
|
|
end
|
|
|
|
# Access control
|
|
# Default-deny: an empty allowed_groups list means no one gets in.
|
|
# To make an app accessible to "everyone", attach the seeded auto-assign
|
|
# group (or any group every user is in).
|
|
def user_allowed?(user)
|
|
return false unless active?
|
|
return false unless user.active?
|
|
(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 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 :username
|
|
headers[header_name] = user.username if user.username.present?
|
|
when :groups
|
|
headers[header_name] = user.groups.map(&: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
|
|
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
|
|
|
|
# Check if this application supports backchannel logout
|
|
def supports_backchannel_logout?
|
|
backchannel_logout_uri.present?
|
|
end
|
|
|
|
# Check if a user has an active session with this application
|
|
# (i.e., has valid, non-revoked tokens)
|
|
def user_has_active_session?(user)
|
|
oidc_access_tokens.where(user: user).valid.exists? ||
|
|
oidc_refresh_tokens.where(user: user).valid.exists?
|
|
end
|
|
|
|
private
|
|
|
|
def bust_forward_auth_cache
|
|
Rails.application.config.forward_auth_cache&.delete("fa_apps")
|
|
end
|
|
|
|
def fix_icon_content_types
|
|
ICON_ATTACHMENTS.each do |attr|
|
|
attachment = public_send(attr)
|
|
next unless attachment.attached?
|
|
# Fix SVG content type if it was detected incorrectly
|
|
if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
|
|
attachment.blob.update(content_type: "image/svg+xml")
|
|
end
|
|
end
|
|
end
|
|
|
|
def sanitize_svg_icons
|
|
# Runs in before_validation. The blob has NOT yet been uploaded to disk at
|
|
# this point (Active Storage uploads in before_save), so we cannot call
|
|
# download — we must read from the pending attachable.
|
|
#
|
|
# attach below re-sets attachment_changes and would re-fire this callback;
|
|
# we skip if the pending attachable is the cleaned hash we just installed
|
|
# (tracked by object identity, per-attribute).
|
|
@svg_sanitized_attachables ||= {}
|
|
|
|
ICON_ATTACHMENTS.each do |attr|
|
|
change = attachment_changes[attr.to_s]
|
|
next unless change
|
|
attachable = change.attachable
|
|
next if attachable.equal?(@svg_sanitized_attachables[attr])
|
|
|
|
raw_svg, filename, content_type = read_pending_icon(attachable)
|
|
next unless raw_svg
|
|
next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
|
|
|
|
doc = Loofah.xml_document(raw_svg)
|
|
doc.scrub!(SvgScrubber.new)
|
|
clean_svg = doc.to_xml
|
|
|
|
sanitized = {
|
|
io: StringIO.new(clean_svg),
|
|
filename: filename,
|
|
content_type: "image/svg+xml"
|
|
}
|
|
@svg_sanitized_attachables[attr] = sanitized
|
|
public_send(attr).attach(sanitized)
|
|
end
|
|
end
|
|
|
|
def read_pending_icon(attachable)
|
|
case attachable
|
|
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
|
|
content = attachable.read
|
|
attachable.rewind
|
|
[content, attachable.original_filename, attachable.content_type]
|
|
when Hash
|
|
io = attachable[:io] || attachable["io"]
|
|
return [nil, nil, nil] unless io
|
|
content = io.read
|
|
io.rewind if io.respond_to?(:rewind)
|
|
[content,
|
|
attachable[:filename] || attachable["filename"],
|
|
attachable[:content_type] || attachable["content_type"]]
|
|
else
|
|
[nil, nil, nil]
|
|
end
|
|
end
|
|
|
|
def icon_validation
|
|
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
|
|
|
ICON_ATTACHMENTS.each do |attr|
|
|
attachment = public_send(attr)
|
|
next unless attachment.attached?
|
|
|
|
unless allowed_types.include?(attachment.content_type)
|
|
errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
|
|
end
|
|
|
|
if attachment.blob.byte_size > 2.megabytes
|
|
errors.add(attr, "must be less than 2MB")
|
|
end
|
|
end
|
|
end
|
|
|
|
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 client secret only for confidential clients
|
|
# Public clients (is_public_client checked) don't get a secret - they use PKCE only
|
|
if new_record? && client_secret.blank? && !is_public_client_selected?
|
|
secret = SecureRandom.urlsafe_base64(48)
|
|
self.client_secret = secret
|
|
end
|
|
end
|
|
|
|
# Check if the user selected public client option
|
|
def is_public_client_selected?
|
|
ActiveModel::Type::Boolean.new.cast(is_public_client)
|
|
end
|
|
|
|
def backchannel_logout_uri_must_be_https_in_production
|
|
return unless Rails.env.production?
|
|
return unless backchannel_logout_uri.present?
|
|
|
|
begin
|
|
uri = URI.parse(backchannel_logout_uri)
|
|
unless uri.scheme == "https"
|
|
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
|
|
end
|
|
rescue URI::InvalidURIError
|
|
# Let the format validator handle invalid URIs
|
|
end
|
|
end
|
|
|
|
# SSRF guard: the backchannel logout URI is dialled server-side on every user
|
|
# logout, so it must not target internal infrastructure (loopback, private
|
|
# ranges, or the link-local cloud metadata endpoint). This is the fast,
|
|
# config-time check; BackchannelLogoutJob re-checks with DNS resolution.
|
|
def backchannel_logout_uri_not_internal
|
|
uri = URI.parse(backchannel_logout_uri)
|
|
if uri.host.present? && PrivateAddressCheck.internal_host?(uri.host)
|
|
errors.add(:backchannel_logout_uri, "must not point to a private, loopback, or link-local address")
|
|
end
|
|
rescue URI::InvalidURIError
|
|
# Let the format validator handle invalid URIs
|
|
end
|
|
end
|