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 # Fail closed: with no host we cannot resolve an application or evaluate its # group policy. Emitting identity headers here would bypass all per-domain # access control, so reject instead. Rails.logger.info "ForwardAuth: Access denied - no host header present" return render_forbidden("No host header present") end # Reaching here implies a matching, active application was resolved above # (every other path returns forbidden), so headers are always scoped to it. headers = app.headers_for_user(user) 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 # Re-check group membership at use-time. The ApiKey model only validates # access on creation, so a user removed from the app's allowed groups # afterwards must not keep access via an existing key. unless app.user_allowed?(user) Rails.logger.info "ForwardAuth: API key '#{api_key.name}' denied - user #{user.email_address} lacks group access to #{app.domain_pattern}" return render_bearer_error("Access denied: insufficient group membership") 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 if token.blank? cached = Rails.cache.read("forward_auth_token:#{token}") return nil unless cached.is_a?(Hash) # The token is bound to the host that created it. If the request is # arriving at a different host, refuse — and do NOT burn the cache # entry, so that the legitimate destination can still redeem within # the 60s TTL. request_host = (request.headers["X-Forwarded-Host"] || request.headers["Host"]) .to_s.sub(/:\d+\z/, "").downcase return nil if request_host.blank? return nil unless cached[:host] == request_host session = Session.find_by(id: cached[:session_id]) return nil unless session && !session.expired? Rails.cache.delete("forward_auth_token:#{token}") cached[: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"] || "/" # X-Forwarded-Host is attacker-influenceable, so only honour the forwarded # URL as a post-login redirect target if it resolves to a known, active # forward-auth application. Otherwise this is an open redirect: a spoofed # host would be stored and reflected into the signin `rd`, then followed # (with allow_other_host) after the user authenticates. Fall back to a # validated `rd` or, failing that, the IdP's own base URL. forwarded_url = "https://#{original_host}#{original_uri}" if original_host.present? original_url = validate_redirect_url(forwarded_url) || redirect_url || base_url 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? # CLINCH_HOST is the IdP's canonical origin and is mandatory in deployed # environments (enforced at boot in config/initializers/clinch_host.rb). # We never fall back to the request host: a spoofed X-Forwarded-Host would # otherwise redirect the login flow to an attacker-controlled origin. The # localhost default only applies to local dev/test. host = ENV["CLINCH_HOST"].presence || "http://localhost:3000" host.match?(%r{\Ahttps?://}) ? host : "https://#{host}" end end end