From fc9afcd1b7e87f6e64384658266e749699d1c4a7 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Fri, 24 Oct 2025 10:56:27 +1100 Subject: [PATCH] Separate Forward auth into it's own models + controller --- README.md | 3 +- .../admin/forward_auth_rules_controller.rb | 71 ++++++++++ app/controllers/concerns/authentication.rb | 49 ++++++- app/models/application.rb | 7 +- app/models/forward_auth_rule.rb | 53 ++++++++ app/models/forward_auth_rule_group.rb | 6 + app/views/admin/applications/_form.html.erb | 2 +- app/views/admin/applications/index.html.erb | 4 +- app/views/admin/applications/show.html.erb | 2 - .../admin/forward_auth_rules/edit.html.erb | 57 ++++++++ .../admin/forward_auth_rules/index.html.erb | 89 ++++++++++++ .../admin/forward_auth_rules/new.html.erb | 57 ++++++++ .../admin/forward_auth_rules/show.html.erb | 111 +++++++++++++++ app/views/shared/_sidebar.html.erb | 18 +++ config/routes.rb | 1 + ...0251023210508_create_forward_auth_rules.rb | 11 ++ ...3234744_create_forward_auth_rule_groups.rb | 10 ++ db/schema.rb | 23 +++- test/fixtures/forward_auth_rules.yml | 11 ++ test/models/forward_auth_rule_test.rb | 127 ++++++++++++++++++ 20 files changed, 696 insertions(+), 16 deletions(-) create mode 100644 app/controllers/admin/forward_auth_rules_controller.rb create mode 100644 app/models/forward_auth_rule.rb create mode 100644 app/models/forward_auth_rule_group.rb create mode 100644 app/views/admin/forward_auth_rules/edit.html.erb create mode 100644 app/views/admin/forward_auth_rules/index.html.erb create mode 100644 app/views/admin/forward_auth_rules/new.html.erb create mode 100644 app/views/admin/forward_auth_rules/show.html.erb create mode 100644 db/migrate/20251023210508_create_forward_auth_rules.rb create mode 100644 db/migrate/20251023234744_create_forward_auth_rule_groups.rb create mode 100644 test/fixtures/forward_auth_rules.yml create mode 100644 test/models/forward_auth_rule_test.rb diff --git a/README.md b/README.md index 2cc989f..8192a11 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Clinch gives you one place to manage users and lets any web app authenticate aga Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management. -**Clinch is a lightweight alternative to Authelia and Authentik**, designed for simplicity and ease of deployment. +**Clinch is a lightweight alternative to [Authelia](https://www.authelia.com) and [Authentik](https://goauthentik.io)**, designed for simplicity and ease of deployment. --- @@ -45,6 +45,7 @@ Works with reverse proxies (Caddy, Traefik, Nginx): 3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth. +Forward Auth works only on the same domain as Clinch runs ### SMTP Integration Send emails for: diff --git a/app/controllers/admin/forward_auth_rules_controller.rb b/app/controllers/admin/forward_auth_rules_controller.rb new file mode 100644 index 0000000..740df3e --- /dev/null +++ b/app/controllers/admin/forward_auth_rules_controller.rb @@ -0,0 +1,71 @@ +module Admin + class ForwardAuthRulesController < BaseController + before_action :set_forward_auth_rule, only: [:show, :edit, :update, :destroy] + + def index + @forward_auth_rules = ForwardAuthRule.ordered + end + + def show + @allowed_groups = @forward_auth_rule.allowed_groups + end + + def new + @forward_auth_rule = ForwardAuthRule.new + @available_groups = Group.order(:name) + end + + def create + @forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params) + + if @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?) + @forward_auth_rule.allowed_groups = Group.where(id: group_ids) + end + + redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule created successfully." + else + @available_groups = Group.order(:name) + render :new, status: :unprocessable_entity + end + end + + def edit + @available_groups = Group.order(:name) + end + + def update + if @forward_auth_rule.update(forward_auth_rule_params) + # Handle group assignments + if params[:forward_auth_rule][:group_ids].present? + group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?) + @forward_auth_rule.allowed_groups = Group.where(id: group_ids) + else + @forward_auth_rule.allowed_groups = [] + end + + redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule updated successfully." + else + @available_groups = Group.order(:name) + render :edit, status: :unprocessable_entity + end + end + + def destroy + @forward_auth_rule.destroy + redirect_to admin_forward_auth_rules_path, notice: "Forward auth rule deleted successfully." + end + + private + + def set_forward_auth_rule + @forward_auth_rule = ForwardAuthRule.find(params[:id]) + end + + def forward_auth_rule_params + params.require(:forward_auth_rule).permit(:domain_pattern, :active) + end + end +end \ No newline at end of file diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 3538f48..16bdf0d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -41,7 +41,21 @@ module Authentication def start_new_session_for(user) user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| Current.session = session - cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + + # Extract root domain for cross-subdomain cookies (required for forward auth) + domain = extract_root_domain(request.host) + + cookie_options = { + value: session.id, + httponly: true, + same_site: :lax, + secure: Rails.env.production? + } + + # 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 end end @@ -49,4 +63,37 @@ module Authentication Current.session.destroy cookies.delete(:session_id) end + + # Extract root domain for cross-subdomain cookies + # Examples: + # - clinch.aapamilne.com -> .aapamilne.com + # - app.example.co.uk -> .example.co.uk + # - localhost -> nil (no domain setting for local development) + def extract_root_domain(host) + return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/) + + # Split hostname into parts + parts = host.split('.') + + # For normal domains like example.com, we need at least 2 parts + # For complex domains like co.uk, we need at least 3 parts + return nil if parts.length < 2 + + # Extract root domain with leading dot for cross-subdomain cookies + if parts.length >= 3 + # Check if it's a known complex TLD + complex_tlds = %w[co.uk com.au co.nz co.za co.jp] + second_level = "#{parts[-2]}.#{parts[-1]}" + + if complex_tlds.include?(second_level) + # For complex TLDs, include more parts: app.example.co.uk -> .example.co.uk + root_parts = parts[-3..-1] + return ".#{root_parts.join('.')}" + end + end + + # For regular domains: app.example.com -> .example.com + root_parts = parts[-2..-1] + ".#{root_parts.join('.')}" + end end diff --git a/app/models/application.rb b/app/models/application.rb index 62a86b2..ba14f3a 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -8,7 +8,7 @@ class Application < ApplicationRecord validates :slug, presence: true, uniqueness: { case_sensitive: false }, format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" } validates :app_type, presence: true, - inclusion: { in: %w[oidc trusted_header saml] } + inclusion: { in: %w[oidc saml] } validates :client_id, uniqueness: { allow_nil: true } normalizes :slug, with: ->(slug) { slug.strip.downcase } @@ -18,7 +18,6 @@ class Application < ApplicationRecord # Scopes scope :active, -> { where(active: true) } scope :oidc, -> { where(app_type: "oidc") } - scope :trusted_header, -> { where(app_type: "trusted_header") } scope :saml, -> { where(app_type: "saml") } # Type checks @@ -26,10 +25,6 @@ class Application < ApplicationRecord app_type == "oidc" end - def trusted_header? - app_type == "trusted_header" - end - def saml? app_type == "saml" end diff --git a/app/models/forward_auth_rule.rb b/app/models/forward_auth_rule.rb new file mode 100644 index 0000000..fef753e --- /dev/null +++ b/app/models/forward_auth_rule.rb @@ -0,0 +1,53 @@ +class ForwardAuthRule < ApplicationRecord + has_many :forward_auth_rule_groups, dependent: :destroy + has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group + + validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false } + validates :active, inclusion: { in: [true, false] } + + normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase } + + # Scopes + scope :active, -> { where(active: true) } + scope :ordered, -> { order(domain_pattern: :asc) } + + # Check if a domain matches this rule + def matches_domain?(domain) + return false if domain.blank? + + pattern = domain_pattern.gsub('.', '\.') + pattern = pattern.gsub('*', '[^.]*') + + regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE) + regex.match?(domain.downcase) + end + + # Access control for forward auth + def user_allowed?(user) + return false unless active? + return false unless user.active? + + # If no groups are specified, allow all active users (bypass) + return true if allowed_groups.empty? + + # Otherwise, user must be in at least one of the allowed groups + (user.groups & allowed_groups).any? + end + + # Policy determination based on user status and rule configuration + def policy_for_user(user) + return 'deny' unless active? + return 'deny' unless user.active? + + # If no groups specified, bypass authentication + return 'bypass' if allowed_groups.empty? + + # If user is in allowed groups, determine auth level + if user_allowed?(user) + # Require 2FA if user has TOTP configured, otherwise one factor + user.totp_enabled? ? 'two_factor' : 'one_factor' + else + 'deny' + end + end +end diff --git a/app/models/forward_auth_rule_group.rb b/app/models/forward_auth_rule_group.rb new file mode 100644 index 0000000..9973e76 --- /dev/null +++ b/app/models/forward_auth_rule_group.rb @@ -0,0 +1,6 @@ +class ForwardAuthRuleGroup < ApplicationRecord + belongs_to :forward_auth_rule + belongs_to :group + + validates :forward_auth_rule_id, uniqueness: { scope: :group_id } +end \ No newline at end of file diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index 8463c3e..a3be5af 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -36,7 +36,7 @@
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %> - <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["ForwardAuth (Trusted Headers)", "trusted_header"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %> + <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %> <% if application.persisted? %>

Application type cannot be changed after creation.

<% end %> diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb index de8db7d..9919584 100644 --- a/app/views/admin/applications/index.html.erb +++ b/app/views/admin/applications/index.html.erb @@ -1,7 +1,7 @@

Applications

-

Manage OIDC and ForwardAuth applications.

+

Manage OIDC applications.

<%= link_to "New Application", new_admin_application_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" %> @@ -37,8 +37,6 @@ <% case application.app_type %> <% when "oidc" %> OIDC - <% when "trusted_header" %> - ForwardAuth <% when "saml" %> SAML <% end %> diff --git a/app/views/admin/applications/show.html.erb b/app/views/admin/applications/show.html.erb index fbd3334..092563d 100644 --- a/app/views/admin/applications/show.html.erb +++ b/app/views/admin/applications/show.html.erb @@ -27,8 +27,6 @@ <% case @application.app_type %> <% when "oidc" %> OIDC - <% when "trusted_header" %> - ForwardAuth <% when "saml" %> SAML <% end %> diff --git a/app/views/admin/forward_auth_rules/edit.html.erb b/app/views/admin/forward_auth_rules/edit.html.erb new file mode 100644 index 0000000..7796ccb --- /dev/null +++ b/app/views/admin/forward_auth_rules/edit.html.erb @@ -0,0 +1,57 @@ +<% content_for :title, "Edit Forward Auth Rule" %> + +
+
+

+ Edit Forward Auth Rule +

+
+
+ +
+ <%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %> + <%= render "shared/form_errors", form: form %> + +
+
+
+
+ <%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= form.text_field :domain_pattern, 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: "*.example.com" %> +
+

+ Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported. +

+
+ +
+ <%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %> +
+
+ +
+
+ Groups +
+
+ <%= form.collection_select :group_ids, @available_groups, :id, :name, + { selected: @forward_auth_rule.allowed_groups.map(&:id), prompt: "Select groups (leave empty for bypass)" }, + { multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %> +
+

+ Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass). +

+
+
+
+
+ +
+ <%= link_to "Cancel", admin_forward_auth_rule_path(@forward_auth_rule), class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %> + <%= form.submit "Update Rule", class: "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 %> +
\ No newline at end of file diff --git a/app/views/admin/forward_auth_rules/index.html.erb b/app/views/admin/forward_auth_rules/index.html.erb new file mode 100644 index 0000000..b8e4a74 --- /dev/null +++ b/app/views/admin/forward_auth_rules/index.html.erb @@ -0,0 +1,89 @@ +<% content_for :title, "Forward Auth Rules" %> + +
+
+

Forward Auth Rules

+

A list of all 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" %> +
+
+ +
+
+
+ <% 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 %> +
+
+
\ 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 new file mode 100644 index 0000000..7f15374 --- /dev/null +++ b/app/views/admin/forward_auth_rules/new.html.erb @@ -0,0 +1,57 @@ +<% content_for :title, "New Forward Auth Rule" %> + +
+
+

+ New Forward Auth Rule +

+
+
+ +
+ <%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %> + <%= render "shared/form_errors", form: form %> + +
+
+
+
+ <%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= form.text_field :domain_pattern, 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: "*.example.com" %> +
+

+ Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported. +

+
+ +
+ <%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %> +
+ <%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %> +
+
+ +
+
+ Groups +
+
+ <%= form.collection_select :group_ids, @available_groups, :id, :name, + { prompt: "Select groups (leave empty for bypass)" }, + { multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %> +
+

+ Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass). +

+
+
+
+
+ +
+ <%= link_to "Cancel", admin_forward_auth_rules_path, class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %> + <%= form.submit "Create Rule", class: "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 %> +
\ No newline at end of file diff --git a/app/views/admin/forward_auth_rules/show.html.erb b/app/views/admin/forward_auth_rules/show.html.erb new file mode 100644 index 0000000..4767eaf --- /dev/null +++ b/app/views/admin/forward_auth_rules/show.html.erb @@ -0,0 +1,111 @@ +<% 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" %> +
+
+ +
+
+
+

Rule Details

+

Forward authentication rule configuration.

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

Only users in these groups are allowed access:

+
+ <% @allowed_groups.each do |group| %> + + <%= group.name %> + + <% end %> +
+
+ <% else %> + + Bypass - All authenticated users allowed + + <% 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)
  • + <% end %> +
  • Inactive rules are ignored during authentication
  • +
