An observed fa_token (via Referer leaks, access logs, JS monitors) could previously be redeemed against a different reverse-proxied app within the 60s TTL. The token now stores the destination host at creation and the verifier rejects mismatches without burning the cache entry, so legitimate destinations can still redeem. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
165 lines
5.4 KiB
Ruby
165 lines
5.4 KiB
Ruby
require "uri"
|
|
require "public_suffix"
|
|
require "ipaddr"
|
|
|
|
module Authentication
|
|
extend ActiveSupport::Concern
|
|
|
|
included do
|
|
before_action :require_authentication
|
|
helper_method :authenticated?
|
|
end
|
|
|
|
class_methods do
|
|
def allow_unauthenticated_access(**options)
|
|
skip_before_action :require_authentication, **options
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def authenticated?
|
|
resume_session
|
|
end
|
|
|
|
def require_authentication
|
|
resume_session || request_authentication
|
|
end
|
|
|
|
def resume_session
|
|
Current.session ||= find_session_by_cookie
|
|
end
|
|
|
|
def find_session_by_cookie
|
|
Session.active.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
|
end
|
|
|
|
def request_authentication
|
|
session[:return_to_after_authenticating] = request.url
|
|
redirect_to signin_path
|
|
end
|
|
|
|
def after_authentication_url
|
|
session.delete(:return_to_after_authenticating) || root_url
|
|
end
|
|
|
|
def start_new_session_for(user, acr: "1", remember_me: false)
|
|
user.update!(last_sign_in_at: Time.current)
|
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|
|
|
Current.session = session
|
|
|
|
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
|
domain = extract_root_domain(request.host)
|
|
|
|
# Set cookie options based on environment
|
|
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
|
|
# Development: Use SameSite=Lax since HTTPS might not be available
|
|
cookie_options = if Rails.env.production?
|
|
{
|
|
value: session.id,
|
|
httponly: true,
|
|
same_site: :lax,
|
|
secure: true
|
|
}
|
|
else
|
|
{
|
|
value: session.id,
|
|
httponly: true,
|
|
same_site: :lax,
|
|
secure: false
|
|
}
|
|
end
|
|
|
|
# Set domain for cross-subdomain authentication if we can extract it
|
|
cookie_options[:domain] = domain if domain.present?
|
|
|
|
cookies.signed.permanent[:session_id] = cookie_options
|
|
|
|
# Create a one-time token for immediate forward auth after authentication
|
|
# This solves the race condition where browser hasn't processed cookie yet
|
|
create_forward_auth_token(session)
|
|
end
|
|
end
|
|
|
|
def terminate_session
|
|
Current.session.destroy
|
|
cookies.delete(:session_id)
|
|
end
|
|
|
|
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
|
#
|
|
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
|
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
|
# both app.example.com and api.example.com).
|
|
#
|
|
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
|
# When accessing services by IP, there are no subdomains to share cookies with,
|
|
# and setting a domain cookie would break authentication.
|
|
#
|
|
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
|
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
|
#
|
|
# Examples:
|
|
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
|
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
|
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
|
# - localhost -> nil (local development, no domain cookie)
|
|
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
|
#
|
|
# @param host [String] The request host (may include port)
|
|
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
|
def extract_root_domain(host)
|
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
|
|
|
# Strip port number for domain parsing
|
|
host_without_port = host.split(":").first
|
|
|
|
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
|
begin
|
|
return nil if IPAddr.new(host_without_port)
|
|
rescue
|
|
false
|
|
end
|
|
|
|
# Use Public Suffix List for accurate domain parsing
|
|
domain = PublicSuffix.parse(host_without_port)
|
|
".#{domain.domain}"
|
|
rescue PublicSuffix::DomainInvalid
|
|
# Fallback for invalid domains or IPs
|
|
nil
|
|
end
|
|
|
|
# Create a one-time token for forward auth to handle the race condition
|
|
# where the browser hasn't processed the session cookie yet.
|
|
#
|
|
# The token is bound to the destination host so that anyone who observes
|
|
# the token (Referer leaks, access logs, JS monitors) cannot redeem it for
|
|
# a different application within the 60-second TTL.
|
|
def create_forward_auth_token(session_obj)
|
|
controller_session = session
|
|
return unless controller_session[:return_to_after_authenticating].present?
|
|
|
|
uri = URI.parse(controller_session[:return_to_after_authenticating])
|
|
|
|
# OAuth flow handles its own session propagation — no fa_token needed.
|
|
return if uri.path&.start_with?("/oauth/")
|
|
|
|
# Path-only URLs are same-origin on Clinch; the cookie race doesn't apply
|
|
# and we have no destination host to bind against.
|
|
bound_host = uri.hostname&.downcase
|
|
return if bound_host.blank?
|
|
|
|
token = SecureRandom.urlsafe_base64(32)
|
|
Rails.cache.write(
|
|
"forward_auth_token:#{token}",
|
|
{ session_id: session_obj.id, host: bound_host },
|
|
expires_in: 60.seconds
|
|
)
|
|
|
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
|
query_params["fa_token"] = token
|
|
uri.query = URI.encode_www_form(query_params)
|
|
controller_session[:return_to_after_authenticating] = uri.to_s
|
|
end
|
|
end
|