Add configuration foward-auth headers

This commit is contained in:
Dan Milne
2025-10-26 14:41:20 +11:00
parent 2679634a2b
commit 88428bfd97
13 changed files with 543 additions and 178 deletions

View File

@@ -17,6 +17,8 @@ module Admin
def create def create
@forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params) @forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
# Handle headers configuration
@forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
if @forward_auth_rule.save if @forward_auth_rule.save
# Handle group assignments # Handle group assignments
@@ -38,6 +40,10 @@ module Admin
def update def update
if @forward_auth_rule.update(forward_auth_rule_params) if @forward_auth_rule.update(forward_auth_rule_params)
# Handle headers configuration
@forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
@forward_auth_rule.save!
# Handle group assignments # Handle group assignments
if params[:forward_auth_rule][:group_ids].present? if params[:forward_auth_rule][:group_ids].present?
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?) group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
@@ -67,5 +73,12 @@ module Admin
def forward_auth_rule_params def forward_auth_rule_params
params.require(:forward_auth_rule).permit(:domain_pattern, :active) params.require(:forward_auth_rule).permit(:domain_pattern, :active)
end end
def process_headers_config(headers_params)
return {} unless headers_params.is_a?(Hash)
# Clean up headers config - remove empty values, keep only filled ones
headers_params.select { |key, value| value.present? }.symbolize_keys
end
end end
end end

View File

@@ -64,18 +64,26 @@ module Api
end end
# User is authenticated and authorized # User is authenticated and authorized
# Return 200 with user information headers # Return 200 with user information headers using rule-specific configuration
response.headers["Remote-User"] = user.email_address headers = rule ? rule.headers_for_user(user) : ForwardAuthRule::DEFAULT_HEADERS.map { |key, header_name|
response.headers["Remote-Email"] = user.email_address case key
response.headers["Remote-Name"] = user.email_address when :user, :email, :name
[header_name, user.email_address]
# Add groups if user has any when :groups
if user.groups.any? user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
response.headers["Remote-Groups"] = user.groups.pluck(:name).join(",") when :admin
[header_name, user.admin? ? "true" : "false"]
end end
}.compact.to_h
# Add admin flag headers.each { |key, value| response.headers[key] = value }
response.headers["Remote-Admin"] = user.admin? ? "true" : "false"
# Log what headers we're sending (helpful for debugging)
if headers.any?
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
else
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
end
# Return 200 OK with no body # Return 200 OK with no body
head :ok head :ok

View File