+
+
+
+
+
\ No newline at end of file diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb index 3336732..35f2fa4 100644 --- a/app/views/shared/_sidebar.html.erb +++ b/app/views/shared/_sidebar.html.erb @@ -66,6 +66,16 @@ Groups <% end %> + + +
  • + <%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/forward_auth_rules') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %> + + + + Forward Auth Rules + <% end %> +
  • <% end %> @@ -160,6 +170,14 @@ Groups <% end %> +
  • + <%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %> + + + + Forward Auth Rules + <% end %> +
  • <% end %>
  • <%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %> diff --git a/config/routes.rb b/config/routes.rb index f1b9814..abfbce3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,6 +57,7 @@ Rails.application.routes.draw do end end resources :groups + resources :forward_auth_rules end # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) diff --git a/db/migrate/20251023210508_create_forward_auth_rules.rb b/db/migrate/20251023210508_create_forward_auth_rules.rb new file mode 100644 index 0000000..2dedef6 --- /dev/null +++ b/db/migrate/20251023210508_create_forward_auth_rules.rb @@ -0,0 +1,11 @@ +class CreateForwardAuthRules < ActiveRecord::Migration[8.1] + def change + create_table :forward_auth_rules do |t| + t.string :domain_pattern + t.integer :policy + t.boolean :active + + t.timestamps + end + end +end diff --git a/db/migrate/20251023234744_create_forward_auth_rule_groups.rb b/db/migrate/20251023234744_create_forward_auth_rule_groups.rb new file mode 100644 index 0000000..ef00259 --- /dev/null +++ b/db/migrate/20251023234744_create_forward_auth_rule_groups.rb @@ -0,0 +1,10 @@ +class CreateForwardAuthRuleGroups < ActiveRecord::Migration[8.1] + def change + create_table :forward_auth_rule_groups do |t| + t.references :forward_auth_rule, null: false, foreign_key: true + t.references :group, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index eb95c44..6f90f28 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_23_091355) do +ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do create_table "application_groups", force: :cascade do |t| t.integer "application_id", null: false t.datetime "created_at", null: false @@ -37,6 +37,23 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do t.index ["slug"], name: "index_applications_on_slug", unique: true end + create_table "forward_auth_rule_groups", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "forward_auth_rule_id", null: false + t.integer "group_id", null: false + t.datetime "updated_at", null: false + t.index ["forward_auth_rule_id"], name: "index_forward_auth_rule_groups_on_forward_auth_rule_id" + t.index ["group_id"], name: "index_forward_auth_rule_groups_on_group_id" + end + + create_table "forward_auth_rules", force: :cascade do |t| + t.boolean "active" + t.datetime "created_at", null: false + t.string "domain_pattern" + t.integer "policy" + t.datetime "updated_at", null: false + end + create_table "groups", force: :cascade do |t| t.datetime "created_at", null: false t.text "description" @@ -108,7 +125,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do t.datetime "created_at", null: false t.string "email_address", null: false t.string "password_digest", null: false - t.integer "status" + t.integer "status", default: 0, null: false t.boolean "totp_required", default: false, null: false t.string "totp_secret" t.datetime "updated_at", null: false @@ -118,6 +135,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_091355) do add_foreign_key "application_groups", "applications" add_foreign_key "application_groups", "groups" + add_foreign_key "forward_auth_rule_groups", "forward_auth_rules" + add_foreign_key "forward_auth_rule_groups", "groups" add_foreign_key "oidc_access_tokens", "applications" add_foreign_key "oidc_access_tokens", "users" add_foreign_key "oidc_authorization_codes", "applications" diff --git a/test/fixtures/forward_auth_rules.yml b/test/fixtures/forward_auth_rules.yml new file mode 100644 index 0000000..50ae278 --- /dev/null +++ b/test/fixtures/forward_auth_rules.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + domain_pattern: MyString + policy: 1 + active: false + +two: + domain_pattern: MyString + policy: 1 + active: false diff --git a/test/models/forward_auth_rule_test.rb b/test/models/forward_auth_rule_test.rb new file mode 100644 index 0000000..b5eea93 --- /dev/null +++ b/test/models/forward_auth_rule_test.rb @@ -0,0 +1,127 @@ +require "test_helper" + +class ForwardAuthRuleTest < ActiveSupport::TestCase + def setup + @rule = ForwardAuthRule.new( + domain_pattern: "*.example.com", + active: true + ) + end + + test "should be valid with valid attributes" do + assert @rule.valid? + end + + test "should require domain_pattern" do + @rule.domain_pattern = "" + assert_not @rule.valid? + assert_includes @rule.errors[:domain_pattern], "can't be blank" + end + + test "should require active to be boolean" do + @rule.active = nil + assert_not @rule.valid? + assert_includes @rule.errors[:active], "is not included in the list" + end + + test "should normalize domain_pattern to lowercase" do + @rule.domain_pattern = "*.EXAMPLE.COM" + @rule.save! + assert_equal "*.example.com", @rule.reload.domain_pattern + end + + test "should enforce unique domain_pattern" do + @rule.save! + duplicate = ForwardAuthRule.new( + domain_pattern: "*.example.com", + active: true + ) + assert_not duplicate.valid? + assert_includes duplicate.errors[:domain_pattern], "has already been taken" + end + + test "should match domain patterns correctly" do + @rule.save! + + assert @rule.matches_domain?("app.example.com") + assert @rule.matches_domain?("api.example.com") + assert @rule.matches_domain?("sub.app.example.com") + assert_not @rule.matches_domain?("example.org") + assert_not @rule.matches_domain?("otherexample.com") + end + + test "should handle exact domain matches" do + @rule.domain_pattern = "api.example.com" + @rule.save! + + assert @rule.matches_domain?("api.example.com") + assert_not @rule.matches_domain?("app.example.com") + assert_not @rule.matches_domain?("sub.api.example.com") + end + + test "policy_for_user should return bypass when no groups assigned" do + user = users(:one) + @rule.save! + + assert_equal "bypass", @rule.policy_for_user(user) + end + + test "policy_for_user should return deny for inactive rule" do + user = users(:one) + @rule.active = false + @rule.save! + + assert_equal "deny", @rule.policy_for_user(user) + end + + test "policy_for_user should return deny for inactive user" do + user = users(:one) + user.update!(active: false) + @rule.save! + + assert_equal "deny", @rule.policy_for_user(user) + end + + test "policy_for_user should return correct policy based on user groups and TOTP" do + group = groups(:one) + user_with_totp = users(:two) + user_without_totp = users(:one) + + user_with_totp.totp_secret = "test_secret" + user_with_totp.save! + + @rule.allowed_groups << group + user_with_totp.groups << group + user_without_totp.groups << group + @rule.save! + + assert_equal "two_factor", @rule.policy_for_user(user_with_totp) + assert_equal "one_factor", @rule.policy_for_user(user_without_totp) + end + + test "user_allowed? should return true when no groups assigned" do + user = users(:one) + @rule.save! + + assert @rule.user_allowed?(user) + end + + test "user_allowed? should return true when user in allowed groups" do + group = groups(:one) + user = users(:one) + user.groups << group + @rule.allowed_groups << group + @rule.save! + + assert @rule.user_allowed?(user) + end + + test "user_allowed? should return false when user not in allowed groups" do + group = groups(:one) + user = users(:one) + @rule.allowed_groups << group + @rule.save! + + assert_not @rule.user_allowed?(user) + end +end