diff --git a/app/controllers/admin/forward_auth_rules_controller.rb b/app/controllers/admin/forward_auth_rules_controller.rb index 740df3e..17c1b4c 100644 --- a/app/controllers/admin/forward_auth_rules_controller.rb +++ b/app/controllers/admin/forward_auth_rules_controller.rb @@ -17,6 +17,8 @@ module Admin def create @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 # Handle group assignments @@ -38,6 +40,10 @@ module Admin def update 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 if params[:forward_auth_rule][:group_ids].present? group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?) @@ -67,5 +73,12 @@ module Admin def forward_auth_rule_params params.require(:forward_auth_rule).permit(:domain_pattern, :active) 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 \ No newline at end of file diff --git a/app/controllers/api/forward_auth_controller.rb b/app/controllers/api/forward_auth_controller.rb index 7678061..51603bd 100644 --- a/app/controllers/api/forward_auth_controller.rb +++ b/app/controllers/api/forward_auth_controller.rb @@ -64,19 +64,27 @@ module Api end # User is authenticated and authorized - # Return 200 with user information headers - response.headers["Remote-User"] = user.email_address - response.headers["Remote-Email"] = user.email_address - response.headers["Remote-Name"] = user.email_address + # Return 200 with user information headers using rule-specific configuration + headers = rule ? rule.headers_for_user(user) : ForwardAuthRule::DEFAULT_HEADERS.map { |key, header_name| + case key + when :user, :email, :name + [header_name, user.email_address] + when :groups + user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil + when :admin + [header_name, user.admin? ? "true" : "false"] + end + }.compact.to_h - # Add groups if user has any - if user.groups.any? - response.headers["Remote-Groups"] = user.groups.pluck(:name).join(",") + headers.each { |key, value| response.headers[key] = value } + + # 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 - # Add admin flag - response.headers["Remote-Admin"] = user.admin? ? "true" : "false" - # Return 200 OK with no body head :ok end diff --git a/app/models/forward_auth_rule.rb b/app/models/forward_auth_rule.rb index fef753e..19f86c2 100644 --- a/app/models/forward_auth_rule.rb +++ b/app/models/forward_auth_rule.rb @@ -7,6 +7,15 @@ class ForwardAuthRule < ApplicationRecord 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 scope :active, -> { where(active: true) } scope :ordered, -> { order(domain_pattern: :asc) } @@ -50,4 +59,36 @@ class ForwardAuthRule < ApplicationRecord 'deny' 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 diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb index 30f27fe..49be1b8 100644 --- a/app/views/admin/applications/index.html.erb +++ b/app/views/admin/applications/index.html.erb @@ -56,9 +56,11 @@ <% end %> - <%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %> - <%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %> - <%= 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 "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %> + <%= 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" %> +
<% end %> diff --git a/app/views/admin/forward_auth_rules/edit.html.erb b/app/views/admin/forward_auth_rules/edit.html.erb index 7796ccb..6c3d302 100644 --- a/app/views/admin/forward_auth_rules/edit.html.erb +++ b/app/views/admin/forward_auth_rules/edit.html.erb @@ -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).

+ +
+
+ HTTP Headers Configuration +
+
+
+
+ <%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for user identity

+
+ +
+ <%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for user email

+
+ +
+ <%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for user display name

+
+ +
+ <%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for user groups (comma-separated)

+
+ +
+ <%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for admin status (true/false)

+
+
+ +
+

Header Configuration Options:

+
    +
  • Default headers: Use standard headers like Remote-User, Remote-Email
  • +
  • X- prefixed: Use X-Remote-User, X-Remote-Email, etc.
  • +
  • Custom: Use application-specific headers
  • +
  • No headers: Leave fields empty for access-only (like Metube)
  • +
+
+
+
diff --git a/app/views/admin/forward_auth_rules/index.html.erb b/app/views/admin/forward_auth_rules/index.html.erb index b8e4a74..1fdc91d 100644 --- a/app/views/admin/forward_auth_rules/index.html.erb +++ b/app/views/admin/forward_auth_rules/index.html.erb @@ -1,89 +1,68 @@ -<% content_for :title, "Forward Auth Rules" %> -
-