@@ -7,6 +7,15 @@ class ForwardAuthRule < ApplicationRecord
normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase } normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
# Default header configuration
DEFAULT_HEADERS = {
user: 'X-Remote-User',
email: 'X-Remote-Email',
name: 'X-Remote-Name',
groups: 'X-Remote-Groups',
admin: 'X-Remote-Admin'
}.freeze
# Scopes # Scopes
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
scope :ordered, -> { order(domain_pattern: :asc) } scope :ordered, -> { order(domain_pattern: :asc) }
@@ -50,4 +59,36 @@ class ForwardAuthRule < ApplicationRecord
'deny' 'deny'
end end
end end
# Get effective header configuration (rule-specific + defaults)
def effective_headers
DEFAULT_HEADERS.merge((headers_config || {}).symbolize_keys)
end
# Generate headers for a specific user
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, :name
headers[header_name] = user.email_address
when :groups
headers[header_name] = user.groups.pluck(: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
def headers_disabled?
headers_config.present? && effective_headers.values.all?(&:blank?)
end
end end

View File

@@ -56,9 +56,11 @@
<% end %> <% end %>
</td> </td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %> <div class="flex justify-end space-x-3">
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %> <%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900" %> <%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
</div>
</td> </td>
</tr> </tr>
<% end %> <% end %>

View File

@@ -45,6 +45,75 @@
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass). Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
</p> </p>
</div> </div>
<div class="col-span-full">
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
HTTP Headers Configuration
</div>
<div class="mt-2 space-y-4">
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
<div>
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-User" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
</div>
<div>
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Email" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
</div>
<div>
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Name" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
</div>
<div>
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Groups" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
</div>
<div>
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Admin" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
</div>
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
<li>• <strong>Custom:</strong> Use application-specific headers</li>
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
</ul>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,89 +1,68 @@
<% content_for :title, "Forward Auth Rules" %>
<div class="sm:flex sm:items-center"> <div class="sm:flex sm:items-center">
<div class="sm:flex-auto"> <div class="sm:flex-auto">
<h1 class="text-base font-semibold leading-6 text-gray-900">Forward Auth Rules</h1> <h1 class="text-2xl font-semibold text-gray-900">Forward Auth Rules</h1>
<p class="mt-2 text-sm text-gray-700">A list of all forward authentication rules for domain-based access control.</p> <p class="mt-2 text-sm text-gray-700">Manage forward authentication rules for domain-based access control.</p>
</div> </div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> <div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %> <%= link_to "New Rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div> </div>
</div> </div>
<div class="mt-8 flow-root"> <div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<% if @forward_auth_rules.any? %>
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300"> <table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50"> <thead>
<tr> <tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Domain Pattern</th> <th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Domain Pattern</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Headers</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6"> <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span> <span class="sr-only">Actions</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody class="divide-y divide-gray-200">
<% @forward_auth_rules.each do |rule| %> <% @forward_auth_rules.each do |rule| %>
<tr> <tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6"> <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
<%= rule.domain_pattern %> <%= link_to rule.domain_pattern, admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
</td> </td>
<td class="px-3 py-4 text-sm text-gray-500"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if rule.allowed_groups.any? %> <% if rule.headers_config.blank? %>
<div class="flex flex-wrap gap-1"> <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
<% rule.allowed_groups.each do |group| %> <% elsif rule.headers_config.values.all?(&:blank?) %>
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700"> <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
<%= group.name %>
</span>
<% end %>
</div>
<% else %> <% else %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700"> <span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
Bypass (All Users)
</span>
<% end %> <% end %>
</td> </td>
<td class="px-3 py-4 text-sm text-gray-500"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if rule.allowed_groups.empty? %>
<span class="text-gray-400">All users</span>
<% else %>
<%= rule.allowed_groups.count %> groups
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if rule.active? %> <% if rule.active? %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700"> <span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
Active
</span>
<% else %> <% else %>
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700"> <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
Inactive
</span>
<% end %> <% end %>
</td> </td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"> <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-4" %> <div class="flex justify-end space-x-3">
<%= link_to "Delete", admin_forward_auth_rule_path(rule), <%= link_to "View", admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
data: { <%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
turbo_method: :delete, <%= button_to "Delete", admin_forward_auth_rule_path(rule), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this forward auth rule?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
turbo_confirm: "Are you sure you want to delete this forward auth rule?" </div>
},
class: "text-red-600 hover:text-red-900" %>
</td> </td>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>
</table> </table>
</div> </div>
<% else %>
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<h3 class="mt-2 text-sm font-semibold text-gray-900">No forward auth rules</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a new forward authentication rule.</p>
<div class="mt-6">
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
</div>
<% end %>
</div>
</div> </div>
</div> </div>

View File

