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 %>
Header name for user identity
+Header name for user email
+Header name for user display name
+Header name for user groups (comma-separated)
+Header name for admin status (true/false)
+A list of all forward authentication rules for domain-based access control.
+Manage forward authentication rules for domain-based access control.
| Domain Pattern | -Groups | -Status | -- 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" %> - | -
Get started by creating a new forward authentication rule.
-| Domain Pattern | +Headers | +Groups | +Status | ++ 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" %>
+
+ |
+
Header name for user identity
+Header name for user email
+Header name for user display name
+Header name for user groups (comma-separated)
+Header name for admin status (true/false)
+Forward authentication rule for domain-based access control
+Forward authentication rule configuration.
-<%= @forward_auth_rule.domain_pattern %>
- <%= @forward_auth_rule.domain_pattern %>Only users in these groups are allowed access:
-<%= @forward_auth_rule.domain_pattern %>+ No headers configured - access control only. +
+<%= header_name %>
+ + No groups assigned - all active users can access this domain. +
+<%= group.name %>
+<%= pluralize(group.users.count, "member") %>
++ 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