Forward Auth Rules

-

A list of all forward authentication rules for domain-based access control.

+

Forward Auth Rules

+

Manage forward authentication rules for domain-based access control.

-
- <%= 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" %>
- <% if @forward_auth_rules.any? %> -
- - - - - - - - - - - <% @forward_auth_rules.each do |rule| %> - - - - - - - <% end %> - -
Domain PatternGroupsStatus - Actions -
- <%= rule.domain_pattern %> - - <% if rule.allowed_groups.any? %> -
- <% rule.allowed_groups.each do |group| %> - - <%= group.name %> - - <% end %> -
- <% else %> - - Bypass (All Users) - - <% end %> -
- <% if rule.active? %> - - Active - - <% else %> - - Inactive - - <% end %> - - <%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-4" %> - <%= link_to "Delete", admin_forward_auth_rule_path(rule), - data: { - turbo_method: :delete, - turbo_confirm: "Are you sure you want to delete this forward auth rule?" - }, - class: "text-red-600 hover:text-red-900" %> -
-
- <% else %> -
- -

No forward auth rules

-

Get started by creating a new forward authentication rule.

-
- <%= 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" %> -
-
- <% end %> + + + + + + + + + + + + <% @forward_auth_rules.each do |rule| %> + + + + + + + + <% end %> + +
Domain PatternHeadersGroupsStatus + Actions +
+ <%= link_to rule.domain_pattern, admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900" %> + + <% if rule.headers_config.blank? %> + Default + <% elsif rule.headers_config.values.all?(&:blank?) %> + None + <% else %> + Custom + <% end %> + + <% if rule.allowed_groups.empty? %> + All users + <% else %> + <%= rule.allowed_groups.count %> groups + <% end %> + + <% if rule.active? %> + Active + <% else %> + Inactive + <% end %> + +
+ <%= link_to "View", admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %> + <%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %> + <%= 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" %> +
+
\ No newline at end of file diff --git a/app/views/admin/forward_auth_rules/new.html.erb b/app/views/admin/forward_auth_rules/new.html.erb index 7f15374..cd3f16d 100644 --- a/app/views/admin/forward_auth_rules/new.html.erb +++ b/app/views/admin/forward_auth_rules/new.html.erb @@ -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).

+ +
+
+ HTTP Headers Configuration +
+
+
+
+ <%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for user identity

+
+ +
+ <%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for user email

+
+ +
+ <%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for user display name

+
+ +
+ <%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for user groups (comma-separated)

+
+ +
+ <%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= 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" %> +
+

Header name for admin status (true/false)

+
+
+ +
+

Header Configuration Options:

+
    +
  • Default headers: Use standard headers like Remote-User, Remote-Email
  • +
  • X- prefixed: Use X-Remote-User, X-Remote-Email, etc.
  • +
  • Custom: Use application-specific headers
  • +
  • No headers: Leave fields empty for access-only (like Metube)
  • +
+
+
+
diff --git a/app/views/admin/forward_auth_rules/show.html.erb b/app/views/admin/forward_auth_rules/show.html.erb index 4767eaf..6e1953e 100644 --- a/app/views/admin/forward_auth_rules/show.html.erb +++ b/app/views/admin/forward_auth_rules/show.html.erb @@ -1,110 +1,115 @@ -<% content_for :title, "Forward Auth Rule: #{@forward_auth_rule.domain_pattern}" %> - -
-
-

- <%= @forward_auth_rule.domain_pattern %> -

-
-
- <%= 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" %> +
+
+
+

<%= @forward_auth_rule.domain_pattern %>

+

Forward authentication rule for domain-based access control

+
+
+ <%= 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" %> +
-
-
-
-

Rule Details

-

Forward authentication rule configuration.

-
-
-
-
+
+ +
+
+