@@ -45,6 +45,75 @@
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass). Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
</p> </p>
</div> </div>
<div class="col-span-full">
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
HTTP Headers Configuration
</div>
<div class="mt-2 space-y-4">
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
<div>
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-User" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
</div>
<div>
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Email" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
</div>
<div>
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Name" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
</div>
<div>
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Groups" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
</div>
<div>
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Admin" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
</div>
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
<li>• <strong>Custom:</strong> Use application-specific headers</li>
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
</ul>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,110 +1,115 @@
<% content_for :title, "Forward Auth Rule: #{@forward_auth_rule.domain_pattern}" %> <div class="mb-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div class="md:flex md:items-center md:justify-between"> <div>
<div class="min-w-0 flex-1"> <h1 class="text-2xl font-semibold text-gray-900"><%= @forward_auth_rule.domain_pattern %></h1>
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight"> <p class="mt-1 text-sm text-gray-500">Forward authentication rule for domain-based access control</p>
<%= @forward_auth_rule.domain_pattern %> </div>
</h2> <div class="mt-4 sm:mt-0 flex gap-3">
<%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<%= button_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
</div> </div>
<div class="mt-4 flex md:ml-4 md:mt-0">
<%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<%= link_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule),
data: {
turbo_method: :delete,
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
},
class: "ml-3 inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" %>
</div> </div>
</div> </div>
<div class="mt-8"> <div class="space-y-6">
<div class="bg-white shadow overflow-hidden sm:rounded-lg"> <!-- Basic Information -->
<div class="px-4 py-5 sm:px-6"> <div class="bg-white shadow sm:rounded-lg">
<h3 class="text-lg leading-6 font-medium text-gray-900">Rule Details</h3> <div class="px-4 py-5 sm:p-6">
<p class="mt-1 max-w-2xl text-sm text-gray-500">Forward authentication rule configuration.</p> <h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Basic Information</h3>
</div> <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div class="border-t border-gray-200"> <div>
<dl>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt> <dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900"><code class="bg-gray-100 px-2 py-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></dd>
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @forward_auth_rule.domain_pattern %></code>
</dd>
</div> </div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <div>
<dt class="text-sm font-medium text-gray-500">Status</dt> <dt class="text-sm font-medium text-gray-500">Status</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900">
<% if @forward_auth_rule.active? %> <% if @forward_auth_rule.active? %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700"> <span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
Active
</span>
<% else %> <% else %>
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700"> <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
Inactive
</span>
<% end %> <% end %>
</dd> </dd>
</div> </div>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <div>
<dt class="text-sm font-medium text-gray-500">Access Policy</dt> <dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0"> <dd class="mt-1 text-sm text-gray-900">
<% if @allowed_groups.any? %> <% if @forward_auth_rule.headers_config.blank? %>
<div class="space-y-2"> <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
<p class="text-sm">Only users in these groups are allowed access:</p> <% elsif @forward_auth_rule.headers_config.values.all?(&:blank?) %>
<div class="flex flex-wrap gap-2"> <span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
<% @allowed_groups.each do |group| %>
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
<%= group.name %>
</span>
<% end %>
</div>
</div>
<% else %> <% else %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700"> <span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
Bypass - All authenticated users allowed
</span>
<% end %> <% end %>
</dd> </dd>
</div> </div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<%= @forward_auth_rule.created_at.strftime("%B %d, %Y at %I:%M %p") %>
</dd>
</div>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<%= @forward_auth_rule.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
</dd>
</div>
</dl> </dl>
</div> </div>
</div> </div>
<!-- Header Configuration -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Header Configuration</h3>
<div class="space-y-4">
<% effective_headers = @forward_auth_rule.effective_headers %>
<% if effective_headers.empty? %>
<div class="rounded-md bg-gray-50 p-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-gray-700">
No headers configured - access control only.
</p>
</div>
</div>
</div>
<% else %>
<dl class="space-y-4">
<% effective_headers.each do |key, header_name| %>
<div>
<dt class="text-sm font-medium text-gray-500"><%= key.to_s.capitalize %></dt>
<dd class="mt-1 text-sm text-gray-900">
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= header_name %></code>
</dd>
</div>
<% end %>
</dl>
<% end %>
</div>
</div>
</div> </div>
<div class="mt-8"> <!-- Group Access Control -->
<div class="bg-blue-50 border-l-4 border-blue-400 p-4"> <div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Access Control</h3>
<div>
<dt class="text-sm font-medium text-gray-500 mb-2">Allowed Groups</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @allowed_groups.empty? %>
<div class="rounded-md bg-blue-50 p-4">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3"> <div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">How this rule works</h3> <p class="text-sm text-blue-700">
<div class="mt-2 text-sm text-blue-700"> No groups assigned - all active users can access this domain.
<ul class="list-disc list-inside space-y-1"> </p>
<li>This rule matches domains that fit the pattern: <code class="bg-blue-100 px-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></li> </div>
<% if @allowed_groups.any? %> </div>
<li>Only users belonging to the specified groups will be granted access</li> </div>
<li>Users will be required to authenticate with password (and 2FA if enabled)</li>
<% else %> <% else %>
<li>All authenticated users will be granted access (bypass mode)</li> <ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
<% @allowed_groups.each do |group| %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900"><%= group.name %></p>
<p class="text-xs text-gray-500"><%= pluralize(group.users.count, "member") %></p>
</div>
</li>
<% end %> <% end %>
<li>Inactive rules are ignored during authentication</li>
</ul> </ul>
</div> <% end %>
</dd>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,12 @@
<p>
You've been invited to join Clinch! To set up your account and create your password, please visit
<%= link_to "this invitation page", invite_url(@user.invitation_login_token) %>.
</p>
<p>
This invitation link will expire in <%= distance_of_time_in_words(0, @user.invitation_login_token_expires_in) %>.
</p>
<p>
If you didn't expect this invitation, you can safely ignore this email.
</p>

View File

@@ -0,0 +1,8 @@
You've been invited to join Clinch!
To set up your account and create your password, please visit:
#{invite_url(@user.invitation_login_token)}
This invitation link will expire in #{distance_of_time_in_words(0, @user.invitation_login_token_expires_in)}.
If you didn't expect this invitation, you can safely ignore this email.

View File

@@ -0,0 +1,5 @@
class AddHeadersConfigToForwardAuthRule < ActiveRecord::Migration[8.1]
def change
add_column :forward_auth_rules, :headers_config, :json, default: {}, null: false
end
end

3
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_10_24_055739) do ActiveRecord::Schema[8.1].define(version: 2025_10_26_033102) do
create_table "application_groups", force: :cascade do |t| create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -68,6 +68,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_24_055739) do
t.boolean "active" t.boolean "active"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.string "domain_pattern" t.string "domain_pattern"
t.json "headers_config", default: {}, null: false
t.integer "policy" t.integer "policy"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end end

153
docs/forward-auth.md Normal file
View File

