module Api class ForwardAuthController < ApplicationController allow_unauthenticated_access skip_before_action :verify_authenticity_token before_action :check_forward_auth_rate_limit after_action :track_failed_forward_auth_attempt # GET /api/verify # Called by reverse proxies (Traefik, Caddy, nginx) to verify authentication and authorization. def verify bearer_result = authenticate_bearer_token return bearer_result if bearer_result session_id = check_forward_auth_token session_id ||= extract_session_id unless session_id return render_unauthorized("No session cookie") end session = Session.includes(user: :groups).find_by(id: session_id) unless session return render_unauthorized("Invalid session") end if session.expired? session.destroy return render_unauthorized("Session expired") end # Debounce last_activity_at updates (at most once per minute) if session.last_activity_at.nil? || session.last_activity_at < 1.minute.ago session.update_column(:last_activity_at, Time.current) end user = session.user unless user.active? return render_unauthorized("User account is not active") end forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"] app = nil if forwarded_host.present? apps = cached_forward_auth_apps app = apps.find { |a| a.matches_domain?(forwarded_host) } if app unless app.active? Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive" return render_forbidden("No authentication rule configured for this domain") end unless app.user_allowed?(user) Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}" return render_forbidden("You do not have permission to access this domain") end Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})" else Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured" return render_forbidden("No authentication rule configured for this domain") end else Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)" end headers = if app app.headers_for_user(user) else Application::DEFAULT_HEADERS.map { |key, header_name| case key when :user, :email, :name [header_name, user.email_address] when :username [header_name, user.username] if user.username.present? when :groups user.groups.any? ? [header_name, user.groups.map(&:name).join(",")] : nil when :admin [header_name, user.admin? ? "true" : "false"] end }.compact.to_h end headers.each { |key, value| response.headers[key] = value } Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any? head :ok end private def fa_cache Rails.application.config.forward_auth_cache end def cached_forward_auth_apps fa_cache.fetch("fa_apps", expires_in: 5.minutes) do Application.forward_auth.includes(:allowed_groups).to_a end end RATE_LIMIT_MAX_FAILURES = 50 RATE_LIMIT_WINDOW = 1.minute def check_forward_auth_rate_limit count = fa_cache.read("fa_fail:#{request.remote_ip}") return unless count && count >= RATE_LIMIT_MAX_FAILURES response.headers["Retry-After"] = "60" head :too_many_requests end def track_failed_forward_auth_attempt return unless response.status.in?([401, 403, 302]) return if response.status == 302 && !response.headers["X-Auth-Reason"] cache_key = "fa_fail:#{request.remote_ip}" # Use increment to avoid resetting TTL on each failure (fixed window) unless fa_cache.increment(cache_key) fa_cache.write(cache_key, 1, expires_in: RATE_LIMIT_WINDOW) end end def authenticate_bearer_token auth_header = request.headers["Authorization"] return nil unless auth_header&.start_with?("Bearer ") token = auth_header.delete_prefix("Bearer ").strip return render_bearer_error("Missing token") if token.blank? api_key = ApiKey.find_by_token(token) return render_bearer_error("Invalid or expired API key") unless api_key&.active? user = api_key.user return render_bearer_error("User account is not active") unless user.active? forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"] app = api_key.application if forwarded_host.present? && !app.matches_domain?(forwarded_host) return render_bearer_error("API key not valid for this domain") end unless app.active? return render_bearer_error("Application is inactive") end api_key.touch_last_used! headers = app.headers_for_user(user) headers.each { |key, value| response.headers[key] = value } Rails.logger.info "ForwardAuth: API key '#{api_key.name}' authenticated user #{user.email_address} for #{forwarded_host}" head :ok end def render_bearer_error(message) render json: { error: message }, status: :unauthorized end def check_forward_auth_token token = params[:fa_token] return nil unless token.present? session_id = Rails.cache.read("forward_auth_token:#{token}") return nil unless session_id session = Session.find_by(id: session_id) return nil unless session && !session.expired? Rails.cache.delete("forward_auth_token:#{token}") session_id end def extract_session_id cookies.signed[:session_id] end def render_unauthorized(reason = nil) Rails.logger.info "ForwardAuth: Unauthorized - #{reason}" response.headers["X-Auth-Reason"] = reason if reason.present? redirect_url = validate_redirect_url(params[:rd]) base_url = determine_base_url(redirect_url) original_host = request.headers["X-Forwarded-Host"] original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/" original_url = if original_host "https://#{original_host}#{original_uri}" else redirect_url || base_url end session[:return_to_after_authenticating] = original_url login_params = { rd: original_url, rm: request.method } login_url = "#{base_url}/signin?#{login_params.to_query}" redirect_to login_url, allow_other_host: true, status: :found end def render_forbidden(reason = nil) Rails.logger.info "ForwardAuth: Forbidden - #{reason}" response.headers["X-Auth-Reason"] = reason if reason.present? head :forbidden end def validate_redirect_url(url) return nil unless url.present? begin uri = URI.parse(url) return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) return nil unless Rails.env.development? || uri.scheme == "https" redirect_domain = uri.host.downcase return nil unless redirect_domain.present? matching_app = cached_forward_auth_apps.find do |app| app.active? && app.matches_domain?(redirect_domain) end matching_app ? url : nil rescue URI::InvalidURIError nil end end def determine_base_url(redirect_url) return redirect_url if redirect_url.present? if ENV["CLINCH_HOST"].present? host = ENV["CLINCH_HOST"] host.match?(/^https?:\/\//) ? host : "https://#{host}" else request_host = request.host || request.headers["X-Forwarded-Host"] if request_host.present? Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}" "https://#{request_host}" else raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available." end end end end end