Compare commits
13 Commits
39757a43dc
...
431e947a4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
431e947a4c | ||
|
|
8dd3e60071 | ||
|
|
e4e7a0873e | ||
|
|
b5b1d94d47 | ||
|
|
52cfd6122c | ||
|
|
87796e0478 | ||
|
|
227e29ce0a | ||
|
|
d98f777e7d | ||
|
|
88428bfd97 | ||
|
|
2679634a2b | ||
|
|
2d5823213c | ||
|
|
5921cf82c2 | ||
|
|
df834b6e57 |
10
Gemfile
10
Gemfile
@@ -26,17 +26,16 @@ gem "bcrypt", "~> 3.1.7"
|
||||
gem "rotp", "~> 6.3"
|
||||
|
||||
# QR code generation for TOTP setup
|
||||
gem "rqrcode", "~> 2.0"
|
||||
gem "rqrcode", "~> 3.1"
|
||||
|
||||
# JWT for OIDC ID tokens
|
||||
gem "jwt", "~> 2.9"
|
||||
gem "jwt", "~> 3.1"
|
||||
|
||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
|
||||
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
|
||||
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||
gem "solid_cache"
|
||||
gem "solid_queue"
|
||||
gem "solid_cable"
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
@@ -68,6 +67,9 @@ end
|
||||
group :development do
|
||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||
gem "web-console"
|
||||
|
||||
# Preview emails in browser instead of sending them
|
||||
gem "letter_opener"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
39
Gemfile.lock
39
Gemfile.lock
@@ -100,6 +100,8 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
@@ -113,8 +115,6 @@ GEM
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
@@ -122,9 +122,6 @@ GEM
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.14.7)
|
||||
@@ -145,7 +142,7 @@ GEM
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.1)
|
||||
jwt (2.10.2)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.8.1)
|
||||
activesupport (>= 7.0)
|
||||
@@ -159,6 +156,12 @@ GEM
|
||||
thor (~> 1.3)
|
||||
zeitwerk (>= 2.6.18, < 3.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.1)
|
||||
@@ -225,7 +228,6 @@ GEM
|
||||
public_suffix (6.0.2)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
rack-session (2.1.1)
|
||||
@@ -276,10 +278,10 @@ GEM
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rubocop (1.81.6)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
@@ -312,9 +314,9 @@ GEM
|
||||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.0)
|
||||
rubyzip (3.2.1)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.37.0)
|
||||
selenium-webdriver (4.38.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -329,13 +331,6 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.2.2)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||
sqlite3 (2.7.4-arm-linux-gnu)
|
||||
@@ -414,18 +409,18 @@ DEPENDENCIES
|
||||
image_processing (~> 1.2)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
jwt (~> 2.9)
|
||||
jwt (~> 3.1)
|
||||
kamal
|
||||
letter_opener
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.0)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.0)
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
sqlite3 (>= 2.1)
|
||||
stimulus-rails
|
||||
tailwindcss-rails
|
||||
|
||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Dan Milne
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
15
README.md
15
README.md
@@ -1,5 +1,7 @@
|
||||
# Clinch
|
||||
|
||||
This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
|
||||
**A lightweight, self-hosted identity & SSO portal**
|
||||
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
||||
@@ -167,7 +169,7 @@ bin/dev
|
||||
docker build -t clinch .
|
||||
|
||||
# Run container
|
||||
docker run -p 9000:9000 \
|
||||
docker run -p 3000:3000 \
|
||||
-v clinch-storage:/rails/storage \
|
||||
-e SECRET_KEY_BASE=your-secret-key \
|
||||
-e SMTP_ADDRESS=smtp.example.com \
|
||||
@@ -208,7 +210,7 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
```
|
||||
|
||||
### First Run
|
||||
1. Visit Clinch at `http://localhost:9000` (or your configured domain)
|
||||
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
||||
2. First-run wizard creates initial admin user
|
||||
3. Admin can then:
|
||||
- Create groups
|
||||
@@ -227,12 +229,14 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
- First-run wizard
|
||||
|
||||
### Planned Features
|
||||
- **Audit logging** - Track all authentication events
|
||||
- **WebAuthn/Passkeys** - Hardware key support
|
||||
|
||||
#### Maybe
|
||||
- **SAML support** - SAML 2.0 identity provider
|
||||
- **Policy engine** - Rule-based access control
|
||||
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
||||
- Stored as JSON, evaluated after auth but before consent
|
||||
- **Audit logging** - Track all authentication events
|
||||
- **WebAuthn/Passkeys** - Hardware key support
|
||||
- **LDAP sync** - Import users from LDAP/Active Directory
|
||||
|
||||
---
|
||||
@@ -250,5 +254,4 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
MIT
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ module Authentication
|
||||
end
|
||||
|
||||
def start_new_session_for(user)
|
||||
user.update!(last_sign_in_at: Time.current)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
Current.session = session
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class InvitationsController < ApplicationController
|
||||
include Authentication
|
||||
allow_unauthenticated_access
|
||||
before_action :set_user_by_invitation_token, only: %i[ show update ]
|
||||
|
||||
@@ -10,7 +11,8 @@ class InvitationsController < ApplicationController
|
||||
if @user.update(params.permit(:password, :password_confirmation))
|
||||
@user.update!(status: :active)
|
||||
@user.sessions.destroy_all
|
||||
redirect_to new_session_path, notice: "Your account has been set up successfully. Please sign in."
|
||||
start_new_session_for @user
|
||||
redirect_to root_path, notice: "Your account has been set up successfully. Welcome!"
|
||||
else
|
||||
redirect_to invite_path(params[:token]), alert: "Passwords did not match."
|
||||
end
|
||||
@@ -19,7 +21,7 @@ class InvitationsController < ApplicationController
|
||||
private
|
||||
|
||||
def set_user_by_invitation_token
|
||||
@user = User.find_by_invitation_login_token!(params[:token])
|
||||
@user = User.find_by_token_for(:invitation_login, params[:token])
|
||||
|
||||
# Check if user is still pending invitation
|
||||
unless @user.pending_invitation?
|
||||
|
||||
@@ -28,7 +28,7 @@ class PasswordsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_user_by_token
|
||||
@user = User.find_by_password_reset_token!(params[:token])
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
|
||||
@@ -1,2 +1,22 @@
|
||||
module ApplicationHelper
|
||||
def smtp_configured?
|
||||
return true if Rails.env.test?
|
||||
|
||||
smtp_address = ENV["SMTP_ADDRESS"]
|
||||
smtp_port = ENV["SMTP_PORT"]
|
||||
|
||||
smtp_address.present? &&
|
||||
smtp_port.present? &&
|
||||
smtp_address != "localhost" &&
|
||||
!smtp_address.start_with?("127.0.0.1") &&
|
||||
!smtp_address.start_with?("localhost")
|
||||
end
|
||||
|
||||
def email_delivery_method
|
||||
if Rails.env.development?
|
||||
ActionMailer::Base.delivery_method
|
||||
else
|
||||
:smtp
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,9 +8,17 @@ class User < ApplicationRecord
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
|
||||
# Token generation for passwordless flows
|
||||
generates_token_for :invitation, expires_in: 7.days
|
||||
generates_token_for :password_reset, expires_in: 1.hour
|
||||
generates_token_for :magic_login, expires_in: 15.minutes
|
||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||
updated_at
|
||||
end
|
||||
|
||||
generates_token_for :password_reset, expires_in: 1.hour do
|
||||
updated_at
|
||||
end
|
||||
|
||||
generates_token_for :magic_login, expires_in: 15.minutes do
|
||||
last_sign_in_at
|
||||
end
|
||||
|
||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ class OidcJwtService
|
||||
def issuer_url
|
||||
# In production, this should come from ENV or config
|
||||
# For now, we'll use a placeholder that can be overridden
|
||||
ENV.fetch("CLINCH_HOST", "http://localhost:3000")
|
||||
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -56,9 +56,11 @@
|
||||
<% end %>
|
||||
</td>
|
||||
<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" %>
|
||||
<%= 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" %>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -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).
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -1,89 +1,68 @@
|
||||
<% content_for :title, "Forward Auth Rules" %>
|
||||
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold leading-6 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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Forward Auth Rules</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Manage forward authentication rules for domain-based access control.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 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" %>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<%= 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 class="mt-8 flow-root">
|
||||
<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">
|
||||
<% 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">
|
||||
<thead class="bg-gray-50">
|
||||
<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="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="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @forward_auth_rules.each do |rule| %>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||
<%= rule.domain_pattern %>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.allowed_groups.any? %>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<% rule.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>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Bypass (All Users)
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500">
|
||||
<% 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">
|
||||
Active
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<%= 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" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</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 %>
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<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">Status</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<% @forward_auth_rules.each do |rule| %>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||
<%= link_to rule.domain_pattern, admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.headers_config.blank? %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
|
||||
<% elsif rule.headers_config.values.all?(&:blank?) %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<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? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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).
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@@ -1,110 +1,115 @@
|
||||
<% content_for :title, "Forward Auth Rule: #{@forward_auth_rule.domain_pattern}" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
<%= @forward_auth_rule.domain_pattern %>
|
||||
</h2>
|
||||
</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 class="mb-6">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @forward_auth_rule.domain_pattern %></h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Forward authentication rule for domain-based access control</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Rule Details</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Forward authentication rule configuration.</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200">
|
||||
<dl>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<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">Basic Information</h3>
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<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">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @forward_auth_rule.domain_pattern %></code>
|
||||
</dd>
|
||||
<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>
|
||||
</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>
|
||||
<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? %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Active
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Inactive
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||
<% end %>
|
||||
</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">Access Policy</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<% if @allowed_groups.any? %>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm">Only users in these groups are allowed access:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% @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>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @forward_auth_rule.headers_config.blank? %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
|
||||
<% elsif @forward_auth_rule.headers_config.values.all?(&:blank?) %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Bypass - All authenticated users allowed
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
||||
<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">
|
||||
<h3 class="text-sm font-medium text-blue-800">How this rule works</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>This rule matches domains that fit the pattern: <code class="bg-blue-100 px-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></li>
|
||||
<% if @allowed_groups.any? %>
|
||||
<li>Only users belonging to the specified groups will be granted access</li>
|
||||
<li>Users will be required to authenticate with password (and 2FA if enabled)</li>
|
||||
<% else %>
|
||||
<li>All authenticated users will be granted access (bypass mode)</li>
|
||||
<!-- 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 %>
|
||||
<li>Inactive rules are ignored during authentication</li>
|
||||
</ul>
|
||||
</div>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Access Control -->
|
||||
<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="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
No groups assigned - all active users can access this domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<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 %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% unless smtp_configured? %>
|
||||
<div class="mt-6 rounded-md bg-yellow-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
Email delivery not configured
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
<% if Rails.env.development? %>
|
||||
Emails are being delivered using <span class="font-mono"><%= email_delivery_method %></span> and will open in your browser.
|
||||
<% else %>
|
||||
SMTP settings are not configured. Invitation emails and other notifications will not be sent.
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
<% if Rails.env.development? %>
|
||||
To configure SMTP for production, set environment variables like <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, etc.
|
||||
<% else %>
|
||||
Configure SMTP settings by setting environment variables: <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, <span class="font-mono">SMTP_PASSWORD</span>, etc.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<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">
|
||||
@@ -66,8 +99,17 @@
|
||||
<%= user.groups.count %>
|
||||
</td>
|
||||
<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_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= button_to "Delete", admin_user_path(user), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this user?" }, class: "text-red-600 hover:text-red-900" %>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<% if user.pending_invitation? %>
|
||||
<%= link_to "Resend", resend_invitation_admin_user_path(user),
|
||||
data: { turbo_method: :post },
|
||||
class: "text-yellow-600 hover:text-yellow-900" %>
|
||||
<% end %>
|
||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<%= link_to "Delete", admin_user_path(user),
|
||||
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },
|
||||
class: "text-red-600 hover:text-red-900" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
12
app/views/invitations_mailer/invite_user.html.erb
Normal file
12
app/views/invitations_mailer/invite_user.html.erb
Normal 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", invitation_url(@user.generate_token_for(:invitation_login)) %>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This invitation link will expire in 24 hours.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you didn't expect this invitation, you can safely ignore this email.
|
||||
</p>
|
||||
8
app/views/invitations_mailer/invite_user.text.erb
Normal file
8
app/views/invitations_mailer/invite_user.text.erb
Normal 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.
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Public layout (signup/signin) -->
|
||||
<main class="container mx-auto mt-28 px-5 flex">
|
||||
<main class="container mx-auto mt-28 px-5">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
@@ -23,5 +23,18 @@ module Clinch
|
||||
#
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
|
||||
# Configure SMTP settings using environment variables
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = {
|
||||
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
||||
port: ENV.fetch('SMTP_PORT', 587),
|
||||
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
||||
user_name: ENV.fetch('SMTP_USERNAME', nil),
|
||||
password: ENV.fetch('SMTP_PASSWORD', nil),
|
||||
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
||||
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
||||
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,8 +31,9 @@ Rails.application.configure do
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
config.active_storage.service = :local
|
||||
|
||||
# Don't care if the mailer can't send.
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
# Preview emails in browser using letter_opener
|
||||
config.action_mailer.delivery_method = :letter_opener
|
||||
config.action_mailer.perform_deliveries = true
|
||||
|
||||
# Make template changes take effect immediately.
|
||||
config.action_mailer.perform_caching = false
|
||||
@@ -58,9 +59,8 @@ Rails.application.configure do
|
||||
# Highlight code that enqueued background job in logs.
|
||||
config.active_job.verbose_enqueue_logs = true
|
||||
|
||||
# Use Solid Queue for background jobs (same as production).
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
# Use async processor for background jobs in development
|
||||
config.active_job.queue_adapter = :async
|
||||
|
||||
|
||||
# Highlight code that triggered redirect in logs.
|
||||
|
||||
@@ -49,16 +49,17 @@ Rails.application.configure do
|
||||
# Replace the default in-process memory cache store with a durable alternative.
|
||||
config.cache_store = :solid_cache_store
|
||||
|
||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
# Use async processor for background jobs (modify as needed for production)
|
||||
config.active_job.queue_adapter = :async
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||
# config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
config.action_mailer.default_url_options = {
|
||||
host: ENV.fetch('CLINCH_HOST', 'example.com')
|
||||
}
|
||||
|
||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||
# config.action_mailer.smtp_settings = {
|
||||
|
||||
@@ -34,8 +34,7 @@ port ENV.fetch("PORT", 3000)
|
||||
# Allow puma to be restarted by `bin/rails restart` command.
|
||||
plugin :tmp_restart
|
||||
|
||||
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
||||
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
||||
# Solid Queue plugin removed - now using async processor
|
||||
|
||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||
# In other environments, only set the PID file if requested.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# examples:
|
||||
# periodic_cleanup:
|
||||
# class: CleanSoftDeletedRecordsJob
|
||||
# queue: background
|
||||
# args: [ 1000, { batch_size: 500 } ]
|
||||
# schedule: every hour
|
||||
# periodic_cleanup_with_command:
|
||||
# command: "SoftDeletedRecord.due.delete_all"
|
||||
# priority: 2
|
||||
# schedule: at 5am every day
|
||||
|
||||
production:
|
||||
clear_solid_queue_finished_jobs:
|
||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||
schedule: every hour at minute 12
|
||||
@@ -1,6 +1,7 @@
|
||||
Rails.application.routes.draw do
|
||||
resource :session
|
||||
resources :passwords, param: :token
|
||||
resources :invitations, param: :token, only: [:show, :update]
|
||||
mount ActionCable.server => "/cable"
|
||||
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
@@ -56,7 +57,11 @@ Rails.application.routes.draw do
|
||||
# Admin routes
|
||||
namespace :admin do
|
||||
root "dashboard#index"
|
||||
resources :users
|
||||
resources :users do
|
||||
member do
|
||||
post :resend_invitation
|
||||
end
|
||||
end
|
||||
resources :applications do
|
||||
member do
|
||||
post :regenerate_credentials
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLastSignInAtToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :last_sign_in_at, :datetime
|
||||
end
|
||||
end
|
||||
3
db/schema.rb
generated
3
db/schema.rb
generated
@@ -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
|
||||
|
||||
153
docs/forward-auth.md
Normal file
153
docs/forward-auth.md
Normal 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}"
|
||||
```
|
||||
275
test/controllers/api/forward_auth_controller_test.rb
Normal file
275
test/controllers/api/forward_auth_controller_test.rb
Normal file
@@ -0,0 +1,275 @@
|
||||
require "test_helper"
|
||||
|
||||
module Api
|
||||
class ForwardAuthControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:one)
|
||||
@admin_user = users(:two)
|
||||
@inactive_user = users(:three)
|
||||
@group = groups(:one)
|
||||
@rule = ForwardAuthRule.create!(domain_pattern: "test.example.com", active: true)
|
||||
@inactive_rule = ForwardAuthRule.create!(domain_pattern: "inactive.example.com", active: false)
|
||||
end
|
||||
|
||||
# Authentication Tests
|
||||
test "should redirect to login when no session cookie" do
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 302
|
||||
assert_match %r{/signin}, response.location
|
||||
assert_equal "No session cookie", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should redirect when session cookie is invalid" do
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=invalid_session_id"
|
||||
}
|
||||
|
||||
assert_response 302
|
||||
assert_match %r{/signin}, response.location
|
||||
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should redirect when session is expired" do
|
||||
expired_session = @user.sessions.create!(created_at: 1.year.ago)
|
||||
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=#{expired_session.id}"
|
||||
}
|
||||
|
||||
assert_response 302
|
||||
assert_match %r{/signin}, response.location
|
||||
assert_equal "Session expired", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should redirect when user is inactive" do
|
||||
sign_in_as(@inactive_user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 302
|
||||
assert_equal "User account is not active", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should return 200 when user is authenticated" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
# Rule Matching Tests
|
||||
test "should return 200 when matching rule exists" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
test "should return 200 with default headers when no rule matches" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "unknown.example.com" }
|
||||
|
||||
assert_response 200
|
||||
assert_equal "X-Remote-User", response.headers["X-Remote-User"]
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
test "should return 403 when rule exists but is inactive" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "inactive.example.com" }
|
||||
|
||||
assert_response 403
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should return 403 when rule exists but user not in allowed groups" do
|
||||
@rule.allowed_groups << @group
|
||||
sign_in_as(@user) # User not in group
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 403
|
||||
assert_match %r{permission to access this domain}, response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should return 200 when user is in allowed groups" do
|
||||
@rule.allowed_groups << @group
|
||||
@user.groups << @group
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
# Domain Pattern Tests
|
||||
test "should match wildcard domains correctly" do
|
||||
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true)
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||
assert_response 200
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
||||
assert_response 200
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "other.com" }
|
||||
assert_response 200 # Falls back to default behavior
|
||||
end
|
||||
|
||||
test "should match exact domains correctly" do
|
||||
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true)
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
||||
assert_response 200
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.api.example.com" }
|
||||
assert_response 200 # Falls back to default behavior
|
||||
end
|
||||
|
||||
# Header Configuration Tests
|
||||
test "should return default headers when rule has no custom config" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") }
|
||||
assert_equal "X-Remote-Email", response.headers.keys.find { |k| k.include?("Email") }
|
||||
assert_equal "X-Remote-Name", response.headers.keys.find { |k| k.include?("Name") }
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
test "should return custom headers when configured" do
|
||||
custom_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "custom.example.com",
|
||||
active: true,
|
||||
headers_config: {
|
||||
user: "X-WEBAUTH-USER",
|
||||
email: "X-WEBAUTH-EMAIL",
|
||||
groups: "X-WEBAUTH-ROLES"
|
||||
}
|
||||
)
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
|
||||
|
||||
assert_response 200
|
||||
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") }
|
||||
assert_equal "X-WEBAUTH-EMAIL", response.headers.keys.find { |k| k.include?("EMAIL") }
|
||||
assert_equal @user.email_address, response.headers["X-WEBAUTH-USER"]
|
||||
end
|
||||
|
||||
test "should return no headers when all headers disabled" do
|
||||
no_headers_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "noheaders.example.com",
|
||||
active: true,
|
||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
||||
)
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
|
||||
|
||||
assert_response 200
|
||||
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||
assert_empty auth_headers
|
||||
end
|
||||
|
||||
test "should include groups header when user has groups" do
|
||||
@user.groups << @group
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
assert_equal @group.name, response.headers["X-Remote-Groups"]
|
||||
end
|
||||
|
||||
test "should not include groups header when user has no groups" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
assert_nil response.headers["X-Remote-Groups"]
|
||||
end
|
||||
|
||||
test "should include admin header correctly" do
|
||||
sign_in_as(@admin_user) # Assuming users(:two) is admin
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
assert_equal "true", response.headers["X-Remote-Admin"]
|
||||
end
|
||||
|
||||
test "should include multiple groups when user has multiple groups" do
|
||||
group2 = groups(:two)
|
||||
@user.groups << @group
|
||||
@user.groups << group2
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
groups_header = response.headers["X-Remote-Groups"]
|
||||
assert_includes groups_header, @group.name
|
||||
assert_includes groups_header, group2.name
|
||||
end
|
||||
|
||||
# Header Fallback Tests
|
||||
test "should fall back to Host header when X-Forwarded-Host is missing" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "Host" => "test.example.com" }
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
test "should handle requests without any host headers" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify"
|
||||
|
||||
assert_response 200
|
||||
assert_equal "User #{@user.email_address} authenticated (no domain specified)",
|
||||
request.env["action_dispatch.instance"].instance_variable_get(:@logged_messages)&.last
|
||||
end
|
||||
|
||||
# Security Tests
|
||||
test "should handle malformed session IDs gracefully" do
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=malformed_session_id_with_special_chars!@#$%"
|
||||
}
|
||||
|
||||
assert_response 302
|
||||
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
test "should handle very long domain names" do
|
||||
long_domain = "a" * 250 + ".example.com"
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => long_domain }
|
||||
|
||||
assert_response 200 # Should fall back to default behavior
|
||||
end
|
||||
|
||||
test "should handle case insensitive domain matching" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "TEST.Example.COM" }
|
||||
|
||||
assert_response 200
|
||||
end
|
||||
end
|
||||
end
|
||||
12
test/fixtures/application_groups.yml
vendored
12
test/fixtures/application_groups.yml
vendored
@@ -1,9 +1,9 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
application: one
|
||||
group: one
|
||||
kavita_admin_group:
|
||||
application: kavita_app
|
||||
group: admin_group
|
||||
|
||||
two:
|
||||
application: two
|
||||
group: two
|
||||
kavita_editor_group:
|
||||
application: kavita_app
|
||||
group: editor_group
|
||||
|
||||
16
test/fixtures/oidc_user_consents.yml
vendored
16
test/fixtures/oidc_user_consents.yml
vendored
@@ -1,13 +1,13 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
user: one
|
||||
application: one
|
||||
scopes_granted: MyText
|
||||
alice_consent:
|
||||
user: alice
|
||||
application: kavita_app
|
||||
scopes_granted: openid profile email
|
||||
granted_at: 2025-10-24 16:57:39
|
||||
|
||||
two:
|
||||
user: two
|
||||
application: two
|
||||
scopes_granted: MyText
|
||||
bob_consent:
|
||||
user: bob
|
||||
application: another_app
|
||||
scopes_granted: openid email groups
|
||||
granted_at: 2025-10-24 16:57:39
|
||||
|
||||
12
test/fixtures/user_groups.yml
vendored
12
test/fixtures/user_groups.yml
vendored
@@ -1,9 +1,9 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
user: one
|
||||
group: one
|
||||
alice_admin_group:
|
||||
user: alice
|
||||
group: admin_group
|
||||
|
||||
two:
|
||||
user: two
|
||||
group: two
|
||||
bob_editor_group:
|
||||
user: bob
|
||||
group: editor_group
|
||||
|
||||
322
test/integration/forward_auth_integration_test.rb
Normal file
322
test/integration/forward_auth_integration_test.rb
Normal file
@@ -0,0 +1,322 @@
|
||||
require "test_helper"
|
||||
|
||||
class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:one)
|
||||
@admin_user = users(:two)
|
||||
@group = groups(:one)
|
||||
@group2 = groups(:two)
|
||||
end
|
||||
|
||||
# Basic Authentication Flow Tests
|
||||
test "complete authentication flow: unauthenticated to authenticated" do
|
||||
# Step 1: Unauthenticated request should redirect
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
assert_response 302
|
||||
assert_match %r{/signin}, response.location
|
||||
assert_equal "No session cookie", response.headers["X-Auth-Reason"]
|
||||
|
||||
# Step 2: Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
assert_redirected_to "/"
|
||||
assert cookies[:session_id]
|
||||
|
||||
# Step 3: Authenticated request should succeed
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
test "session persistence across multiple requests" do
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
session_cookie = cookies[:session_id]
|
||||
assert session_cookie
|
||||
|
||||
# Multiple requests should work with same session
|
||||
3.times do |i|
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
end
|
||||
|
||||
test "session expiration handling" do
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
|
||||
# Manually expire the session
|
||||
session = Session.find_by(id: cookies.signed[:session_id])
|
||||
session.update!(created_at: 1.year.ago)
|
||||
|
||||
# Request should fail and redirect to login
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
assert_response 302
|
||||
assert_equal "Session expired", response.headers["X-Auth-Reason"]
|
||||
end
|
||||
|
||||
# Domain and Rule Integration Tests
|
||||
test "different domain patterns with same session" do
|
||||
# Create test rules
|
||||
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true)
|
||||
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true)
|
||||
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
|
||||
# Test wildcard domain
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
|
||||
# Test exact domain
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "api.example.com" }
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
|
||||
# Test non-matching domain (should use defaults)
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "other.example.com" }
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
test "group-based access control integration" do
|
||||
# Create restricted rule
|
||||
restricted_rule = ForwardAuthRule.create!(domain_pattern: "restricted.example.com", active: true)
|
||||
restricted_rule.allowed_groups << @group
|
||||
|
||||
# Sign in user without group
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
|
||||
# Should be denied access
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" }
|
||||
assert_response 403
|
||||
assert_match %r{permission to access this domain}, response.headers["X-Auth-Reason"]
|
||||
|
||||
# Add user to group
|
||||
@user.groups << @group
|
||||
|
||||
# Should now be allowed
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "restricted.example.com" }
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
# Header Configuration Integration Tests
|
||||
test "different header configurations with same user" do
|
||||
# Create rules with different header configs
|
||||
default_rule = ForwardAuthRule.create!(domain_pattern: "default.example.com", active: true)
|
||||
custom_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "custom.example.com",
|
||||
active: true,
|
||||
headers_config: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" }
|
||||
)
|
||||
no_headers_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "noheaders.example.com",
|
||||
active: true,
|
||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
||||
)
|
||||
|
||||
# Add user to groups
|
||||
@user.groups << @group
|
||||
@user.groups << @group2
|
||||
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
|
||||
# Test default headers
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "default.example.com" }
|
||||
assert_response 200
|
||||
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") }
|
||||
assert_equal "X-Remote-Groups", response.headers.keys.find { |k| k.include?("Groups") }
|
||||
|
||||
# Test custom headers
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "custom.example.com" }
|
||||
assert_response 200
|
||||
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") }
|
||||
assert_equal "X-WEBAUTH-ROLES", response.headers.keys.find { |k| k.include?("ROLES") }
|
||||
|
||||
# Test no headers
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "noheaders.example.com" }
|
||||
assert_response 200
|
||||
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||
assert_empty auth_headers
|
||||
end
|
||||
|
||||
# Redirect URL Integration Tests
|
||||
test "redirect URL preserves original request information" do
|
||||
# Test with various redirect parameters
|
||||
test_cases = [
|
||||
{ rd: "https://app.example.com/", rm: "GET" },
|
||||
{ rd: "https://grafana.example.com/dashboard", rm: "POST" },
|
||||
{ rd: "https://metube.example.com/videos", rm: "PUT" }
|
||||
]
|
||||
|
||||
test_cases.each do |params|
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }, params: params
|
||||
|
||||
assert_response 302
|
||||
location = response.location
|
||||
|
||||
# Should contain the original redirect URL
|
||||
assert_includes location, params[:rd]
|
||||
assert_includes location, params[:rm]
|
||||
assert_includes location, "/signin"
|
||||
end
|
||||
end
|
||||
|
||||
test "return URL functionality after authentication" do
|
||||
# Initial request should set return URL
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"X-Forwarded-Uri" => "/admin"
|
||||
}, params: { rd: "https://app.example.com/admin" }
|
||||
|
||||
assert_response 302
|
||||
location = response.location
|
||||
|
||||
# Extract return URL from location
|
||||
assert_match /rd=([^&]+)/, location
|
||||
return_url = CGI.unescape($1)
|
||||
assert_equal "https://app.example.com/admin", return_url
|
||||
|
||||
# Store session return URL
|
||||
return_to_after_authenticating = session[:return_to_after_authenticating]
|
||||
assert_equal "https://app.example.com/admin", return_to_after_authenticating
|
||||
end
|
||||
|
||||
# Multiple User Scenarios Integration Tests
|
||||
test "multiple users with different access levels" do
|
||||
regular_user = users(:one)
|
||||
admin_user = users(:two)
|
||||
|
||||
# Create restricted rule
|
||||
admin_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "admin.example.com",
|
||||
active: true,
|
||||
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" }
|
||||
)
|
||||
|
||||
# Test regular user
|
||||
post "/signin", params: { email_address: regular_user.email_address, password: "password" }
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||
assert_response 200
|
||||
assert_equal regular_user.email_address, response.headers["X-Admin-User"]
|
||||
|
||||
# Sign out
|
||||
delete "/session"
|
||||
|
||||
# Test admin user
|
||||
post "/signin", params: { email_address: admin_user.email_address, password: "password" }
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||
assert_response 200
|
||||
assert_equal admin_user.email_address, response.headers["X-Admin-User"]
|
||||
assert_equal "true", response.headers["X-Admin-Flag"]
|
||||
end
|
||||
|
||||
# Security Integration Tests
|
||||
test "session hijacking prevention" do
|
||||
# User A signs in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
user_a_session = cookies[:session_id]
|
||||
|
||||
# User B signs in
|
||||
delete "/session"
|
||||
post "/signin", params: { email_address: @admin_user.email_address, password: "password" }
|
||||
user_b_session = cookies[:session_id]
|
||||
|
||||
# User A's session should still work
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=#{user_a_session}"
|
||||
}
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
|
||||
# User B's session should work
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=#{user_b_session}"
|
||||
}
|
||||
assert_response 200
|
||||
assert_equal @admin_user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
test "concurrent requests with same session" do
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
session_cookie = cookies[:session_id]
|
||||
|
||||
# Simulate concurrent requests
|
||||
threads = []
|
||||
results = []
|
||||
|
||||
5.times do |i|
|
||||
threads << Thread.new do
|
||||
# Create a new integration test instance for this thread
|
||||
test_instance = self.class.new
|
||||
test_instance.setup_controller_request_and_response
|
||||
|
||||
test_instance.get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "app#{i}.example.com",
|
||||
"Cookie" => "_clinch_session_id=#{session_cookie}"
|
||||
}
|
||||
|
||||
results << {
|
||||
thread_id: i,
|
||||
status: test_instance.response.status,
|
||||
user: test_instance.response.headers["X-Remote-User"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# All requests should succeed
|
||||
results.each do |result|
|
||||
assert_equal 200, result[:status], "Thread #{result[:thread_id]} failed"
|
||||
assert_equal @user.email_address, result[:user], "Thread #{result[:thread_id]} has wrong user"
|
||||
end
|
||||
end
|
||||
|
||||
# Performance Integration Tests
|
||||
test "response times are reasonable" do
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
|
||||
# Test multiple requests
|
||||
start_time = Time.current
|
||||
|
||||
10.times do |i|
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
end_time = Time.current
|
||||
total_time = end_time - start_time
|
||||
average_time = total_time / 10
|
||||
|
||||
# Each request should take less than 100ms on average
|
||||
assert average_time < 0.1, "Average response time #{average_time}s is too slow"
|
||||
end
|
||||
|
||||
# Error Handling Integration Tests
|
||||
test "graceful handling of malformed headers" do
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
|
||||
# Test various malformed header combinations
|
||||
test_cases = [
|
||||
{ "X-Forwarded-Host" => nil },
|
||||
{ "X-Forwarded-Host" => "" },
|
||||
{ "X-Forwarded-Host" => " " },
|
||||
{ "Host" => nil },
|
||||
{ "Host" => "" }
|
||||
]
|
||||
|
||||
test_cases.each_with_index do |headers, i|
|
||||
get "/api/verify", headers: headers
|
||||
assert_response 200, "Failed on test case #{i}: #{headers.inspect}"
|
||||
end
|
||||
end
|
||||
end
|
||||
90
test/jobs/application_job_test.rb
Normal file
90
test/jobs/application_job_test.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
require "test_helper"
|
||||
|
||||
class ApplicationJobTest < ActiveJob::TestCase
|
||||
test "should inherit from ActiveJob::Base" do
|
||||
assert ApplicationJob < ActiveJob::Base
|
||||
end
|
||||
|
||||
test "should have proper job configuration" do
|
||||
# Test that the ApplicationJob is properly configured
|
||||
assert_respond_to ApplicationJob, :perform_now
|
||||
assert_respond_to ApplicationJob, :perform_later
|
||||
end
|
||||
|
||||
test "should handle job execution" do
|
||||
# Create a simple test job to verify the base functionality
|
||||
test_job = Class.new(ApplicationJob) do
|
||||
def perform(*args)
|
||||
args
|
||||
end
|
||||
end
|
||||
|
||||
# Test synchronous execution
|
||||
result = test_job.perform_now("test", "data")
|
||||
assert_equal ["test", "data"], result
|
||||
|
||||
# Test asynchronous execution using the test helper
|
||||
assert_enqueued_jobs 1 do
|
||||
test_job.perform_later("test", "data")
|
||||
end
|
||||
end
|
||||
|
||||
test "should queue jobs with proper arguments" do
|
||||
test_job = Class.new(ApplicationJob) do
|
||||
def perform(*args)
|
||||
# No-op for testing
|
||||
end
|
||||
end
|
||||
|
||||
assert_enqueued_jobs 1 do
|
||||
test_job.perform_later("arg1", "arg2", { key: "value" })
|
||||
end
|
||||
|
||||
# Job class name may be nil in test environment, focus on args
|
||||
assert_equal ["arg1", "arg2", { key: "value" }], enqueued_jobs.last[:args]
|
||||
end
|
||||
|
||||
test "should have default queue configuration" do
|
||||
# Test that jobs have proper queue configuration
|
||||
test_job = Class.new(ApplicationJob) do
|
||||
def perform(*args)
|
||||
# No-op
|
||||
end
|
||||
end
|
||||
|
||||
job_instance = test_job.new
|
||||
assert_respond_to job_instance, :queue_name
|
||||
end
|
||||
|
||||
test "should handle job serialization and deserialization" do
|
||||
# Test that Active Record objects can be properly serialized
|
||||
user = users(:alice)
|
||||
|
||||
test_job = Class.new(ApplicationJob) do
|
||||
def perform(user_record)
|
||||
user_record.email_address
|
||||
end
|
||||
end
|
||||
|
||||
assert_enqueued_jobs 1 do
|
||||
test_job.perform_later(user)
|
||||
end
|
||||
|
||||
# Verify the job was queued with user (handling serialization)
|
||||
args = enqueued_jobs.last[:args]
|
||||
if args.is_a?(Array) && args.first.is_a?(Hash)
|
||||
# GlobalID serialization format
|
||||
assert_equal user.to_global_id.to_s, args.first['_aj_globalid']
|
||||
else
|
||||
# Direct object serialization
|
||||
assert_equal user.id, args.first.id
|
||||
end
|
||||
end
|
||||
|
||||
test "should respect retry configuration" do
|
||||
# This tests the framework for retry configuration
|
||||
# Individual jobs should inherit this behavior
|
||||
assert_respond_to ApplicationJob, :retry_on
|
||||
assert_respond_to ApplicationJob, :discard_on
|
||||
end
|
||||
end
|
||||
123
test/jobs/invitations_mailer_test.rb
Normal file
123
test/jobs/invitations_mailer_test.rb
Normal file
@@ -0,0 +1,123 @@
|
||||
require "test_helper"
|
||||
|
||||
class InvitationsMailerTest < ActionMailer::TestCase
|
||||
setup do
|
||||
@user = users(:alice)
|
||||
@invitation_mail = InvitationsMailer.invite_user(@user)
|
||||
end
|
||||
|
||||
test "should queue invitation email job" do
|
||||
# Note: In test environment, deliver_later might not enqueue jobs the same way
|
||||
# This test focuses on the mail delivery functionality
|
||||
assert_nothing_raised do
|
||||
InvitationsMailer.invite_user(@user).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
test "should deliver invitation email successfully" do
|
||||
assert_emails 1 do
|
||||
InvitationsMailer.invite_user(@user).deliver_now
|
||||
end
|
||||
end
|
||||
|
||||
test "should have correct email content" do
|
||||
email = @invitation_mail
|
||||
|
||||
assert_equal "You're invited to join Clinch", email.subject
|
||||
assert_equal [@user.email_address], email.to
|
||||
assert_equal [], email.cc
|
||||
assert_equal [], email.bcc
|
||||
# From address is configured in ApplicationMailer
|
||||
assert_not_nil email.from
|
||||
assert email.from.is_a?(Array)
|
||||
end
|
||||
|
||||
test "should include user data in email body" do
|
||||
email = @invitation_mail
|
||||
# Use text_part to get the readable content
|
||||
email_text = email.text_part&.decoded || email.body.decoded
|
||||
|
||||
# Should include invitation-related text
|
||||
assert_includes email_text, "invited"
|
||||
assert_includes email_text, "Clinch"
|
||||
end
|
||||
|
||||
test "should handle different user statuses" do
|
||||
# Test with pending user
|
||||
pending_user = users(:bob)
|
||||
pending_user.status = :pending_invitation
|
||||
pending_user.save!
|
||||
|
||||
assert_emails 1 do
|
||||
InvitationsMailer.invite_user(pending_user).deliver_now
|
||||
end
|
||||
end
|
||||
|
||||
test "should queue multiple invitation emails" do
|
||||
users = [users(:alice), users(:bob)]
|
||||
|
||||
# Test that multiple deliveries don't raise errors
|
||||
assert_nothing_raised do
|
||||
users.each { |user| InvitationsMailer.invite_user(user).deliver_later }
|
||||
end
|
||||
|
||||
# Test synchronous delivery to verify functionality
|
||||
assert_emails 2 do
|
||||
users.each { |user| InvitationsMailer.invite_user(user).deliver_now }
|
||||
end
|
||||
end
|
||||
|
||||
test "should handle job with invalid user" do
|
||||
# Test behavior when user doesn't exist
|
||||
invalid_user_id = User.maximum(:id) + 1000
|
||||
|
||||
# This should not raise an error immediately (job is queued)
|
||||
assert_nothing_raised do
|
||||
assert_enqueued_jobs 1 do
|
||||
# Create a mail with non-persisted user for testing
|
||||
temp_user = User.new(id: invalid_user_id, email_address: "invalid@test.com")
|
||||
InvitationsMailer.invite_user(temp_user).deliver_later
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "should respect mailer configuration" do
|
||||
# Test that the mailer inherits from ApplicationMailer properly
|
||||
assert InvitationsMailer < ApplicationMailer
|
||||
assert_respond_to InvitationsMailer, :default
|
||||
end
|
||||
|
||||
test "should handle concurrent email deliveries" do
|
||||
# Simulate concurrent invitation deliveries
|
||||
users = User.limit(3)
|
||||
|
||||
# Test that multiple deliveries don't raise errors
|
||||
assert_nothing_raised do
|
||||
users.each do |user|
|
||||
InvitationsMailer.invite_user(user).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
# Test synchronous delivery to verify functionality
|
||||
assert_emails users.count do
|
||||
users.each do |user|
|
||||
InvitationsMailer.invite_user(user).deliver_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "should have proper email headers" do
|
||||
email = @invitation_mail
|
||||
|
||||
# Test common email headers
|
||||
assert_not_nil email.message_id
|
||||
assert_not_nil email.date
|
||||
|
||||
# Test content-type
|
||||
if email.html_part
|
||||
assert_includes email.content_type, "text/html"
|
||||
elsif email.text_part
|
||||
assert_includes email.content_type, "text/plain"
|
||||
end
|
||||
end
|
||||
end
|
||||
197
test/jobs/passwords_mailer_test.rb
Normal file
197
test/jobs/passwords_mailer_test.rb
Normal file
@@ -0,0 +1,197 @@
|
||||
require "test_helper"
|
||||
|
||||
class PasswordsMailerTest < ActionMailer::TestCase
|
||||
setup do
|
||||
@user = users(:alice)
|
||||
@reset_mail = PasswordsMailer.reset(@user)
|
||||
end
|
||||
|
||||
test "should queue password reset email job" do
|
||||
# Note: In test environment, deliver_later might not enqueue jobs the same way
|
||||
# This test focuses on the mail delivery functionality
|
||||
assert_nothing_raised do
|
||||
PasswordsMailer.reset(@user).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
test "should deliver password reset email successfully" do
|
||||
assert_emails 1 do
|
||||
PasswordsMailer.reset(@user).deliver_now
|
||||
end
|
||||
end
|
||||
|
||||
test "should have correct email content" do
|
||||
email = @reset_mail
|
||||
|
||||
assert_equal "Reset your password", email.subject
|
||||
assert_equal [@user.email_address], email.to
|
||||
assert_equal [], email.cc
|
||||
assert_equal [], email.bcc
|
||||
# From address is configured in ApplicationMailer
|
||||
assert_not_nil email.from
|
||||
assert email.from.is_a?(Array)
|
||||
end
|
||||
|
||||
test "should include user data and reset token in email body" do
|
||||
# Set a password reset token for testing
|
||||
@user.generate_token_for(:password_reset)
|
||||
@user.save!
|
||||
|
||||
email = PasswordsMailer.reset(@user)
|
||||
email_body = email.body.encoded
|
||||
|
||||
# Should include user's email address
|
||||
assert_includes email_body, @user.email_address
|
||||
|
||||
# Should include reset link structure
|
||||
assert_includes email_body, "reset"
|
||||
assert_includes email_body, "password"
|
||||
|
||||
# Use text_part to get readable content
|
||||
email_text = email.text_part&.decoded || email.body.decoded
|
||||
|
||||
# Should include reset-related text
|
||||
assert_includes email_text, "reset"
|
||||
assert_includes email_text, "password"
|
||||
end
|
||||
|
||||
test "should handle users with different statuses" do
|
||||
# Test with active user
|
||||
active_user = users(:bob)
|
||||
assert active_user.status == "active"
|
||||
|
||||
assert_emails 1 do
|
||||
PasswordsMailer.reset(active_user).deliver_now
|
||||
end
|
||||
|
||||
# Test with disabled user (should still send reset if they request it)
|
||||
active_user.status = :disabled
|
||||
active_user.save!
|
||||
|
||||
assert_emails 1 do
|
||||
PasswordsMailer.reset(active_user).deliver_now
|
||||
end
|
||||
end
|
||||
|
||||
test "should queue multiple password reset emails" do
|
||||
users = [users(:alice), users(:bob)]
|
||||
|
||||
# Test that multiple deliveries don't raise errors
|
||||
assert_nothing_raised do
|
||||
users.each do |user|
|
||||
user.generate_token_for(:password_reset)
|
||||
PasswordsMailer.reset(user).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
# Test synchronous delivery to verify functionality
|
||||
assert_emails 2 do
|
||||
users.each do |user|
|
||||
user.generate_token_for(:password_reset)
|
||||
PasswordsMailer.reset(user).deliver_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "should handle user with reset token" do
|
||||
# User should have a reset token for the email to be useful
|
||||
assert_respond_to @user, :password_reset_token
|
||||
|
||||
# Generate token and test email content
|
||||
@user.generate_token_for(:password_reset)
|
||||
@user.save!
|
||||
|
||||
email = PasswordsMailer.reset(@user)
|
||||
email_text = email.text_part&.decoded || email.body.decoded
|
||||
|
||||
assert_not_nil @user.password_reset_token
|
||||
assert_includes email_text, "reset"
|
||||
end
|
||||
|
||||
test "should handle expired reset tokens gracefully" do
|
||||
# Test email generation even with expired tokens
|
||||
@user.generate_token_for(:password_reset)
|
||||
|
||||
# Manually expire the token by updating its created_at time
|
||||
@user.instance_variable_set(:@password_reset_token_created_at, 25.hours.ago)
|
||||
|
||||
# Email should still generate (validation happens elsewhere)
|
||||
assert_emails 1 do
|
||||
PasswordsMailer.reset(@user).deliver_now
|
||||
end
|
||||
end
|
||||
|
||||
test "should respect mailer configuration" do
|
||||
# Test that the mailer inherits from ApplicationMailer properly
|
||||
assert PasswordsMailer < ApplicationMailer
|
||||
assert_respond_to PasswordsMailer, :default
|
||||
end
|
||||
|
||||
test "should handle concurrent password reset deliveries" do
|
||||
# Simulate concurrent password reset deliveries
|
||||
users = User.limit(3)
|
||||
|
||||
# Test that multiple deliveries don't raise errors
|
||||
assert_nothing_raised do
|
||||
users.each do |user|
|
||||
user.generate_token_for(:password_reset)
|
||||
PasswordsMailer.reset(user).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
# Test synchronous delivery to verify functionality
|
||||
assert_emails users.count do
|
||||
users.each do |user|
|
||||
user.generate_token_for(:password_reset)
|
||||
PasswordsMailer.reset(user).deliver_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "should have proper email headers and security" do
|
||||
email = @reset_mail
|
||||
|
||||
# Test common email headers
|
||||
assert_not_nil email.message_id
|
||||
assert_not_nil email.date
|
||||
|
||||
# Test content-type
|
||||
if email.html_part
|
||||
assert_includes email.content_type, "text/html"
|
||||
elsif email.text_part
|
||||
assert_includes email.content_type, "text/plain"
|
||||
end
|
||||
|
||||
# Should not include sensitive data in headers
|
||||
email.header.each do |key, value|
|
||||
refute_includes value.to_s.downcase, "password"
|
||||
refute_includes value.to_s.downcase, "token"
|
||||
end
|
||||
end
|
||||
|
||||
test "should handle users with different email formats" do
|
||||
# Test with different email formats to ensure proper handling
|
||||
test_emails = [
|
||||
"user+tag@example.com",
|
||||
"user.name@example.com",
|
||||
"user@example.co.uk",
|
||||
"123user@example.com"
|
||||
]
|
||||
|
||||
test_emails.each do |email_address|
|
||||
temp_user = User.new(
|
||||
email_address: email_address,
|
||||
password: "password123",
|
||||
status: :active
|
||||
)
|
||||
temp_user.save!(validate: false) # Skip validation for testing
|
||||
|
||||
assert_emails 1 do
|
||||
PasswordsMailer.reset(temp_user).deliver_now
|
||||
end
|
||||
|
||||
email = PasswordsMailer.reset(temp_user)
|
||||
assert_equal [email_address], email.to
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -124,4 +124,272 @@ class ForwardAuthRuleTest < ActiveSupport::TestCase
|
||||
|
||||
assert_not @rule.user_allowed?(user)
|
||||
end
|
||||
|
||||
# Header Configuration Tests
|
||||
test "effective_headers should return default headers when no custom config" do
|
||||
@rule.save!
|
||||
|
||||
expected = ForwardAuthRule::DEFAULT_HEADERS
|
||||
assert_equal expected, @rule.effective_headers
|
||||
end
|
||||
|
||||
test "effective_headers should merge custom headers with defaults" do
|
||||
@rule.save!
|
||||
@rule.update!(headers_config: { user: "X-Forwarded-User", email: "X-Forwarded-Email" })
|
||||
|
||||
expected = ForwardAuthRule::DEFAULT_HEADERS.merge(
|
||||
user: "X-Forwarded-User",
|
||||
email: "X-Forwarded-Email"
|
||||
)
|
||||
assert_equal expected, @rule.effective_headers
|
||||
end
|
||||
|
||||
test "headers_for_user should generate correct headers for user with groups" do
|
||||
group = groups(:one)
|
||||
user = users(:one)
|
||||
user.groups << group
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
assert_equal user.email_address, headers["X-Remote-User"]
|
||||
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||
assert_equal user.email_address, headers["X-Remote-Name"]
|
||||
assert_equal group.name, headers["X-Remote-Groups"]
|
||||
assert_equal "true", headers["X-Remote-Admin"]
|
||||
end
|
||||
|
||||
test "headers_for_user should generate correct headers for user without groups" do
|
||||
user = users(:one)
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
assert_equal user.email_address, headers["X-Remote-User"]
|
||||
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||
assert_equal user.email_address, headers["X-Remote-Name"]
|
||||
assert_nil headers["X-Remote-Groups"] # No groups, no header
|
||||
assert_equal "true", headers["X-Remote-Admin"]
|
||||
end
|
||||
|
||||
test "headers_for_user should work with custom headers" do
|
||||
user = users(:one)
|
||||
@rule.update!(headers_config: {
|
||||
user: "X-Forwarded-User",
|
||||
groups: "X-Custom-Groups"
|
||||
})
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
assert_equal user.email_address, headers["X-Forwarded-User"]
|
||||
assert_nil headers["X-Remote-User"] # Should be overridden
|
||||
assert_equal user.email_address, headers["X-Remote-Email"] # Default preserved
|
||||
assert_nil headers["X-Custom-Groups"] # User has no groups
|
||||
end
|
||||
|
||||
test "headers_for_user should return empty hash when all headers disabled" do
|
||||
user = users(:one)
|
||||
@rule.update!(headers_config: {
|
||||
user: "",
|
||||
email: "",
|
||||
name: "",
|
||||
groups: "",
|
||||
admin: ""
|
||||
})
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
assert_empty headers
|
||||
end
|
||||
|
||||
test "headers_disabled? should correctly identify disabled headers" do
|
||||
@rule.save!
|
||||
assert_not @rule.headers_disabled?
|
||||
|
||||
@rule.update!(headers_config: { user: "X-Custom-User" })
|
||||
assert_not @rule.headers_disabled?
|
||||
|
||||
@rule.update!(headers_config: { user: "", email: "", name: "", groups: "", admin: "" })
|
||||
assert @rule.headers_disabled?
|
||||
end
|
||||
|
||||
# Additional Domain Pattern Tests
|
||||
test "matches_domain? should handle complex patterns" do
|
||||
@rule.save!
|
||||
|
||||
# Test multiple wildcards
|
||||
@rule.update!(domain_pattern: "*.*.example.com")
|
||||
assert @rule.matches_domain?("app.dev.example.com")
|
||||
assert @rule.matches_domain?("api.staging.example.com")
|
||||
assert_not @rule.matches_domain?("example.com")
|
||||
assert_not @rule.matches_domain?("app.example.org")
|
||||
|
||||
# Test exact domain with dots
|
||||
@rule.update!(domain_pattern: "api.v2.example.com")
|
||||
assert @rule.matches_domain?("api.v2.example.com")
|
||||
assert_not @rule.matches_domain?("api.v3.example.com")
|
||||
assert_not @rule.matches_domain?("v2.api.example.com")
|
||||
end
|
||||
|
||||
test "matches_domain? should handle case insensitivity" do
|
||||
@rule.update!(domain_pattern: "*.EXAMPLE.COM")
|
||||
@rule.save!
|
||||
|
||||
assert @rule.matches_domain?("app.example.com")
|
||||
assert @rule.matches_domain?("APP.EXAMPLE.COM")
|
||||
assert @rule.matches_domain?("App.Example.Com")
|
||||
end
|
||||
|
||||
test "matches_domain? should handle empty and nil domains" do
|
||||
@rule.save!
|
||||
|
||||
assert_not @rule.matches_domain?("")
|
||||
assert_not @rule.matches_domain?(nil)
|
||||
end
|
||||
|
||||
# Advanced Header Configuration Tests
|
||||
test "headers_for_user should handle partial header configuration" do
|
||||
user = users(:one)
|
||||
user.groups << groups(:one)
|
||||
@rule.update!(headers_config: {
|
||||
user: "X-Custom-User",
|
||||
email: "", # Disabled
|
||||
groups: "X-Custom-Groups"
|
||||
})
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
# Should include custom user header
|
||||
assert_equal "X-Custom-User", headers.keys.find { |k| k.include?("User") }
|
||||
assert_equal user.email_address, headers["X-Custom-User"]
|
||||
|
||||
# Should include default email header (not overridden)
|
||||
assert_equal "X-Remote-Email", headers.keys.find { |k| k.include?("Email") }
|
||||
assert_equal user.email_address, headers["X-Remote-Email"]
|
||||
|
||||
# Should include custom groups header
|
||||
assert_equal "X-Custom-Groups", headers.keys.find { |k| k.include?("Groups") }
|
||||
assert_equal groups(:one).name, headers["X-Custom-Groups"]
|
||||
|
||||
# Should include default name header (not overridden)
|
||||
assert_equal "X-Remote-Name", headers.keys.find { |k| k.include?("Name") }
|
||||
end
|
||||
|
||||
test "headers_for_user should handle user without groups when groups header configured" do
|
||||
user = users(:one)
|
||||
user.groups.clear # No groups
|
||||
@rule.update!(headers_config: { groups: "X-Custom-Groups" })
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
# Should not include groups header for user with no groups
|
||||
assert_nil headers["X-Custom-Groups"]
|
||||
assert_nil headers["X-Remote-Groups"]
|
||||
end
|
||||
|
||||
test "headers_for_user should handle non-admin user correctly" do
|
||||
user = users(:one)
|
||||
# Ensure user is not admin
|
||||
user.update!(admin: false)
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
assert_equal "false", headers["X-Remote-Admin"]
|
||||
end
|
||||
|
||||
test "headers_for_user should work with nil headers_config" do
|
||||
user = users(:one)
|
||||
@rule.update!(headers_config: nil)
|
||||
@rule.save!
|
||||
|
||||
headers = @rule.headers_for_user(user)
|
||||
|
||||
# Should use default headers
|
||||
assert_equal "X-Remote-User", headers.keys.find { |k| k.include?("User") }
|
||||
assert_equal user.email_address, headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
test "effective_headers should handle symbol keys in headers_config" do
|
||||
@rule.update!(headers_config: { user: "X-Symbol-User", email: "X-Symbol-Email" })
|
||||
@rule.save!
|
||||
|
||||
effective = @rule.effective_headers
|
||||
|
||||
assert_equal "X-Symbol-User", effective[:user]
|
||||
assert_equal "X-Symbol-Email", effective[:email]
|
||||
assert_equal "X-Remote-Name", effective[:name] # Default
|
||||
end
|
||||
|
||||
test "effective_headers should handle string keys in headers_config" do
|
||||
@rule.update!(headers_config: { "user" => "X-String-User", "email" => "X-String-Email" })
|
||||
@rule.save!
|
||||
|
||||
effective = @rule.effective_headers
|
||||
|
||||
assert_equal "X-String-User", effective[:user]
|
||||
assert_equal "X-String-Email", effective[:email]
|
||||
assert_equal "X-Remote-Name", effective[:name] # Default
|
||||
end
|
||||
|
||||
# Policy and Access Control Tests
|
||||
test "policy_for_user should handle user with TOTP enabled" do
|
||||
user = users(:one)
|
||||
user.update!(totp_secret: "test_secret")
|
||||
@rule.allowed_groups << groups(:one)
|
||||
user.groups << groups(:one)
|
||||
@rule.save!
|
||||
|
||||
policy = @rule.policy_for_user(user)
|
||||
assert_equal "two_factor", policy
|
||||
end
|
||||
|
||||
test "policy_for_user should handle user without TOTP" do
|
||||
user = users(:one)
|
||||
user.update!(totp_secret: nil)
|
||||
@rule.allowed_groups << groups(:one)
|
||||
user.groups << groups(:one)
|
||||
@rule.save!
|
||||
|
||||
policy = @rule.policy_for_user(user)
|
||||
assert_equal "one_factor", policy
|
||||
end
|
||||
|
||||
test "policy_for_user should handle user with multiple groups" do
|
||||
user = users(:one)
|
||||
group1 = groups(:one)
|
||||
group2 = groups(:two)
|
||||
@rule.allowed_groups << group1
|
||||
@rule.allowed_groups << group2
|
||||
user.groups << group1
|
||||
@rule.save!
|
||||
|
||||
policy = @rule.policy_for_user(user)
|
||||
assert_equal "one_factor", policy
|
||||
end
|
||||
|
||||
test "user_allowed? should handle user with multiple groups, one allowed" do
|
||||
user = users(:one)
|
||||
allowed_group = groups(:one)
|
||||
other_group = groups(:two)
|
||||
@rule.allowed_groups << allowed_group
|
||||
user.groups << allowed_group
|
||||
user.groups << other_group
|
||||
@rule.save!
|
||||
|
||||
assert @rule.user_allowed?(user)
|
||||
end
|
||||
|
||||
test "user_allowed? should handle user with multiple groups, none allowed" do
|
||||
user = users(:one)
|
||||
group1 = groups(:one)
|
||||
group2 = groups(:two)
|
||||
# Don't add any groups to allowed_groups
|
||||
user.groups << group1
|
||||
user.groups << group2
|
||||
@rule.save!
|
||||
|
||||
assert_not @rule.user_allowed?(user)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,218 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcAccessTokenTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
def setup
|
||||
@access_token = oidc_access_tokens(:one)
|
||||
end
|
||||
|
||||
test "should be valid with all required attributes" do
|
||||
assert @access_token.valid?
|
||||
end
|
||||
|
||||
test "should belong to an application" do
|
||||
assert_respond_to @access_token, :application
|
||||
assert_equal applications(:kavita_app), @access_token.application
|
||||
end
|
||||
|
||||
test "should belong to a user" do
|
||||
assert_respond_to @access_token, :user
|
||||
assert_equal users(:alice), @access_token.user
|
||||
end
|
||||
|
||||
test "should generate token before validation on create" do
|
||||
new_token = OidcAccessToken.new(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice)
|
||||
)
|
||||
assert_nil new_token.token
|
||||
assert new_token.save
|
||||
assert_not_nil new_token.token
|
||||
assert_match /^[A-Za-z0-9_-]+$/, new_token.token
|
||||
end
|
||||
|
||||
test "should set expiry before validation on create" do
|
||||
new_token = OidcAccessToken.new(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice)
|
||||
)
|
||||
assert_nil new_token.expires_at
|
||||
assert new_token.save
|
||||
assert_not_nil new_token.expires_at
|
||||
assert new_token.expires_at > Time.current
|
||||
assert new_token.expires_at <= 61.minutes.from_now # Allow some variance
|
||||
end
|
||||
|
||||
test "should validate presence of token" do
|
||||
@access_token.token = nil
|
||||
assert_not @access_token.valid?
|
||||
assert_includes @access_token.errors[:token], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate uniqueness of token" do
|
||||
@access_token.save! if @access_token.changed?
|
||||
duplicate = OidcAccessToken.new(
|
||||
token: @access_token.token,
|
||||
application: applications(:another_app),
|
||||
user: users(:bob)
|
||||
)
|
||||
assert_not duplicate.valid?
|
||||
assert_includes duplicate.errors[:token], "has already been taken"
|
||||
end
|
||||
|
||||
test "should identify expired tokens correctly" do
|
||||
@access_token.expires_at = 5.minutes.ago
|
||||
assert @access_token.expired?, "Should identify past expiry as expired"
|
||||
|
||||
@access_token.expires_at = 5.minutes.from_now
|
||||
assert_not @access_token.expired?, "Should identify future expiry as not expired"
|
||||
|
||||
@access_token.expires_at = Time.current
|
||||
assert @access_token.expired?, "Should identify current time as expired"
|
||||
end
|
||||
|
||||
test "should identify active tokens correctly" do
|
||||
# Non-expired token should be active
|
||||
@access_token.expires_at = 5.minutes.from_now
|
||||
assert @access_token.active?, "Future expiry should be active"
|
||||
|
||||
# Expired token should not be active
|
||||
@access_token.expires_at = 5.minutes.ago
|
||||
assert_not @access_token.active?, "Past expiry should not be active"
|
||||
|
||||
# Current time should be considered expired (not active)
|
||||
@access_token.expires_at = Time.current
|
||||
assert_not @access_token.active?, "Current time should not be active"
|
||||
end
|
||||
|
||||
test "should revoke token correctly" do
|
||||
@access_token.expires_at = 1.hour.from_now
|
||||
original_expiry = @access_token.expires_at
|
||||
assert @access_token.active?, "Token should be active before revocation"
|
||||
|
||||
@access_token.revoke!
|
||||
@access_token.reload
|
||||
|
||||
assert @access_token.expired?, "Token should be expired after revocation"
|
||||
assert @access_token.expires_at <= Time.current, "Expiry should be set to current time or earlier"
|
||||
assert @access_token.expires_at < original_expiry, "Expiry should be changed from original"
|
||||
end
|
||||
|
||||
test "valid scope should return only non-expired tokens" do
|
||||
# Create tokens with different states
|
||||
valid_token = OidcAccessToken.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice)
|
||||
)
|
||||
|
||||
expired_token = OidcAccessToken.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
expires_at: 5.minutes.ago
|
||||
)
|
||||
|
||||
valid_tokens = OidcAccessToken.valid
|
||||
assert_includes valid_tokens, valid_token
|
||||
assert_not_includes valid_tokens, expired_token
|
||||
end
|
||||
|
||||
test "expired scope should return only expired tokens" do
|
||||
# Create tokens with different expiry states
|
||||
non_expired_token = OidcAccessToken.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
expires_at: 1.hour.from_now
|
||||
)
|
||||
|
||||
expired_token = OidcAccessToken.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
expires_at: 5.minutes.ago
|
||||
)
|
||||
|
||||
expired_tokens = OidcAccessToken.expired
|
||||
assert_includes expired_tokens, expired_token
|
||||
assert_not_includes expired_tokens, non_expired_token
|
||||
end
|
||||
|
||||
test "should handle concurrent revocation safely" do
|
||||
@access_token.expires_at = 1.hour.from_now
|
||||
@access_token.save!
|
||||
|
||||
original_active = @access_token.active?
|
||||
@access_token.revoke!
|
||||
|
||||
assert original_active, "Token should be active before revocation"
|
||||
assert @access_token.expired?, "Token should be expired after revocation"
|
||||
end
|
||||
|
||||
test "should generate secure random tokens" do
|
||||
tokens = []
|
||||
5.times do
|
||||
token = OidcAccessToken.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice)
|
||||
)
|
||||
tokens << token.token
|
||||
end
|
||||
|
||||
# All tokens should be unique
|
||||
assert_equal tokens.length, tokens.uniq.length
|
||||
|
||||
# All tokens should match the expected pattern
|
||||
tokens.each do |token|
|
||||
assert_match /^[A-Za-z0-9_-]+$/, token
|
||||
# Base64 token length may vary due to padding, just ensure it's reasonable
|
||||
assert token.length >= 43, "Token should be at least 43 characters"
|
||||
assert token.length <= 64, "Token should not exceed 64 characters"
|
||||
end
|
||||
end
|
||||
|
||||
test "should have longer token than authorization codes" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback"
|
||||
)
|
||||
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice)
|
||||
)
|
||||
|
||||
assert access_token.token.length > auth_code.code.length,
|
||||
"Access tokens should be longer than authorization codes"
|
||||
end
|
||||
|
||||
test "should have appropriate expiry times" do
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback"
|
||||
)
|
||||
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice)
|
||||
)
|
||||
|
||||
# Authorization codes expire in 10 minutes, access tokens in 1 hour
|
||||
assert access_token.expires_at > auth_code.expires_at,
|
||||
"Access tokens should have longer expiry than authorization codes"
|
||||
end
|
||||
|
||||
test "revoked tokens should not appear in valid scope" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice)
|
||||
)
|
||||
|
||||
# Token should be in valid scope initially
|
||||
assert_includes OidcAccessToken.valid, access_token
|
||||
|
||||
# Revoke the token
|
||||
access_token.revoke!
|
||||
|
||||
# Token should no longer be in valid scope
|
||||
assert_not_includes OidcAccessToken.valid, access_token
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,193 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcAuthorizationCodeTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
def setup
|
||||
@auth_code = oidc_authorization_codes(:one)
|
||||
end
|
||||
|
||||
test "should be valid with all required attributes" do
|
||||
assert @auth_code.valid?
|
||||
end
|
||||
|
||||
test "should belong to an application" do
|
||||
assert_respond_to @auth_code, :application
|
||||
assert_equal applications(:kavita_app), @auth_code.application
|
||||
end
|
||||
|
||||
test "should belong to a user" do
|
||||
assert_respond_to @auth_code, :user
|
||||
assert_equal users(:alice), @auth_code.user
|
||||
end
|
||||
|
||||
test "should generate code before validation on create" do
|
||||
new_code = OidcAuthorizationCode.new(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback"
|
||||
)
|
||||
assert_nil new_code.code
|
||||
assert new_code.save
|
||||
assert_not_nil new_code.code
|
||||
assert_match /^[A-Za-z0-9_-]+$/, new_code.code
|
||||
end
|
||||
|
||||
test "should set expiry before validation on create" do
|
||||
new_code = OidcAuthorizationCode.new(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback"
|
||||
)
|
||||
assert_nil new_code.expires_at
|
||||
assert new_code.save
|
||||
assert_not_nil new_code.expires_at
|
||||
assert new_code.expires_at > Time.current
|
||||
assert new_code.expires_at <= 11.minutes.from_now # Allow some variance
|
||||
end
|
||||
|
||||
test "should validate presence of code" do
|
||||
@auth_code.code = nil
|
||||
assert_not @auth_code.valid?
|
||||
assert_includes @auth_code.errors[:code], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate uniqueness of code" do
|
||||
@auth_code.save! if @auth_code.changed?
|
||||
duplicate = OidcAuthorizationCode.new(
|
||||
code: @auth_code.code,
|
||||
application: applications(:another_app),
|
||||
user: users(:bob),
|
||||
redirect_uri: "https://example.com/callback"
|
||||
)
|
||||
assert_not duplicate.valid?
|
||||
assert_includes duplicate.errors[:code], "has already been taken"
|
||||
end
|
||||
|
||||
test "should validate presence of redirect_uri" do
|
||||
@auth_code.redirect_uri = nil
|
||||
assert_not @auth_code.valid?
|
||||
assert_includes @auth_code.errors[:redirect_uri], "can't be blank"
|
||||
end
|
||||
|
||||
test "should identify expired codes correctly" do
|
||||
@auth_code.expires_at = 5.minutes.ago
|
||||
assert @auth_code.expired?, "Should identify past expiry as expired"
|
||||
|
||||
@auth_code.expires_at = 5.minutes.from_now
|
||||
assert_not @auth_code.expired?, "Should identify future expiry as not expired"
|
||||
|
||||
@auth_code.expires_at = Time.current
|
||||
assert @auth_code.expired?, "Should identify current time as expired"
|
||||
end
|
||||
|
||||
test "should identify usable codes correctly" do
|
||||
# Fresh, unused code should be usable
|
||||
@auth_code.expires_at = 5.minutes.from_now
|
||||
@auth_code.used = false
|
||||
assert @auth_code.usable?, "Fresh unused code should be usable"
|
||||
|
||||
# Used code should not be usable
|
||||
@auth_code.used = true
|
||||
assert_not @auth_code.usable?, "Used code should not be usable"
|
||||
|
||||
# Expired code should not be usable
|
||||
@auth_code.used = false
|
||||
@auth_code.expires_at = 5.minutes.ago
|
||||
assert_not @auth_code.usable?, "Expired code should not be usable"
|
||||
|
||||
# Used and expired code should not be usable
|
||||
@auth_code.used = true
|
||||
@auth_code.expires_at = 5.minutes.ago
|
||||
assert_not @auth_code.usable?, "Used and expired code should not be usable"
|
||||
end
|
||||
|
||||
test "should consume code correctly" do
|
||||
@auth_code.used = false
|
||||
assert_not @auth_code.used?, "Code should initially be unused"
|
||||
|
||||
@auth_code.consume!
|
||||
@auth_code.reload
|
||||
assert @auth_code.used?, "Code should be marked as used after consumption"
|
||||
end
|
||||
|
||||
test "valid scope should return only unused and non-expired codes" do
|
||||
# Create codes with different states
|
||||
valid_code = OidcAuthorizationCode.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback"
|
||||
)
|
||||
|
||||
used_code = OidcAuthorizationCode.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback",
|
||||
used: true
|
||||
)
|
||||
|
||||
expired_code = OidcAuthorizationCode.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback",
|
||||
expires_at: 5.minutes.ago
|
||||
)
|
||||
|
||||
valid_codes = OidcAuthorizationCode.valid
|
||||
assert_includes valid_codes, valid_code
|
||||
assert_not_includes valid_codes, used_code
|
||||
assert_not_includes valid_codes, expired_code
|
||||
end
|
||||
|
||||
test "expired scope should return only expired codes" do
|
||||
# Create codes with different expiry states
|
||||
non_expired_code = OidcAuthorizationCode.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback",
|
||||
expires_at: 5.minutes.from_now
|
||||
)
|
||||
|
||||
expired_code = OidcAuthorizationCode.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback",
|
||||
expires_at: 5.minutes.ago
|
||||
)
|
||||
|
||||
expired_codes = OidcAuthorizationCode.expired
|
||||
assert_includes expired_codes, expired_code
|
||||
assert_not_includes expired_codes, non_expired_code
|
||||
end
|
||||
|
||||
test "should handle concurrent consumption safely" do
|
||||
@auth_code.used = false
|
||||
@auth_code.save!
|
||||
|
||||
# Simulate concurrent consumption
|
||||
original_used = @auth_code.used?
|
||||
@auth_code.consume!
|
||||
|
||||
assert_not original_used, "Code should be unused before consumption"
|
||||
assert @auth_code.used?, "Code should be used after consumption"
|
||||
end
|
||||
|
||||
test "should generate secure random codes" do
|
||||
codes = []
|
||||
5.times do
|
||||
code = OidcAuthorizationCode.create!(
|
||||
application: applications(:kavita_app),
|
||||
user: users(:alice),
|
||||
redirect_uri: "https://example.com/callback"
|
||||
)
|
||||
codes << code.code
|
||||
end
|
||||
|
||||
# All codes should be unique
|
||||
assert_equal codes.length, codes.uniq.length
|
||||
|
||||
# All codes should match the expected pattern
|
||||
codes.each do |code|
|
||||
assert_match /^[A-Za-z0-9_-]+$/, code
|
||||
assert_equal 43, code.length # Base64 padding removed
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,226 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcUserConsentTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
def setup
|
||||
@consent = oidc_user_consents(:alice_consent)
|
||||
end
|
||||
|
||||
test "should be valid with all required attributes" do
|
||||
assert @consent.valid?
|
||||
end
|
||||
|
||||
test "should belong to a user" do
|
||||
assert_respond_to @consent, :user
|
||||
assert_equal users(:alice), @consent.user
|
||||
end
|
||||
|
||||
test "should belong to an application" do
|
||||
assert_respond_to @consent, :application
|
||||
assert_equal applications(:kavita_app), @consent.application
|
||||
end
|
||||
|
||||
test "should validate presence of user" do
|
||||
@consent.user = nil
|
||||
assert_not @consent.valid?
|
||||
assert_includes @consent.errors[:user], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate presence of application" do
|
||||
@consent.application = nil
|
||||
assert_not @consent.valid?
|
||||
assert_includes @consent.errors[:application], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate presence of scopes_granted" do
|
||||
@consent.scopes_granted = nil
|
||||
assert_not @consent.valid?
|
||||
assert_includes @consent.errors[:scopes_granted], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate presence of granted_at" do
|
||||
@consent.granted_at = nil
|
||||
assert_not @consent.valid?
|
||||
assert_includes @consent.errors[:granted_at], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate uniqueness of user_id scoped to application_id" do
|
||||
# Should be able to create consent for different user with same app
|
||||
new_consent = OidcUserConsent.new(
|
||||
user: users(:bob),
|
||||
application: @consent.application,
|
||||
scopes_granted: "openid email"
|
||||
)
|
||||
assert new_consent.valid?
|
||||
|
||||
# Should NOT be able to create consent for same user with same app
|
||||
duplicate_consent = OidcUserConsent.new(
|
||||
user: @consent.user,
|
||||
application: @consent.application,
|
||||
scopes_granted: "openid profile"
|
||||
)
|
||||
assert_not duplicate_consent.valid?
|
||||
assert_includes duplicate_consent.errors[:user_id], "has already been taken"
|
||||
end
|
||||
|
||||
test "should set granted_at before validation on create" do
|
||||
new_consent = OidcUserConsent.new(
|
||||
user: users(:alice),
|
||||
application: applications(:another_app),
|
||||
scopes_granted: "openid email"
|
||||
)
|
||||
assert_nil new_consent.granted_at
|
||||
assert new_consent.save!, "Should save successfully"
|
||||
assert_not_nil new_consent.granted_at
|
||||
assert new_consent.granted_at.is_a?(Time), "granted_at should be a Time object"
|
||||
end
|
||||
|
||||
test "scopes should parse space-separated scopes into array" do
|
||||
@consent.scopes_granted = "openid profile email groups"
|
||||
assert_equal ["openid", "profile", "email", "groups"], @consent.scopes
|
||||
|
||||
# Handle single scope
|
||||
@consent.scopes_granted = "openid"
|
||||
assert_equal ["openid"], @consent.scopes
|
||||
|
||||
# Handle empty string
|
||||
@consent.scopes_granted = ""
|
||||
assert_equal [], @consent.scopes
|
||||
|
||||
# Handle extra spaces
|
||||
@consent.scopes_granted = "openid profile email"
|
||||
assert_equal ["openid", "profile", "email"], @consent.scopes
|
||||
end
|
||||
|
||||
test "scopes= should join array into space-separated string" do
|
||||
@consent.scopes = ["openid", "profile", "email"]
|
||||
assert_equal "openid profile email", @consent.scopes_granted
|
||||
|
||||
# Handle single item array
|
||||
@consent.scopes = ["openid"]
|
||||
assert_equal "openid", @consent.scopes_granted
|
||||
|
||||
# Handle empty array
|
||||
@consent.scopes = []
|
||||
assert_equal "", @consent.scopes_granted
|
||||
|
||||
# Handle duplicates
|
||||
@consent.scopes = ["openid", "profile", "openid"]
|
||||
assert_equal "openid profile", @consent.scopes_granted
|
||||
end
|
||||
|
||||
test "should handle string input for scopes=" do
|
||||
@consent.scopes = "openid profile"
|
||||
assert_equal "openid profile", @consent.scopes_granted
|
||||
assert_equal ["openid", "profile"], @consent.scopes
|
||||
end
|
||||
|
||||
test "covers_scopes? should correctly identify scope coverage" do
|
||||
@consent.scopes_granted = "openid profile email groups"
|
||||
|
||||
# Should cover when all requested scopes are granted
|
||||
assert @consent.covers_scopes?(["openid"]), "Should cover single requested scope"
|
||||
assert @consent.covers_scopes?(["openid", "profile"]), "Should cover multiple requested scopes"
|
||||
assert @consent.covers_scopes?(["email", "groups"]), "Should cover different combination"
|
||||
assert @consent.covers_scopes?(["openid", "profile", "email", "groups"]), "Should cover all granted scopes"
|
||||
|
||||
# Should not cover when requested includes non-granted scope
|
||||
assert_not @consent.covers_scopes?(["admin"]), "Should not cover non-granted scope"
|
||||
assert_not @consent.covers_scopes?(["openid", "admin"]), "Should not cover mixed granted/non-granted"
|
||||
assert_not @consent.covers_scopes?(["admin", "write"]), "Should not cover all non-granted"
|
||||
|
||||
# Handle string input
|
||||
assert @consent.covers_scopes?("openid"), "Should handle string input"
|
||||
assert_not @consent.covers_scopes?("admin"), "Should handle string input for non-granted scope"
|
||||
|
||||
# Handle empty requested scopes
|
||||
assert @consent.covers_scopes?([]), "Should cover empty array"
|
||||
assert @consent.covers_scopes?(nil), "Should handle nil input"
|
||||
end
|
||||
|
||||
test "covers_scopes? should handle edge cases" do
|
||||
# Consent with no scopes
|
||||
@consent.scopes_granted = ""
|
||||
assert_not @consent.covers_scopes?(["openid"]), "Should not cover any scope when no scopes granted"
|
||||
assert @consent.covers_scopes?([]), "Should cover empty request when no scopes granted"
|
||||
|
||||
# Consent with one scope
|
||||
@consent.scopes_granted = "openid"
|
||||
assert @consent.covers_scopes?(["openid"]), "Should cover matching single scope"
|
||||
assert_not @consent.covers_scopes?(["profile"]), "Should not cover different single scope"
|
||||
end
|
||||
|
||||
test "formatted_scopes should provide human-readable scope names" do
|
||||
@consent.scopes_granted = "openid profile email groups"
|
||||
expected = "Basic authentication, Profile information, Email address, Group membership"
|
||||
assert_equal expected, @consent.formatted_scopes
|
||||
|
||||
# Test single scope
|
||||
@consent.scopes_granted = "openid"
|
||||
assert_equal "Basic authentication", @consent.formatted_scopes
|
||||
|
||||
# Test unknown scope
|
||||
@consent.scopes_granted = "unknown_scope"
|
||||
assert_equal "Unknown scope", @consent.formatted_scopes
|
||||
|
||||
# Test mixed known and unknown
|
||||
@consent.scopes_granted = "openid custom_scope"
|
||||
assert_equal "Basic authentication, Custom scope", @consent.formatted_scopes
|
||||
|
||||
# Test empty scopes
|
||||
@consent.scopes_granted = ""
|
||||
assert_equal "", @consent.formatted_scopes
|
||||
end
|
||||
|
||||
test "should maintain consistency between scopes getter and setter" do
|
||||
original_scopes = ["openid", "profile", "email"]
|
||||
@consent.scopes = original_scopes
|
||||
assert_equal original_scopes, @consent.scopes
|
||||
|
||||
# Modify scopes
|
||||
new_scopes = ["openid", "groups"]
|
||||
@consent.scopes = new_scopes
|
||||
assert_equal new_scopes, @consent.scopes
|
||||
end
|
||||
|
||||
test "should handle consent updates correctly" do
|
||||
# Use a different user and app combination to avoid uniqueness constraint
|
||||
consent = OidcUserConsent.create!(
|
||||
user: users(:alice),
|
||||
application: applications(:another_app), # Different app than in fixtures
|
||||
scopes_granted: "openid email"
|
||||
)
|
||||
|
||||
# Update to include more scopes
|
||||
consent.scopes = ["openid", "email", "profile"]
|
||||
consent.save!
|
||||
|
||||
consent.reload
|
||||
assert_equal ["openid", "email", "profile"], consent.scopes
|
||||
assert_equal "openid email profile", consent.scopes_granted
|
||||
|
||||
# Should still cover original scopes
|
||||
assert consent.covers_scopes?(["openid", "email"])
|
||||
# Should cover new scopes
|
||||
assert consent.covers_scopes?(["profile"])
|
||||
# Should cover all scopes
|
||||
assert consent.covers_scopes?(["openid", "email", "profile"])
|
||||
end
|
||||
|
||||
test "should validate scope coverage logic with real OIDC scenarios" do
|
||||
# Typical OIDC consent scenario
|
||||
@consent.scopes_granted = "openid profile email"
|
||||
|
||||
# Application requests only openid (required for OIDC)
|
||||
assert @consent.covers_scopes?(["openid"]), "Should cover required openid scope"
|
||||
|
||||
# Application requests standard scopes
|
||||
assert @consent.covers_scopes?(["openid", "profile"]), "Should cover standard OIDC scopes"
|
||||
|
||||
# Application requests more than granted
|
||||
assert_not @consent.covers_scopes?(["openid", "profile", "groups"]),
|
||||
"Should not cover scopes not granted"
|
||||
|
||||
# Application requests subset
|
||||
assert @consent.covers_scopes?(["email"]), "Should cover subset of granted scopes"
|
||||
end
|
||||
end
|
||||
|
||||
301
test/models/user_password_management_test.rb
Normal file
301
test/models/user_password_management_test.rb
Normal file
@@ -0,0 +1,301 @@
|
||||
require "test_helper"
|
||||
|
||||
class UserPasswordManagementTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@user = users(:alice)
|
||||
end
|
||||
|
||||
test "should generate password reset token" do
|
||||
assert_nil @user.password_reset_token
|
||||
assert_nil @user.password_reset_token_created_at
|
||||
|
||||
@user.generate_token_for(:password_reset)
|
||||
@user.save!
|
||||
|
||||
assert_not_nil @user.password_reset_token
|
||||
assert_not_nil @user.password_reset_token_created_at
|
||||
assert @user.password_reset_token.length > 20
|
||||
assert @user.password_reset_token_created_at > 5.minutes.ago
|
||||
end
|
||||
|
||||
test "should generate invitation login token" do
|
||||
assert_nil @user.invitation_login_token
|
||||
assert_nil @user.invitation_login_token_created_at
|
||||
|
||||
@user.generate_token_for(:invitation_login)
|
||||
@user.save!
|
||||
|
||||
assert_not_nil @user.invitation_login_token
|
||||
assert_not_nil @user.invitation_login_token_created_at
|
||||
assert @user.invitation_login_token.length > 20
|
||||
assert @user.invitation_login_token_created_at > 5.minutes.ago
|
||||
end
|
||||
|
||||
test "should generate magic login token" do
|
||||
assert_nil @user.magic_login_token
|
||||
assert_nil @user.magic_login_token_created_at
|
||||
|
||||
@user.generate_token_for(:magic_login)
|
||||
@user.save!
|
||||
|
||||
assert_not_nil @user.magic_login_token
|
||||
assert_not_nil @user.magic_login_token_created_at
|
||||
assert @user.magic_login_token.length > 20
|
||||
assert @user.magic_login_token_created_at > 5.minutes.ago
|
||||
end
|
||||
|
||||
test "should generate tokens with different lengths" do
|
||||
# Test that different token types generate appropriate length tokens
|
||||
token_types = [:password_reset, :invitation_login, :magic_login]
|
||||
|
||||
token_types.each do |token_type|
|
||||
@user.generate_token_for(token_type)
|
||||
@user.save!
|
||||
|
||||
token = @user.send("#{token_type}_token")
|
||||
assert_not_nil token, "#{token_type} token should be generated"
|
||||
assert token.length >= 32, "#{token_type} token should be at least 32 characters"
|
||||
assert token.length <= 64, "#{token_type} token should not exceed 64 characters"
|
||||
end
|
||||
end
|
||||
|
||||
test "should validate token expiration timing" do
|
||||
# Test token creation timing
|
||||
@user.generate_token_for(:password_reset)
|
||||
@user.save!
|
||||
|
||||
created_at = @user.send("#{:password_reset}_token_created_at")
|
||||
assert created_at.present?, "Token creation time should be set"
|
||||
assert created_at > 1.minute.ago, "Token should be recently created"
|
||||
assert created_at < 1.minute.from_now, "Token should be within reasonable time window"
|
||||
end
|
||||
|
||||
test "should handle secure password generation" do
|
||||
# Test that password generation follows security practices
|
||||
password = "SecurePassword123!"
|
||||
|
||||
# Test password contains uppercase, lowercase, numbers, special chars
|
||||
assert password.match(/[A-Z]/), "Password should contain uppercase letters"
|
||||
assert password.match(/[a-z]/), "Password should contain lowercase letters"
|
||||
assert password.match(/[0-9]/), "Password should contain numbers"
|
||||
assert password.match(/[!@#$%^&*()]/), "Password should contain special characters"
|
||||
assert password.length >= 12, "Password should be sufficiently long"
|
||||
end
|
||||
|
||||
test "should handle password authentication flow" do
|
||||
# Test password authentication cycle
|
||||
password = "TestPassword123!"
|
||||
@user.password = password
|
||||
@user.save!
|
||||
|
||||
# Test successful authentication
|
||||
authenticated_user = User.find_by(email_address: @user.email_address)
|
||||
assert authenticated_user.authenticate(password), "Should authenticate with correct password"
|
||||
assert_not authenticated_user.authenticate("WrongPassword"), "Should not authenticate with wrong password"
|
||||
|
||||
# Test password changes invalidate old sessions
|
||||
old_password_digest = @user.password_digest
|
||||
@user.password = "NewPassword123!"
|
||||
@user.save!
|
||||
|
||||
@user.reload
|
||||
assert_not @user.authenticate(password), "Old password should no longer work"
|
||||
assert @user.authenticate("NewPassword123!"), "New password should work"
|
||||
end
|
||||
|
||||
test "should handle bcrypt password hashing" do
|
||||
# Test that password hashing uses bcrypt properly
|
||||
password = "MySecurePassword456!"
|
||||
|
||||
# Create new user to test password digest
|
||||
new_user = User.new(
|
||||
email_address: "test@example.com",
|
||||
password: password
|
||||
)
|
||||
|
||||
assert new_user.valid?, "User should be valid with password"
|
||||
|
||||
# Save user to generate digest
|
||||
new_user.save!
|
||||
|
||||
# Test that digest is properly set
|
||||
assert_not_nil new_user.password_digest, "Password digest should be set"
|
||||
assert new_user.password_digest.length > 50, "Password digest should be substantial"
|
||||
|
||||
# Test digest format (bcrypt hashes start with $2a$)
|
||||
assert_match /^\$2a\$/, new_user.password_digest, "Password digest should be bcrypt format"
|
||||
|
||||
# Test authentication against digest
|
||||
authenticated_user = User.find(new_user.id)
|
||||
assert authenticated_user.authenticate(password), "Should authenticate against bcrypt digest"
|
||||
assert_not authenticated_user.authenticate("wrongpassword"), "Should fail authentication with wrong password"
|
||||
end
|
||||
|
||||
test "should validate different token types" do
|
||||
# Test all token types work
|
||||
token_types = [:password_reset, :invitation_login, :magic_login]
|
||||
|
||||
token_types.each do |token_type|
|
||||
@user.generate_token_for(token_type)
|
||||
@user.save!
|
||||
|
||||
case token_type
|
||||
when :password_reset
|
||||
assert @user.password_reset_token.present?
|
||||
assert @user.password_reset_token_valid?
|
||||
when :invitation_login
|
||||
assert @user.invitation_login_token.present?
|
||||
assert @user.invitation_login_token_valid?
|
||||
when :magic_login
|
||||
assert @user.magic_login_token.present?
|
||||
assert @user.magic_login_token_valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "should validate password strength" do
|
||||
# Test password validation rules
|
||||
weak_passwords = ["123456", "password", "qwerty", "abc123"]
|
||||
|
||||
weak_passwords.each do |password|
|
||||
user = User.new(email_address: "test@example.com", password: password)
|
||||
assert_not user.valid?, "Weak password should be invalid"
|
||||
assert_includes user.errors[:password].to_s, "too short", "Weak password should be too short"
|
||||
end
|
||||
|
||||
# Test valid password
|
||||
strong_password = "ThisIsA$tr0ngP@ssw0rd!123"
|
||||
user = User.new(email_address: "test@example.com", password: strong_password)
|
||||
assert user.valid?, "Strong password should be valid"
|
||||
end
|
||||
|
||||
test "should handle password confirmation validation" do
|
||||
# Test password confirmation matching
|
||||
user = User.new(
|
||||
email_address: "test@example.com",
|
||||
password: "password123",
|
||||
password_confirmation: "password123"
|
||||
)
|
||||
assert user.valid?, "Password and confirmation should match"
|
||||
|
||||
# Test password confirmation mismatch
|
||||
user.password_confirmation = "different"
|
||||
assert_not user.valid?, "Password and confirmation should match"
|
||||
assert_includes user.errors[:password_confirmation].to_s, "doesn't match"
|
||||
end
|
||||
|
||||
test "should handle password reset controller integration" do
|
||||
# Test that password reset functionality works with controller integration
|
||||
original_password = @user.password_digest
|
||||
|
||||
# Generate reset token through model
|
||||
@user.generate_token_for(:password_reset)
|
||||
@user.save!
|
||||
|
||||
reset_token = @user.password_reset_token
|
||||
assert_not_nil reset_token, "Should generate reset token"
|
||||
|
||||
# Verify token is usable in controller flow
|
||||
found_user = User.find_by_password_reset_token(reset_token)
|
||||
assert_equal @user, found_user, "Should find user by reset token"
|
||||
end
|
||||
|
||||
test "should handle different user statuses" do
|
||||
# Test password functionality for different user statuses
|
||||
active_user = users(:alice)
|
||||
disabled_user = users(:bob)
|
||||
disabled_user.status = :disabled
|
||||
disabled_user.save!
|
||||
|
||||
# Active user should be able to reset password
|
||||
assert active_user.generate_token_for(:password_reset)
|
||||
assert active_user.save
|
||||
|
||||
# Disabled user might still be able to reset password (business logic decision)
|
||||
# This test documents current behavior - adjust if needed
|
||||
assert_nothing_raised do
|
||||
disabled_user.generate_token_for(:password_reset)
|
||||
disabled_user.save
|
||||
end
|
||||
end
|
||||
|
||||
test "should validate email format during password operations" do
|
||||
# Test email format validation
|
||||
invalid_emails = [
|
||||
"invalid-email",
|
||||
"@example.com",
|
||||
"user@",
|
||||
"",
|
||||
nil
|
||||
]
|
||||
|
||||
invalid_emails.each do |email|
|
||||
user = User.new(email_address: email, password: "password123")
|
||||
assert_not user.valid?, "Invalid email should be rejected"
|
||||
assert user.errors[:email_address].present?, "Should have email error"
|
||||
end
|
||||
|
||||
# Test valid email formats
|
||||
valid_emails = [
|
||||
"user@example.com",
|
||||
"user+tag@example.com",
|
||||
"user.name@example.co.uk",
|
||||
"123user@example-domain.com"
|
||||
]
|
||||
|
||||
valid_emails.each do |email|
|
||||
user = User.new(email_address: email, password: "password123")
|
||||
assert user.valid?, "Valid email should be accepted"
|
||||
end
|
||||
end
|
||||
|
||||
test "should log password changes appropriately" do
|
||||
# Test that password changes are logged for audit
|
||||
original_password = @user.password_digest
|
||||
|
||||
# Perform password change directly (bypassing token flow for test)
|
||||
@user.password = "NewPassword123!"
|
||||
@user.save!
|
||||
|
||||
@user.reload
|
||||
assert_not_equal original_password, @user.password_digest
|
||||
assert @user.authenticate("NewPassword123!"), "New password should be valid"
|
||||
|
||||
# Test that old password is invalidated
|
||||
old_password_instance = @user.dup
|
||||
old_password_instance.password_digest = original_password
|
||||
|
||||
assert_not old_password_instance.authenticate("NewPassword123!"), "Old password should not authenticate new instance"
|
||||
assert_not old_password_instance.authenticate("NewPassword123!"), "Password change should invalidate old sessions"
|
||||
end
|
||||
|
||||
test "should update last_sign_in_at timestamp" do
|
||||
# Test that last_sign_in_at is initially nil
|
||||
assert_nil @user.last_sign_in_at, "New user should have nil last_sign_in_at"
|
||||
|
||||
# Update last_sign_in_at
|
||||
@user.update!(last_sign_in_at: Time.current)
|
||||
|
||||
@user.reload
|
||||
assert_not_nil @user.last_sign_in_at, "last_sign_in_at should be set after update"
|
||||
assert @user.last_sign_in_at > 1.minute.ago, "last_sign_in_at should be recent"
|
||||
end
|
||||
|
||||
test "should invalidate magic login token after sign in" do
|
||||
# Generate magic login token
|
||||
@user.update!(last_sign_in_at: 1.hour.ago) # Set initial timestamp
|
||||
old_sign_in_time = @user.last_sign_in_at
|
||||
|
||||
magic_token = @user.generate_token_for(:magic_login)
|
||||
|
||||
# Token should be valid before sign-in
|
||||
assert User.find_by_magic_login_token(magic_token)&.id == @user.id, "Magic login token should be valid initially"
|
||||
|
||||
# Simulate sign-in (which updates last_sign_in_at)
|
||||
@user.update!(last_sign_in_at: Time.current)
|
||||
|
||||
# Token should now be invalid because last_sign_in_at changed
|
||||
assert_nil User.find_by_magic_login_token(magic_token), "Magic login token should be invalid after sign-in"
|
||||
assert_not_equal old_sign_in_time, @user.last_sign_in_at, "last_sign_in_at should have changed"
|
||||
end
|
||||
end
|
||||
211
test/services/oidc_jwt_service_test.rb
Normal file
211
test/services/oidc_jwt_service_test.rb
Normal file
@@ -0,0 +1,211 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@user = users(:alice)
|
||||
@application = applications(:kavita_app)
|
||||
@service = OidcJwtService
|
||||
end
|
||||
|
||||
test "should generate id token with required claims" do
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
assert_not_nil token, "Should generate token"
|
||||
assert token.length > 100, "Token should be substantial"
|
||||
assert token.include?('.')
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
|
||||
assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject"
|
||||
assert_equal @user.email_address, decoded['email'], "Should have correct email"
|
||||
assert_equal true, decoded['email_verified'], "Should have email verified"
|
||||
assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username"
|
||||
assert_equal @user.email_address, decoded['name'], "Should have name"
|
||||
assert_equal "https://localhost:3000", decoded['iss'], "Should have correct issuer"
|
||||
assert_equal Time.now.to_i + 3600, decoded['exp'], "Should have correct expiration"
|
||||
end
|
||||
|
||||
test "should handle nonce in id token" do
|
||||
nonce = "test-nonce-12345"
|
||||
token = @service.generate_id_token(@user, @application, nonce: nonce)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
|
||||
assert_equal Time.now.to_i + 3600, decoded['exp'], "Should have correct expiration with nonce"
|
||||
end
|
||||
|
||||
test "should include groups in token when user has groups" do
|
||||
@user.groups << groups(:admin_group)
|
||||
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
assert_includes decoded['groups'], "admin", "Should include user's groups"
|
||||
end
|
||||
|
||||
test "should include admin claim for admin users" do
|
||||
@user.update!(admin: true)
|
||||
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
assert_equal true, decoded['admin'], "Admin users should have admin claim"
|
||||
end
|
||||
|
||||
test "should handle role-based claims when enabled" do
|
||||
@application.update!(
|
||||
role_mapping_enabled: true,
|
||||
role_mapping_mode: "oidc_managed",
|
||||
role_claim_name: "roles"
|
||||
)
|
||||
|
||||
@application.assign_role_to_user!(@user, "editor", source: 'oidc', metadata: { synced_at: Time.current })
|
||||
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
assert_includes decoded['roles'], "editor", "Should include user's role"
|
||||
end
|
||||
|
||||
test "should include role metadata when configured" do
|
||||
@application.update!(
|
||||
role_mapping_enabled: true,
|
||||
role_mapping_mode: "oidc_managed",
|
||||
parsed_managed_permissions: {
|
||||
"include_permissions" => true,
|
||||
"include_metadata" => true
|
||||
}
|
||||
)
|
||||
|
||||
role = @application.application_roles.create!(
|
||||
name: "editor",
|
||||
display_name: "Content Editor",
|
||||
permissions: ["read", "write"]
|
||||
)
|
||||
|
||||
@application.assign_role_to_user!(
|
||||
@user,
|
||||
"editor",
|
||||
source: 'oidc',
|
||||
metadata: {
|
||||
synced_at: Time.current,
|
||||
department: "Content Team",
|
||||
level: "2"
|
||||
}
|
||||
)
|
||||
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
assert_equal "Content Editor", decoded['role_display_name'], "Should include role display name"
|
||||
assert_includes decoded['role_permissions'], "read", "Should include read permission"
|
||||
assert_includes decoded['role_permissions'], "write", "Should include write permission"
|
||||
assert_equal "Content Team", decoded['role_department'], "Should include department"
|
||||
assert_equal "2", decoded['role_level'], "Should include level"
|
||||
end
|
||||
|
||||
test "should handle missing roles gracefully" do
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
refute_includes decoded, 'roles', "Should not have roles when not configured"
|
||||
end
|
||||
|
||||
test "should use RSA private key from environment" do
|
||||
ENV.stub(:fetch, "OIDC_PRIVATE_KEY") { "test-private-key" }
|
||||
|
||||
private_key = @service.private_key
|
||||
assert_equal "test-private-key", private_key.to_s, "Should use private key from environment"
|
||||
end
|
||||
|
||||
test "should generate RSA private key when missing" do
|
||||
ENV.stub(:fetch, nil) { nil }
|
||||
ENV.stub(:fetch, "OIDC_PRIVATE_KEY", nil) { nil }
|
||||
Rails.application.credentials.stub(:oidc_private_key, nil) { nil }
|
||||
|
||||
private_key = @service.private_key
|
||||
assert_not_nil private_key, "Should generate private key when missing"
|
||||
assert private_key.is_a?(OpenSSL::PKey::RSA), "Should generate RSA private key"
|
||||
assert_equal 2048, private_key.num_bits, "Should generate 2048-bit key"
|
||||
end
|
||||
|
||||
test "should get corresponding public key" do
|
||||
public_key = @service.public_key
|
||||
assert_not_nil public_key, "Should have public key"
|
||||
assert_equal "RSA", public_key.kty, "Should be RSA key"
|
||||
assert_equal 256, public_key.n, "Should be 256-bit key"
|
||||
end
|
||||
|
||||
test "should decode and verify id token" do
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
decoded = @service.decode_id_token(token)
|
||||
|
||||
assert_not_nil decoded, "Should decode valid token"
|
||||
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
||||
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
|
||||
assert decoded['exp'] > Time.current.to_i, "Token should not be expired"
|
||||
end
|
||||
|
||||
test "should reject invalid id tokens" do
|
||||
invalid_tokens = [
|
||||
"invalid.token",
|
||||
"header.payload.signature",
|
||||
"eyJ0",
|
||||
nil,
|
||||
"Bearer"
|
||||
]
|
||||
|
||||
invalid_tokens.each do |invalid_token|
|
||||
assert_raises(JWT::DecodeError) do
|
||||
@service.decode_id_token(invalid_token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "should handle expired tokens" do
|
||||
travel_to 2.hours.from_now do
|
||||
token = @service.generate_id_token(@user, @application, exp: 1.hour.from_now)
|
||||
travel_back
|
||||
|
||||
assert_raises(JWT::ExpiredSignature) do
|
||||
@service.decode_id_token(token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "should handle access token generation" do
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
decoded = JWT.decode(token, nil, true)
|
||||
refute_includes decoded.keys, 'email_verified'
|
||||
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
|
||||
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
|
||||
end
|
||||
|
||||
test "should handle JWT errors gracefully" do
|
||||
original_algorithm = OpenSSL::PKey::RSA::DEFAULT_PRIVATE_KEY
|
||||
|
||||
OpenSSL::PKey::RSA.stub(:new, -> { raise "Key generation failed" }) do
|
||||
OpenSSL::PKey::RSA.new(2048)
|
||||
end
|
||||
|
||||
assert_raises(RuntimeError, message: /Key generation failed/) do
|
||||
@service.private_key
|
||||
end
|
||||
|
||||
OpenSSL::PKey::RSA.stub(:new, original_algorithm) do
|
||||
restored_key = @service.private_key
|
||||
assert_not_equal original_algorithm, restored_key, "Should restore after error"
|
||||
end
|
||||
end
|
||||
|
||||
test "should validate JWT configuration" do
|
||||
@application.update!(client_id: "test-client")
|
||||
|
||||
error = assert_raises(StandardError, message: /no key found/) do
|
||||
@service.generate_id_token(@user, @application)
|
||||
end
|
||||
assert_match /no key found/, error.message, "Should warn about missing private key"
|
||||
end
|
||||
end
|
||||
end
|
||||
96
test/simple_role_test.rb
Normal file
96
test/simple_role_test.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# Simple test script to verify role mapping functionality
|
||||
# Run with: ruby test/simple_role_test.rb
|
||||
|
||||
require_relative "../config/environment"
|
||||
|
||||
puts "🧪 Testing OIDC Role Mapping functionality..."
|
||||
|
||||
begin
|
||||
# Create test user
|
||||
user = User.create!(
|
||||
email_address: "test#{Time.current.to_i}@example.com",
|
||||
password: "password123",
|
||||
admin: false,
|
||||
status: :active
|
||||
)
|
||||
puts "✅ Created test user: #{user.email_address}"
|
||||
|
||||
# Create test application
|
||||
application = Application.create!(
|
||||
name: "Test Role App",
|
||||
slug: "test-role-app-#{Time.current.to_i}",
|
||||
app_type: "oidc",
|
||||
role_mapping_mode: "oidc_managed"
|
||||
)
|
||||
puts "✅ Created test application: #{application.name}"
|
||||
|
||||
# Create role
|
||||
role = application.application_roles.create!(
|
||||
name: "admin",
|
||||
display_name: "Administrator",
|
||||
description: "Full access role"
|
||||
)
|
||||
puts "✅ Created role: #{role.name}"
|
||||
|
||||
# Test role assignment
|
||||
application.assign_role_to_user!(user, "admin", source: 'manual')
|
||||
puts "✅ Assigned role to user"
|
||||
|
||||
# Verify role assignment
|
||||
unless application.user_has_role?(user, "admin")
|
||||
raise "Role should be assigned to user"
|
||||
end
|
||||
puts "✅ Verified role assignment"
|
||||
|
||||
# Test role mapping engine
|
||||
claims = { "roles" => ["admin", "editor"] }
|
||||
RoleMappingEngine.sync_user_roles!(user, application, claims)
|
||||
puts "✅ Synced roles from OIDC claims"
|
||||
|
||||
# Test JWT generation with roles
|
||||
token = OidcJwtService.generate_id_token(user, application)
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
unless decoded["roles"]&.include?("admin")
|
||||
raise "JWT should contain roles"
|
||||
end
|
||||
puts "✅ JWT includes roles claim"
|
||||
|
||||
# Test custom claim name
|
||||
application.update!(role_claim_name: "user_roles")
|
||||
token = OidcJwtService.generate_id_token(user, application)
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
unless decoded["user_roles"]&.include?("admin")
|
||||
raise "JWT should use custom claim name"
|
||||
end
|
||||
puts "✅ Custom claim name works"
|
||||
|
||||
# Test role prefix filtering
|
||||
application.update!(role_prefix: "app-")
|
||||
role.update!(name: "app-admin")
|
||||
application.assign_role_to_user!(user, "app-admin", source: 'manual')
|
||||
|
||||
claims = { "roles" => ["app-admin", "external-role"] }
|
||||
RoleMappingEngine.sync_user_roles!(user, application, claims)
|
||||
unless application.user_has_role?(user, "app-admin")
|
||||
raise "Prefixed role should be assigned"
|
||||
end
|
||||
if application.user_has_role?(user, "external-role")
|
||||
raise "Non-prefixed role should be filtered"
|
||||
end
|
||||
puts "✅ Role prefix filtering works"
|
||||
|
||||
# Cleanup
|
||||
user.destroy
|
||||
application.destroy
|
||||
puts "🧹 Cleaned up test data"
|
||||
|
||||
puts "\n🎉 All tests passed! OIDC Role Mapping is working correctly."
|
||||
|
||||
rescue => e
|
||||
puts "❌ Test failed: #{e.message}"
|
||||
puts e.backtrace.first(5)
|
||||
exit 1
|
||||
end
|
||||
|
||||
398
test/system/forward_auth_system_test.rb
Normal file
398
test/system/forward_auth_system_test.rb
Normal file
@@ -0,0 +1,398 @@
|
||||
require "test_helper"
|
||||
|
||||
class ForwardAuthSystemTest < ActionDispatch::SystemTestCase
|
||||
driven_by :rack_test
|
||||
|
||||
setup do
|
||||
@user = users(:one)
|
||||
@admin_user = users(:two)
|
||||
@group = groups(:one)
|
||||
@group2 = groups(:two)
|
||||
end
|
||||
|
||||
# End-to-End Authentication Flow Tests
|
||||
test "complete forward auth flow with default headers" do
|
||||
# Create a rule with default headers
|
||||
rule = ForwardAuthRule.create!(domain_pattern: "app.example.com", active: true)
|
||||
|
||||
# Step 1: Unauthenticated request to protected resource
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "app.example.com",
|
||||
"X-Forwarded-Uri" => "/dashboard"
|
||||
}, params: { rd: "https://app.example.com/dashboard" }
|
||||
|
||||
assert_response 302
|
||||
location = response.location
|
||||
assert_match %r{/signin}, location
|
||||
assert_match %r{rd=https://app.example.com/dashboard}, location
|
||||
|
||||
# Step 2: Extract return URL from session
|
||||
assert_equal "https://app.example.com/dashboard", session[:return_to_after_authenticating]
|
||||
|
||||
# Step 3: Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
|
||||
assert_response 302
|
||||
assert_redirected_to "https://app.example.com/dashboard"
|
||||
|
||||
# Step 4: Authenticated request to protected resource
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
assert_equal @user.email_address, response.headers["X-Remote-Email"]
|
||||
assert_equal "false", response.headers["X-Remote-Admin"] unless @user.admin?
|
||||
end
|
||||
|
||||
test "multiple domain access with single session" do
|
||||
# Create rules for different applications
|
||||
app_rule = ForwardAuthRule.create!(domain_pattern: "app.example.com", active: true)
|
||||
grafana_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "grafana.example.com",
|
||||
active: true,
|
||||
headers_config: { user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL" }
|
||||
)
|
||||
metube_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "metube.example.com",
|
||||
active: true,
|
||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
|
||||
)
|
||||
|
||||
# Sign in once
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
assert_response 302
|
||||
assert_redirected_to "/"
|
||||
|
||||
# Test access to different applications
|
||||
# App with default headers
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" }
|
||||
assert_response 200
|
||||
assert_equal "X-Remote-User", response.headers.keys.find { |k| k.include?("User") }
|
||||
|
||||
# Grafana with custom headers
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "grafana.example.com" }
|
||||
assert_response 200
|
||||
assert_equal "X-WEBAUTH-USER", response.headers.keys.find { |k| k.include?("USER") }
|
||||
|
||||
# Metube with no headers
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "metube.example.com" }
|
||||
assert_response 200
|
||||
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||
assert_empty auth_headers
|
||||
end
|
||||
|
||||
# Group-Based Access Control System Tests
|
||||
test "group-based access control with multiple groups" do
|
||||
# Create restricted rule
|
||||
restricted_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "admin.example.com",
|
||||
active: true
|
||||
)
|
||||
restricted_rule.allowed_groups << @group
|
||||
restricted_rule.allowed_groups << @group2
|
||||
|
||||
# Add user to first group only
|
||||
@user.groups << @group
|
||||
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
assert_response 302
|
||||
|
||||
# Should have access (in allowed group)
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||
assert_response 200
|
||||
assert_equal @group.name, response.headers["X-Remote-Groups"]
|
||||
|
||||
# Add user to second group
|
||||
@user.groups << @group2
|
||||
|
||||
# Should show multiple groups
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||
assert_response 200
|
||||
groups_header = response.headers["X-Remote-Groups"]
|
||||
assert_includes groups_header, @group.name
|
||||
assert_includes groups_header, @group2.name
|
||||
|
||||
# Remove user from all groups
|
||||
@user.groups.clear
|
||||
|
||||
# Should be denied
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "admin.example.com" }
|
||||
assert_response 403
|
||||
end
|
||||
|
||||
test "bypass mode when no groups assigned to rule" do
|
||||
# Create bypass rule (no groups)
|
||||
bypass_rule = ForwardAuthRule.create!(
|
||||
domain_pattern: "public.example.com",
|
||||
active: true
|
||||
)
|
||||
|
||||
# Create user with no groups
|
||||
@user.groups.clear
|
||||
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
assert_response 302
|
||||
|
||||
# Should have access (bypass mode)
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "public.example.com" }
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
# Security System Tests
|
||||
test "session security and isolation" do
|
||||
# User A signs in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
user_a_session = cookies[:session_id]
|
||||
|
||||
# User B signs in
|
||||
delete "/session"
|
||||
post "/signin", params: { email_address: @admin_user.email_address, password: "password" }
|
||||
user_b_session = cookies[:session_id]
|
||||
|
||||
# User A should still be able to access resources
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=#{user_a_session}"
|
||||
}
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
|
||||
# User B should be able to access resources
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "test.example.com",
|
||||
"Cookie" => "_clinch_session_id=#{user_b_session}"
|
||||
}
|
||||
assert_response 200
|
||||
assert_equal @admin_user.email_address, response.headers["X-Remote-User"]
|
||||
|
||||
# Sessions should be independent
|
||||
assert_not_equal user_a_session, user_b_session
|
||||
end
|
||||
|
||||
test "session expiration and cleanup" do
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
session_id = cookies[:session_id]
|
||||
|
||||
# Should work initially
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
assert_response 200
|
||||
|
||||
# Manually expire session
|
||||
session = Session.find(session_id)
|
||||
session.update!(created_at: 1.year.ago)
|
||||
|
||||
# Should redirect to login
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
assert_response 302
|
||||
assert_equal "Session expired", response.headers["X-Auth-Reason"]
|
||||
|
||||
# Session should be cleaned up
|
||||
assert_nil Session.find_by(id: session_id)
|
||||
end
|
||||
|
||||
test "concurrent access with rate limiting considerations" do
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
session_cookie = cookies[:session_id]
|
||||
|
||||
# Simulate multiple concurrent requests from different IPs
|
||||
threads = []
|
||||
results = []
|
||||
|
||||
10.times do |i|
|
||||
threads << Thread.new do
|
||||
start_time = Time.current
|
||||
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "app#{i}.example.com",
|
||||
"X-Forwarded-For" => "192.168.1.#{100 + i}",
|
||||
"Cookie" => "_clinch_session_id=#{session_cookie}"
|
||||
}
|
||||
|
||||
end_time = Time.current
|
||||
|
||||
results << {
|
||||
thread_id: i,
|
||||
status: response.status,
|
||||
user: response.headers["X-Remote-User"],
|
||||
duration: end_time - start_time
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# All requests should succeed
|
||||
results.each do |result|
|
||||
assert_equal 200, result[:status], "Thread #{result[:thread_id]} failed"
|
||||
assert_equal @user.email_address, result[:user], "Thread #{result[:thread_id]} has wrong user"
|
||||
assert result[:duration] < 1.0, "Thread #{result[:thread_id]} was too slow"
|
||||
end
|
||||
end
|
||||
|
||||
# Complex Scenario System Tests
|
||||
test "complex multi-application scenario" do
|
||||
# Setup multiple applications with different requirements
|
||||
apps = [
|
||||
{
|
||||
domain: "dashboard.example.com",
|
||||
headers_config: { user: "X-DASHBOARD-USER", groups: "X-DASHBOARD-GROUPS" },
|
||||
groups: [@group]
|
||||
},
|
||||
{
|
||||
domain: "api.example.com",
|
||||
headers_config: { user: "X-API-USER", email: "X-API-EMAIL" },
|
||||
groups: []
|
||||
},
|
||||
{
|
||||
domain: "logs.example.com",
|
||||
headers_config: { user: "", email: "", name: "", groups: "", admin: "" },
|
||||
groups: []
|
||||
}
|
||||
]
|
||||
|
||||
# Create rules for each app
|
||||
rules = apps.map do |app|
|
||||
rule = ForwardAuthRule.create!(
|
||||
domain_pattern: app[:domain],
|
||||
active: true,
|
||||
headers_config: app[:headers_config]
|
||||
)
|
||||
app[:groups].each { |group| rule.allowed_groups << group }
|
||||
rule
|
||||
end
|
||||
|
||||
# Add user to required groups
|
||||
@user.groups << @group
|
||||
|
||||
# Sign in once
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
assert_response 302
|
||||
|
||||
# Test access to each application
|
||||
apps.each do |app|
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => app[:domain] }
|
||||
assert_response 200, "Failed for #{app[:domain]}"
|
||||
|
||||
# Verify headers are correct
|
||||
if app[:headers_config][:user].present?
|
||||
assert_equal app[:headers_config][:user],
|
||||
response.headers.keys.find { |k| k.include?("USER") },
|
||||
"Wrong user header for #{app[:domain]}"
|
||||
assert_equal @user.email_address, response.headers[app[:headers_config][:user]]
|
||||
else
|
||||
# Should have no auth headers
|
||||
auth_headers = response.headers.select { |k, v| k.match?(/^(X-|Remote-)/i) }
|
||||
assert_empty auth_headers, "Should have no headers for #{app[:domain]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "domain pattern edge cases" do
|
||||
# Test various domain patterns
|
||||
patterns = [
|
||||
{ pattern: "*.example.com", domains: ["app.example.com", "api.example.com", "sub.app.example.com"] },
|
||||
{ pattern: "api.*.com", domains: ["api.example.com", "api.test.com"] },
|
||||
{ pattern: "*.*.example.com", domains: ["app.dev.example.com", "api.staging.example.com"] }
|
||||
]
|
||||
|
||||
patterns.each do |pattern_config|
|
||||
rule = ForwardAuthRule.create!(
|
||||
domain_pattern: pattern_config[:pattern],
|
||||
active: true
|
||||
)
|
||||
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
|
||||
# Test each domain
|
||||
pattern_config[:domains].each do |domain|
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => domain }
|
||||
assert_response 200, "Failed for pattern #{pattern_config[:pattern]} with domain #{domain}"
|
||||
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||
end
|
||||
|
||||
# Clean up for next test
|
||||
delete "/session"
|
||||
end
|
||||
end
|
||||
|
||||
# Performance System Tests
|
||||
test "system performance under load" do
|
||||
# Create test rule
|
||||
rule = ForwardAuthRule.create!(domain_pattern: "loadtest.example.com", active: true)
|
||||
|
||||
# Sign in
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
session_cookie = cookies[:session_id]
|
||||
|
||||
# Performance test
|
||||
start_time = Time.current
|
||||
request_count = 50
|
||||
results = []
|
||||
|
||||
request_count.times do |i|
|
||||
request_start = Time.current
|
||||
|
||||
get "/api/verify", headers: {
|
||||
"X-Forwarded-Host" => "app#{i}.loadtest.example.com",
|
||||
"Cookie" => "_clinch_session_id=#{session_cookie}"
|
||||
}
|
||||
|
||||
request_end = Time.current
|
||||
|
||||
results << {
|
||||
request_id: i,
|
||||
status: response.status,
|
||||
duration: request_end - request_start
|
||||
}
|
||||
end
|
||||
|
||||
total_time = Time.current - start_time
|
||||
average_duration = results.map { |r| r[:duration] }.sum / request_count
|
||||
|
||||
# Performance assertions
|
||||
assert total_time < 5.0, "Total time #{total_time}s is too slow"
|
||||
assert average_duration < 0.1, "Average request time #{average_duration}s is too slow"
|
||||
assert results.all? { |r| r[:status] == 200 }, "Some requests failed"
|
||||
|
||||
# Calculate requests per second
|
||||
rps = request_count / total_time
|
||||
assert rps > 10, "Requests per second #{rps} is too low"
|
||||
end
|
||||
|
||||
# Error Recovery System Tests
|
||||
test "graceful degradation with database issues" do
|
||||
# Sign in first
|
||||
post "/signin", params: { email_address: @user.email_address, password: "password" }
|
||||
assert_response 302
|
||||
|
||||
# Simulate database connection issue by mocking
|
||||
original_method = Session.method(:find_by)
|
||||
|
||||
# Mock database failure
|
||||
Session.define_singleton_method(:find_by) do |id|
|
||||
raise ActiveRecord::ConnectionNotEstablished, "Database connection lost"
|
||||
end
|
||||
|
||||
begin
|
||||
# Request should handle the error gracefully
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
|
||||
# Should return 302 (redirect to login) rather than 500 error
|
||||
assert_response 302, "Should gracefully handle database issues"
|
||||
assert_equal "Invalid session", response.headers["X-Auth-Reason"]
|
||||
ensure
|
||||
# Restore original method
|
||||
Session.define_singleton_method(:find_by, original_method)
|
||||
end
|
||||
|
||||
# Normal operation should still work
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||
assert_response 200
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user