Files
clinch/app/models/application.rb
T
Dan MilneandClaude Opus 4.8 7149b98b7b Add OAuth device flow, token introspection, and dynamic client registration
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
2026-07-19 12:30:02 +10:00

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