@@ -0,0 +1,153 @@
# Forward Authentication
References:
- https://www.reddit.com/r/selfhosted/comments/1hybe81/i_wanted_to_implement_my_own_forward_auth_proxy/
- https://www.kevinsimper.dk/posts/implementing-a-forward_auth-proxy-tips-and-details
## Overview
Forward authentication allows a reverse proxy (like Caddy, Nginx, Traefik) to delegate authentication decisions to a separate service. Clinch implements this pattern to provide SSO for multiple applications.
## Key Implementation Details
### Tip 1: Forward URL Configuration ✅
Clinch includes the original destination URL in the redirect parameters:
```ruby
login_params = {
rd: original_url, # redirect destination
rm: request.method # request method
}
login_url = "#{base_url}/signin?#{login_params.to_query}"
```
Example: `https://clinch.aapamilne.com/signin?rd=https://metube.aapamilne.com/&rm=GET`
### Tip 2: Root Domain Cookies ✅
Clinch sets authentication cookies on the root domain to enable cross-subdomain authentication:
```ruby
def extract_root_domain(host)
# clinch.aapamilne.com -> .aapamilne.com
# app.example.co.uk -> .example.co.uk
# localhost -> nil (no domain restriction)
end
cookies.signed.permanent[:session_id] = {
value: session.id,
httponly: true,
same_site: :lax,
secure: Rails.env.production?,
domain: ".aapamilne.com" # Available to all subdomains
}
```
This allows the same session cookie to work across:
- `clinch.aapamilne.com` (auth service)
- `metube.aapamilne.com` (protected app)
- `sonarr.aapamilne.com` (protected app)
## Authelia Analysis
### Implementation Comparison
**Authelia Approach (from analysis of `tmp/authelia/`):**
- Returns `302 Found` or `303 See Other` with `Location` header
- Direct browser redirects (bypasses some proxy logic)
- Uses StatusFound (302) or StatusSeeOther (303)
**Clinch Current Implementation:**
- Returns `302 Found` directly to login URL (matching Authelia)
- Includes `rd` (redirect destination) and `rm` (request method) parameters
- Uses root domain cookies for cross-subdomain authentication
## How Clinch Forward Auth Works
### Authentication Flow
1. **User visits** `https://metube.aapamilne.com/`
2. **Caddy forwards** to `http://clinch:9000/api/verify?rd=https://clinch.aapamilne.com`
3. **Clinch checks session**:
- **If authenticated**: Returns `200 OK` with user headers
- **If not authenticated**: Returns `302 Found` to login URL with redirect parameters
4. **Browser follows redirect** to Clinch login page
5. **User logs in** → gets redirected back to original MEtube URL
6. **Caddy tries again** → succeeds and forwards to MEtube
### Response Headers
**Successful Authentication (200 OK):**
```
Remote-User: user@example.com
Remote-Email: user@example.com
Remote-Groups: media-managers,users
Remote-Admin: false
```
**Redirect to Login (302 Found):**
```
Location: https://clinch.aapamilne.com/signin?rd=https://metube.aapamilne.com/&rm=GET
```
## Caddy Configuration
```caddyfile
# Clinch SSO (main authentication server)
clinch.aapamilne.com {
reverse_proxy clinch:9000
}
# MEtube (protected by Clinch)
metube.aapamilne.com {
forward_auth clinch:9000 {
uri /api/verify?rd=https://clinch.aapamilne.com
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
}
handle {
reverse_proxy * {
to http://192.168.2.223:8081
header_up X-Real-IP {remote_host}
}
}
}
```
## Key Files
- **Forward Auth Controller**: `app/controllers/api/forward_auth_controller.rb`
- **Authentication Logic**: `app/controllers/concerns/authentication.rb`
- **Caddy Examples**: `docs/caddy-example.md`
- **Authelia Analysis**: `docs/authelia-forward-auth.md`
## Testing
```bash
# Test forward auth endpoint directly
curl -v http://localhost:9000/api/verify?rd=https://clinch.aapamilne.com
# Should return 302 redirect to login page
# Or 200 OK if you have a valid session cookie
```
## Troubleshooting
### Common Issues
1. **Authentication Loop**: Check that cookies are set on the root domain
2. **Session Not Shared**: Verify `extract_root_domain` is working correctly
3. **Caddy Connection**: Ensure `clinch:9000` resolves from your Caddy container
### Debug Logging
Enable debug logging in `forward_auth_controller.rb` to see:
- Headers received from Caddy
- Domain extraction results
- Redirect URLs being generated
```ruby
Rails.logger.info "ForwardAuth Headers: Host=#{host}, X-Forwarded-Host=#{original_host}"
Rails.logger.info "Setting 302 redirect to: #{login_url}"
```