Basic Information

+
+
Domain Pattern
-
- <%= @forward_auth_rule.domain_pattern %> -
+
<%= @forward_auth_rule.domain_pattern %>
-
+
Status
-
+
<% if @forward_auth_rule.active? %> - - Active - + Active <% else %> - - Inactive - + Inactive <% end %>
-
-
Access Policy
-
- <% if @allowed_groups.any? %> -
-

Only users in these groups are allowed access:

-
- <% @allowed_groups.each do |group| %> - - <%= group.name %> - - <% end %> -
-
+
+
Headers Configuration
+
+ <% if @forward_auth_rule.headers_config.blank? %> + Default + <% elsif @forward_auth_rule.headers_config.values.all?(&:blank?) %> + None <% else %> - - Bypass - All authenticated users allowed - + Custom <% end %>
-
-
Created
-
- <%= @forward_auth_rule.created_at.strftime("%B %d, %Y at %I:%M %p") %> -
-
-
-
Last Updated
-
- <%= @forward_auth_rule.updated_at.strftime("%B %d, %Y at %I:%M %p") %> -
-
-
-
-
-
-
- -
-
-

How this rule works

-
-
    -
  • This rule matches domains that fit the pattern: <%= @forward_auth_rule.domain_pattern %>
  • - <% if @allowed_groups.any? %> -
  • Only users belonging to the specified groups will be granted access
  • -
  • Users will be required to authenticate with password (and 2FA if enabled)
  • - <% else %> -
  • All authenticated users will be granted access (bypass mode)
  • + +
    +
    +

    Header Configuration

    +
    + <% effective_headers = @forward_auth_rule.effective_headers %> + + <% if effective_headers.empty? %> +
    +
    +
    +

    + No headers configured - access control only. +

    +
    +
    +
    + <% else %> +
    + <% effective_headers.each do |key, header_name| %> +
    +
    <%= key.to_s.capitalize %>
    +
    + <%= header_name %> +
    +
    <% end %> -
  • Inactive rules are ignored during authentication
  • -
-
+
+ <% end %> +
+
+
+ + +
+
+

Access Control

+
+
Allowed Groups
+
+ <% if @allowed_groups.empty? %> +
+
+
+

+ No groups assigned - all active users can access this domain. +

+
+
+
+ <% else %> +
    + <% @allowed_groups.each do |group| %> +
  • +
    +

    <%= group.name %>

    +

    <%= pluralize(group.users.count, "member") %>

    +
    +
  • + <% end %> +
+ <% end %> +
diff --git a/app/views/invitations_mailer/invite_user.html.erb b/app/views/invitations_mailer/invite_user.html.erb new file mode 100644 index 0000000..9e2c21f --- /dev/null +++ b/app/views/invitations_mailer/invite_user.html.erb @@ -0,0 +1,12 @@ +

+ 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) %>. +

+ +

+ 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. +

\ No newline at end of file diff --git a/app/views/invitations_mailer/invite_user.text.erb b/app/views/invitations_mailer/invite_user.text.erb new file mode 100644 index 0000000..572a1fc --- /dev/null +++ b/app/views/invitations_mailer/invite_user.text.erb @@ -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. \ No newline at end of file diff --git a/db/migrate/20251026033102_add_headers_config_to_forward_auth_rule.rb b/db/migrate/20251026033102_add_headers_config_to_forward_auth_rule.rb new file mode 100644 index 0000000..0ae9182 --- /dev/null +++ b/db/migrate/20251026033102_add_headers_config_to_forward_auth_rule.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 942fff7..62d0433 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.integer "application_id", 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.datetime "created_at", null: false t.string "domain_pattern" + t.json "headers_config", default: {}, null: false t.integer "policy" t.datetime "updated_at", null: false end diff --git a/docs/forward-auth.md b/docs/forward-auth.md new file mode 100644 index 0000000..ffc39ce --- /dev/null +++ b/docs/forward-auth.md @@ -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}" +``` \ No newline at end of file