Compare commits
40 Commits
2025.01
...
baa75a3456
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa75a3456 | ||
|
|
c3205abffa | ||
|
|
a2008d0750 | ||
|
|
810561d74b | ||
|
|
2ee895888d | ||
|
|
6c9fc429f1 | ||
|
|
7d200b849e | ||
|
|
7074242907 | ||
|
|
da6fd5b800 | ||
|
|
cfab21b130 | ||
|
|
c80bcafdb7 | ||
|
|
f050541e14 | ||
|
|
431e947a4c | ||
|
|
8dd3e60071 | ||
|
|
e4e7a0873e | ||
|
|
b5b1d94d47 | ||
|
|
52cfd6122c | ||
|
|
87796e0478 | ||
|
|
227e29ce0a | ||
|
|
d98f777e7d | ||
|
|
88428bfd97 | ||
|
|
2679634a2b | ||
|
|
2d5823213c | ||
|
|
5921cf82c2 | ||
|
|
df834b6e57 | ||
|
|
471c16890b | ||
|
|
39757a43dc | ||
|
|
5463723455 | ||
|
|
e36850f8ba | ||
|
|
0af3dbefed | ||
|
|
d6c24e50df | ||
|
|
8c80343b89 | ||
|
|
2db7f6a9df | ||
|
|
e3f202f574 | ||
|
|
c7f391541a | ||
|
|
8e56210b74 | ||
|
|
056c69e002 | ||
|
|
225b6b0bb6 | ||
|
|
fbda018065 | ||
|
|
12e0ef66ed |
2
.github/workflows/ci.yml
vendored
@@ -116,7 +116,7 @@ jobs:
|
|||||||
run: bin/rails db:test:prepare test:system
|
run: bin/rails db:test:prepare test:system
|
||||||
|
|
||||||
- name: Keep screenshots from failed system tests
|
- name: Keep screenshots from failed system tests
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: screenshots
|
name: screenshots
|
||||||
|
|||||||
13
Gemfile
@@ -26,17 +26,19 @@ gem "bcrypt", "~> 3.1.7"
|
|||||||
gem "rotp", "~> 6.3"
|
gem "rotp", "~> 6.3"
|
||||||
|
|
||||||
# QR code generation for TOTP setup
|
# QR code generation for TOTP setup
|
||||||
gem "rqrcode", "~> 2.0"
|
gem "rqrcode", "~> 3.1"
|
||||||
|
|
||||||
# JWT for OIDC ID tokens
|
# JWT for OIDC ID tokens
|
||||||
gem "jwt", "~> 2.9"
|
gem "jwt", "~> 3.1"
|
||||||
|
|
||||||
|
# Public Suffix List for domain parsing
|
||||||
|
gem "public_suffix", "~> 6.0"
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
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_cache"
|
||||||
gem "solid_queue"
|
|
||||||
gem "solid_cable"
|
gem "solid_cable"
|
||||||
|
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
@@ -68,6 +70,9 @@ end
|
|||||||
group :development do
|
group :development do
|
||||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||||
gem "web-console"
|
gem "web-console"
|
||||||
|
|
||||||
|
# Preview emails in browser instead of sending them
|
||||||
|
gem "letter_opener"
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
|
|||||||
40
Gemfile.lock
@@ -100,6 +100,8 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
|
childprocess (5.1.0)
|
||||||
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.4)
|
||||||
@@ -113,8 +115,6 @@ GEM
|
|||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (5.1.1)
|
erb (5.1.1)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
|
||||||
tzinfo
|
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-aarch64-linux-musl)
|
ffi (1.17.2-aarch64-linux-musl)
|
||||||
ffi (1.17.2-arm-linux-gnu)
|
ffi (1.17.2-arm-linux-gnu)
|
||||||
@@ -122,9 +122,6 @@ GEM
|
|||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi (1.17.2-x86_64-linux-musl)
|
ffi (1.17.2-x86_64-linux-musl)
|
||||||
fugit (1.12.1)
|
|
||||||
et-orbi (~> 1.4)
|
|
||||||
raabro (~> 1.4)
|
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
@@ -145,7 +142,7 @@ GEM
|
|||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.15.1)
|
json (2.15.1)
|
||||||
jwt (2.10.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.8.1)
|
kamal (2.8.1)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
@@ -159,6 +156,12 @@ GEM
|
|||||||
thor (~> 1.3)
|
thor (~> 1.3)
|
||||||
zeitwerk (>= 2.6.18, < 3.0)
|
zeitwerk (>= 2.6.18, < 3.0)
|
||||||
language_server-protocol (3.17.0.5)
|
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)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.24.1)
|
loofah (2.24.1)
|
||||||
@@ -225,7 +228,6 @@ GEM
|
|||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
raabro (1.4.0)
|
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.3)
|
||||||
rack-session (2.1.1)
|
rack-session (2.1.1)
|
||||||
@@ -276,10 +278,10 @@ GEM
|
|||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (2.2.0)
|
rqrcode (3.1.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 1.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (1.2.0)
|
rqrcode_core (2.0.0)
|
||||||
rubocop (1.81.6)
|
rubocop (1.81.6)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
@@ -312,9 +314,9 @@ GEM
|
|||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.2.0)
|
rubyzip (3.2.1)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.37.0)
|
selenium-webdriver (4.38.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
@@ -329,13 +331,6 @@ GEM
|
|||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 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-gnu)
|
||||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||||
sqlite3 (2.7.4-arm-linux-gnu)
|
sqlite3 (2.7.4-arm-linux-gnu)
|
||||||
@@ -414,18 +409,19 @@ DEPENDENCIES
|
|||||||
image_processing (~> 1.2)
|
image_processing (~> 1.2)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
jwt (~> 2.9)
|
jwt (~> 3.1)
|
||||||
kamal
|
kamal
|
||||||
|
letter_opener
|
||||||
propshaft
|
propshaft
|
||||||
|
public_suffix (~> 6.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.0)
|
rails (~> 8.1.0)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 2.0)
|
rqrcode (~> 3.1)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
solid_queue
|
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
tailwindcss-rails
|
tailwindcss-rails
|
||||||
|
|||||||
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.
|
||||||
44
README.md
@@ -1,5 +1,7 @@
|
|||||||
# Clinch
|
# 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**
|
**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.
|
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
||||||
@@ -18,6 +20,35 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### User Dashboard
|
||||||
|
[](docs/screenshots/0-dashboard.png)
|
||||||
|
|
||||||
|
### Sign In
|
||||||
|
[](docs/screenshots/1-signin.png)
|
||||||
|
|
||||||
|
### Sign In with 2FA
|
||||||
|
[](docs/screenshots/2-signin.png)
|
||||||
|
|
||||||
|
### Users Management
|
||||||
|
[](docs/screenshots/3-users.png)
|
||||||
|
|
||||||
|
### Welcome Screen
|
||||||
|
[](docs/screenshots/4-welcome.png)
|
||||||
|
|
||||||
|
### Welcome Setup
|
||||||
|
[](docs/screenshots/5-welcome-2.png)
|
||||||
|
|
||||||
|
### Setup 2FA
|
||||||
|
[](docs/screenshots/6-setup-2fa.png)
|
||||||
|
|
||||||
|
### Forward Auth Example 1
|
||||||
|
[](docs/screenshots/7-forward-auth-1.png)
|
||||||
|
|
||||||
|
### Forward Auth Example 2
|
||||||
|
[](docs/screenshots/8-forward-auth-2.png)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### User Management
|
### User Management
|
||||||
@@ -167,7 +198,7 @@ bin/dev
|
|||||||
docker build -t clinch .
|
docker build -t clinch .
|
||||||
|
|
||||||
# Run container
|
# Run container
|
||||||
docker run -p 9000:9000 \
|
docker run -p 3000:3000 \
|
||||||
-v clinch-storage:/rails/storage \
|
-v clinch-storage:/rails/storage \
|
||||||
-e SECRET_KEY_BASE=your-secret-key \
|
-e SECRET_KEY_BASE=your-secret-key \
|
||||||
-e SMTP_ADDRESS=smtp.example.com \
|
-e SMTP_ADDRESS=smtp.example.com \
|
||||||
@@ -208,7 +239,7 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
|||||||
```
|
```
|
||||||
|
|
||||||
### First Run
|
### 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
|
2. First-run wizard creates initial admin user
|
||||||
3. Admin can then:
|
3. Admin can then:
|
||||||
- Create groups
|
- Create groups
|
||||||
@@ -227,12 +258,14 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
|||||||
- First-run wizard
|
- First-run wizard
|
||||||
|
|
||||||
### Planned Features
|
### Planned Features
|
||||||
|
- **Audit logging** - Track all authentication events
|
||||||
|
- **WebAuthn/Passkeys** - Hardware key support
|
||||||
|
|
||||||
|
#### Maybe
|
||||||
- **SAML support** - SAML 2.0 identity provider
|
- **SAML support** - SAML 2.0 identity provider
|
||||||
- **Policy engine** - Rule-based access control
|
- **Policy engine** - Rule-based access control
|
||||||
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
||||||
- Stored as JSON, evaluated after auth but before consent
|
- 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
|
- **LDAP sync** - Import users from LDAP/Active Directory
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -250,5 +283,4 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class ApplicationsController < BaseController
|
class ApplicationsController < BaseController
|
||||||
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
|
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials, :roles, :create_role, :update_role, :assign_role, :remove_role]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@applications = Application.order(created_at: :desc)
|
@applications = Application.order(created_at: :desc)
|
||||||
@@ -17,6 +17,7 @@ module Admin
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@application = Application.new(application_params)
|
@application = Application.new(application_params)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
|
|
||||||
if @application.save
|
if @application.save
|
||||||
# Handle group assignments
|
# Handle group assignments
|
||||||
@@ -25,9 +26,22 @@ module Admin
|
|||||||
@application.allowed_groups = Group.where(id: group_ids)
|
@application.allowed_groups = Group.where(id: group_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to admin_application_path(@application), notice: "Application created successfully."
|
# Get the plain text client secret to show one time
|
||||||
|
client_secret = nil
|
||||||
|
if @application.oidc?
|
||||||
|
client_secret = @application.generate_new_client_secret!
|
||||||
|
end
|
||||||
|
|
||||||
|
if @application.oidc? && client_secret
|
||||||
|
flash[:notice] = "Application created successfully."
|
||||||
|
flash[:client_id] = @application.client_id
|
||||||
|
flash[:client_secret] = client_secret
|
||||||
|
else
|
||||||
|
flash[:notice] = "Application created successfully."
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to admin_application_path(@application)
|
||||||
else
|
else
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -60,16 +74,69 @@ module Admin
|
|||||||
|
|
||||||
def regenerate_credentials
|
def regenerate_credentials
|
||||||
if @application.oidc?
|
if @application.oidc?
|
||||||
@application.update!(
|
# Generate new client ID and secret
|
||||||
client_id: SecureRandom.urlsafe_base64(32),
|
new_client_id = SecureRandom.urlsafe_base64(32)
|
||||||
client_secret: SecureRandom.urlsafe_base64(48)
|
client_secret = @application.generate_new_client_secret!
|
||||||
)
|
|
||||||
redirect_to admin_application_path(@application), notice: "Credentials regenerated successfully. Make sure to update your application configuration."
|
@application.update!(client_id: new_client_id)
|
||||||
|
|
||||||
|
flash[:notice] = "Credentials regenerated successfully."
|
||||||
|
flash[:client_id] = @application.client_id
|
||||||
|
flash[:client_secret] = client_secret
|
||||||
|
|
||||||
|
redirect_to admin_application_path(@application)
|
||||||
else
|
else
|
||||||
redirect_to admin_application_path(@application), alert: "Only OIDC applications have credentials."
|
redirect_to admin_application_path(@application), alert: "Only OIDC applications have credentials."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def roles
|
||||||
|
@application_roles = @application.application_roles.includes(:user_role_assignments)
|
||||||
|
@available_users = User.active.order(:email_address)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_role
|
||||||
|
@role = @application.application_roles.build(role_params)
|
||||||
|
|
||||||
|
if @role.save
|
||||||
|
redirect_to roles_admin_application_path(@application), notice: "Role created successfully."
|
||||||
|
else
|
||||||
|
@application_roles = @application.application_roles.includes(:user_role_assignments)
|
||||||
|
@available_users = User.active.order(:email_address)
|
||||||
|
render :roles, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_role
|
||||||
|
@role = @application.application_roles.find(params[:role_id])
|
||||||
|
|
||||||
|
if @role.update(role_params)
|
||||||
|
redirect_to roles_admin_application_path(@application), notice: "Role updated successfully."
|
||||||
|
else
|
||||||
|
@application_roles = @application.application_roles.includes(:user_role_assignments)
|
||||||
|
@available_users = User.active.order(:email_address)
|
||||||
|
render :roles, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_role
|
||||||
|
user = User.find(params[:user_id])
|
||||||
|
role = @application.application_roles.find(params[:role_id])
|
||||||
|
|
||||||
|
@application.assign_role_to_user!(user, role.name, source: 'manual')
|
||||||
|
|
||||||
|
redirect_to roles_admin_application_path(@application), notice: "Role assigned successfully."
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_role
|
||||||
|
user = User.find(params[:user_id])
|
||||||
|
role = @application.application_roles.find(params[:role_id])
|
||||||
|
|
||||||
|
@application.remove_role_from_user!(user, role.name)
|
||||||
|
|
||||||
|
redirect_to roles_admin_application_path(@application), notice: "Role removed successfully."
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_application
|
def set_application
|
||||||
@@ -77,7 +144,14 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def application_params
|
def application_params
|
||||||
params.require(:application).permit(:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata)
|
params.require(:application).permit(
|
||||||
|
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||||
|
:role_mapping_mode, :role_prefix, :role_claim_name, managed_permissions: {}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def role_params
|
||||||
|
params.require(:application_role).permit(:name, :display_name, :description, :active, permissions: {})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ module Admin
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
|
@forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
|
||||||
|
# Handle headers configuration
|
||||||
|
@forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
|
||||||
|
|
||||||
if @forward_auth_rule.save
|
if @forward_auth_rule.save
|
||||||
# Handle group assignments
|
# Handle group assignments
|
||||||
@@ -38,6 +40,10 @@ module Admin
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
if @forward_auth_rule.update(forward_auth_rule_params)
|
if @forward_auth_rule.update(forward_auth_rule_params)
|
||||||
|
# Handle headers configuration
|
||||||
|
@forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
|
||||||
|
@forward_auth_rule.save!
|
||||||
|
|
||||||
# Handle group assignments
|
# Handle group assignments
|
||||||
if params[:forward_auth_rule][:group_ids].present?
|
if params[:forward_auth_rule][:group_ids].present?
|
||||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||||
@@ -67,5 +73,12 @@ module Admin
|
|||||||
def forward_auth_rule_params
|
def forward_auth_rule_params
|
||||||
params.require(:forward_auth_rule).permit(:domain_pattern, :active)
|
params.require(:forward_auth_rule).permit(:domain_pattern, :active)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_headers_config(headers_params)
|
||||||
|
return {} unless headers_params.is_a?(Hash)
|
||||||
|
|
||||||
|
# Clean up headers config - remove empty values, keep only filled ones
|
||||||
|
headers_params.select { |key, value| value.present? }.symbolize_keys
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class UsersController < BaseController
|
class UsersController < BaseController
|
||||||
before_action :set_user, only: [:show, :edit, :update, :destroy]
|
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@users = User.order(created_at: :desc)
|
@users = User.order(created_at: :desc)
|
||||||
@@ -16,9 +16,11 @@ module Admin
|
|||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||||
|
@user.status = :pending_invitation
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
redirect_to admin_users_path, notice: "User created successfully."
|
InvitationsMailer.invite_user(@user).deliver_later
|
||||||
|
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||||
else
|
else
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
@@ -46,6 +48,16 @@ module Admin
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resend_invitation
|
||||||
|
unless @user.pending_invitation?
|
||||||
|
redirect_to admin_users_path, alert: "Cannot send invitation. User is not pending invitation."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
InvitationsMailer.invite_user(@user).deliver_later
|
||||||
|
redirect_to admin_users_path, notice: "Invitation email resent to #{@user.email_address}."
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
# Prevent admin from deleting themselves
|
# Prevent admin from deleting themselves
|
||||||
if @user == Current.session.user
|
if @user == Current.session.user
|
||||||
|
|||||||
@@ -10,15 +10,19 @@ module Api
|
|||||||
def verify
|
def verify
|
||||||
# Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
|
# Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
|
||||||
|
|
||||||
# Get the session from cookie
|
# Check for one-time forward auth token first (to handle race condition)
|
||||||
session_id = extract_session_id
|
session_id = check_forward_auth_token
|
||||||
|
|
||||||
|
# If no token found, try to get session from cookie
|
||||||
|
session_id ||= extract_session_id
|
||||||
|
|
||||||
unless session_id
|
unless session_id
|
||||||
# No session cookie - user is not authenticated
|
# No session cookie or token - user is not authenticated
|
||||||
return render_unauthorized("No session cookie")
|
return render_unauthorized("No session cookie")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the session
|
# Find the session with user association (eager loading for performance)
|
||||||
session = Session.find_by(id: session_id)
|
session = Session.includes(:user).find_by(id: session_id)
|
||||||
unless session
|
unless session
|
||||||
# Invalid session
|
# Invalid session
|
||||||
return render_unauthorized("Invalid session")
|
return render_unauthorized("Invalid session")
|
||||||
@@ -30,10 +34,10 @@ module Api
|
|||||||
return render_unauthorized("Session expired")
|
return render_unauthorized("Session expired")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Update last activity
|
# Update last activity (skip validations for performance)
|
||||||
session.update_column(:last_activity_at, Time.current)
|
session.update_column(:last_activity_at, Time.current)
|
||||||
|
|
||||||
# Get the user
|
# Get the user (already loaded via includes(:user))
|
||||||
user = session.user
|
user = session.user
|
||||||
unless user.active?
|
unless user.active?
|
||||||
return render_unauthorized("User account is not active")
|
return render_unauthorized("User account is not active")
|
||||||
@@ -44,49 +48,81 @@ module Api
|
|||||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||||
|
|
||||||
if forwarded_host.present?
|
if forwarded_host.present?
|
||||||
|
# Load active rules with their associations for better performance
|
||||||
|
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||||
|
rules = ForwardAuthRule.includes(:allowed_groups).active
|
||||||
|
|
||||||
# Find matching forward auth rule for this domain
|
# Find matching forward auth rule for this domain
|
||||||
rule = ForwardAuthRule.active.find { |r| r.matches_domain?(forwarded_host) }
|
rule = rules.find { |r| r.matches_domain?(forwarded_host) }
|
||||||
|
|
||||||
unless rule
|
if rule
|
||||||
Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
|
# Check if user is allowed by this rule
|
||||||
return render_forbidden("No authentication rule configured for this domain")
|
unless rule.user_allowed?(user)
|
||||||
|
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by rule #{rule.domain_pattern}"
|
||||||
|
return render_forbidden("You do not have permission to access this domain")
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by rule #{rule.domain_pattern} (policy: #{rule.policy_for_user(user)})"
|
||||||
|
else
|
||||||
|
# No rule found - allow access with default headers (original behavior)
|
||||||
|
Rails.logger.info "ForwardAuth: No rule found for domain: #{forwarded_host}, allowing with default headers"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if user is allowed by this rule
|
|
||||||
unless rule.user_allowed?(user)
|
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by rule #{rule.domain_pattern}"
|
|
||||||
return render_forbidden("You do not have permission to access this domain")
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by rule #{rule.domain_pattern} (policy: #{rule.policy_for_user(user)})"
|
|
||||||
else
|
else
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||||
end
|
end
|
||||||
|
|
||||||
# User is authenticated and authorized
|
# User is authenticated and authorized
|
||||||
# Return 200 with user information headers
|
# Return 200 with user information headers using rule-specific configuration
|
||||||
response.headers["Remote-User"] = user.email_address
|
headers = rule ? rule.headers_for_user(user) : ForwardAuthRule::DEFAULT_HEADERS.map { |key, header_name|
|
||||||
response.headers["Remote-Email"] = user.email_address
|
case key
|
||||||
response.headers["Remote-Name"] = user.email_address
|
when :user, :email, :name
|
||||||
|
[header_name, user.email_address]
|
||||||
|
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
|
headers.each { |key, value| response.headers[key] = value }
|
||||||
if user.groups.any?
|
|
||||||
response.headers["Remote-Groups"] = user.groups.pluck(:name).join(",")
|
# 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
|
end
|
||||||
|
|
||||||
# Add admin flag
|
|
||||||
response.headers["Remote-Admin"] = user.admin? ? "true" : "false"
|
|
||||||
|
|
||||||
# Return 200 OK with no body
|
# Return 200 OK with no body
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def check_forward_auth_token
|
||||||
|
# Check for one-time token in query parameters (for race condition handling)
|
||||||
|
token = params[:fa_token]
|
||||||
|
return nil unless token.present?
|
||||||
|
|
||||||
|
# Try to get session ID from cache
|
||||||
|
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
||||||
|
return nil unless session_id
|
||||||
|
|
||||||
|
# Verify the session exists and is valid
|
||||||
|
session = Session.find_by(id: session_id)
|
||||||
|
return nil unless session && !session.expired?
|
||||||
|
|
||||||
|
# Delete the token immediately (one-time use)
|
||||||
|
Rails.cache.delete("forward_auth_token:#{token}")
|
||||||
|
|
||||||
|
session_id
|
||||||
|
end
|
||||||
|
|
||||||
def extract_session_id
|
def extract_session_id
|
||||||
# Extract session ID from cookie
|
# Extract session ID from cookie
|
||||||
# Rails uses signed cookies by default
|
# Rails uses signed cookies by default
|
||||||
cookies.signed[:session_id]
|
session_id = cookies.signed[:session_id]
|
||||||
|
session_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_app_from_headers
|
def extract_app_from_headers
|
||||||
@@ -102,7 +138,8 @@ module Api
|
|||||||
response.headers["X-Auth-Reason"] = reason if reason
|
response.headers["X-Auth-Reason"] = reason if reason
|
||||||
|
|
||||||
# Get the redirect URL from query params or construct default
|
# Get the redirect URL from query params or construct default
|
||||||
base_url = params[:rd] || "https://clinch.aapamilne.com"
|
redirect_url = validate_redirect_url(params[:rd])
|
||||||
|
base_url = redirect_url || "https://clinch.aapamilne.com"
|
||||||
|
|
||||||
# Set the original URL that user was trying to access
|
# Set the original URL that user was trying to access
|
||||||
# This will be used after authentication
|
# This will be used after authentication
|
||||||
@@ -113,11 +150,11 @@ module Api
|
|||||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
||||||
|
|
||||||
original_url = if original_host
|
original_url = if original_host
|
||||||
# Use the forwarded host and URI
|
# Use the forwarded host and URI (original behavior)
|
||||||
"https://#{original_host}#{original_uri}"
|
"https://#{original_host}#{original_uri}"
|
||||||
else
|
else
|
||||||
# Fallback: just redirect to the root of the original host
|
# Fallback: use the validated redirect URL or default
|
||||||
"https://#{request.headers['Host']}"
|
redirect_url || "https://clinch.aapamilne.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Debug: log what we're redirecting to after login
|
# Debug: log what we're redirecting to after login
|
||||||
@@ -147,5 +184,40 @@ module Api
|
|||||||
# Return 403 Forbidden
|
# Return 403 Forbidden
|
||||||
head :forbidden
|
head :forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_redirect_url(url)
|
||||||
|
return nil unless url.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
uri = URI.parse(url)
|
||||||
|
|
||||||
|
# Only allow HTTP/HTTPS schemes
|
||||||
|
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
|
# Only allow HTTPS in production
|
||||||
|
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||||
|
|
||||||
|
redirect_domain = uri.host.downcase
|
||||||
|
return nil unless redirect_domain.present?
|
||||||
|
|
||||||
|
# Check against our ForwardAuthRules
|
||||||
|
matching_rule = ForwardAuthRule.active.find do |rule|
|
||||||
|
rule.matches_domain?(redirect_domain)
|
||||||
|
end
|
||||||
|
|
||||||
|
matching_rule ? url : nil
|
||||||
|
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def domain_has_forward_auth_rule?(domain)
|
||||||
|
return false if domain.blank?
|
||||||
|
|
||||||
|
ForwardAuthRule.active.any? do |rule|
|
||||||
|
rule.matches_domain?(domain.downcase)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
require 'uri'
|
||||||
|
require 'public_suffix'
|
||||||
|
require 'ipaddr'
|
||||||
|
|
||||||
module Authentication
|
module Authentication
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
@@ -31,14 +35,17 @@ module Authentication
|
|||||||
|
|
||||||
def request_authentication
|
def request_authentication
|
||||||
session[:return_to_after_authenticating] = request.url
|
session[:return_to_after_authenticating] = request.url
|
||||||
redirect_to new_session_path
|
redirect_to signin_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_authentication_url
|
def after_authentication_url
|
||||||
session.delete(:return_to_after_authenticating) || root_url
|
return_url = session[:return_to_after_authenticating]
|
||||||
|
final_url = session.delete(:return_to_after_authenticating) || root_url
|
||||||
|
final_url
|
||||||
end
|
end
|
||||||
|
|
||||||
def start_new_session_for(user)
|
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|
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||||
Current.session = session
|
Current.session = session
|
||||||
|
|
||||||
@@ -56,6 +63,10 @@ module Authentication
|
|||||||
cookie_options[:domain] = domain if domain.present?
|
cookie_options[:domain] = domain if domain.present?
|
||||||
|
|
||||||
cookies.signed.permanent[:session_id] = cookie_options
|
cookies.signed.permanent[:session_id] = cookie_options
|
||||||
|
|
||||||
|
# Create a one-time token for immediate forward auth after authentication
|
||||||
|
# This solves the race condition where browser hasn't processed cookie yet
|
||||||
|
create_forward_auth_token(session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -64,36 +75,72 @@ module Authentication
|
|||||||
cookies.delete(:session_id)
|
cookies.delete(:session_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extract root domain for cross-subdomain cookies
|
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||||
|
#
|
||||||
|
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||||
|
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||||
|
# both app.example.com and api.example.com).
|
||||||
|
#
|
||||||
|
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||||
|
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||||
|
# and setting a domain cookie would break authentication.
|
||||||
|
#
|
||||||
|
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||||
|
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||||
|
#
|
||||||
# Examples:
|
# Examples:
|
||||||
# - clinch.aapamilne.com -> .aapamilne.com
|
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||||
# - app.example.co.uk -> .example.co.uk
|
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||||
# - localhost -> nil (no domain setting for local development)
|
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||||
|
# - localhost -> nil (local development, no domain cookie)
|
||||||
|
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||||
|
#
|
||||||
|
# @param host [String] The request host (may include port)
|
||||||
|
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||||
def extract_root_domain(host)
|
def extract_root_domain(host)
|
||||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
# Split hostname into parts
|
# Strip port number for domain parsing
|
||||||
parts = host.split('.')
|
host_without_port = host.split(':').first
|
||||||
|
|
||||||
# For normal domains like example.com, we need at least 2 parts
|
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||||
# For complex domains like co.uk, we need at least 3 parts
|
return nil if IPAddr.new(host_without_port) rescue false
|
||||||
return nil if parts.length < 2
|
|
||||||
|
|
||||||
# Extract root domain with leading dot for cross-subdomain cookies
|
# Use Public Suffix List for accurate domain parsing
|
||||||
if parts.length >= 3
|
domain = PublicSuffix.parse(host_without_port)
|
||||||
# Check if it's a known complex TLD
|
".#{domain.domain}"
|
||||||
complex_tlds = %w[co.uk com.au co.nz co.za co.jp]
|
rescue PublicSuffix::DomainInvalid
|
||||||
second_level = "#{parts[-2]}.#{parts[-1]}"
|
# Fallback for invalid domains or IPs
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
if complex_tlds.include?(second_level)
|
# Create a one-time token for forward auth to handle the race condition
|
||||||
# For complex TLDs, include more parts: app.example.co.uk -> .example.co.uk
|
# where the browser hasn't processed the session cookie yet
|
||||||
root_parts = parts[-3..-1]
|
def create_forward_auth_token(session_obj)
|
||||||
return ".#{root_parts.join('.')}"
|
# Generate a secure random token
|
||||||
end
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
|
|
||||||
|
# Store it with an expiry of 30 seconds
|
||||||
|
Rails.cache.write(
|
||||||
|
"forward_auth_token:#{token}",
|
||||||
|
session_obj.id,
|
||||||
|
expires_in: 30.seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the token as a query parameter on the redirect URL
|
||||||
|
# We need to store this in the controller's session
|
||||||
|
controller_session = session
|
||||||
|
if controller_session[:return_to_after_authenticating].present?
|
||||||
|
original_url = controller_session[:return_to_after_authenticating]
|
||||||
|
uri = URI.parse(original_url)
|
||||||
|
|
||||||
|
# Add token as query parameter
|
||||||
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||||
|
query_params['fa_token'] = token
|
||||||
|
uri.query = URI.encode_www_form(query_params)
|
||||||
|
|
||||||
|
# Update the session with the tokenized URL
|
||||||
|
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
# For regular domains: app.example.com -> .example.com
|
|
||||||
root_parts = parts[-2..-1]
|
|
||||||
".#{root_parts.join('.')}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
50
app/controllers/invitations_controller.rb
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
class InvitationsController < ApplicationController
|
||||||
|
include Authentication
|
||||||
|
allow_unauthenticated_access
|
||||||
|
before_action :set_user_by_invitation_token, only: %i[ show update ]
|
||||||
|
|
||||||
|
def show
|
||||||
|
# Show the password setup form
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
# Validate password manually since empty passwords might not trigger validation
|
||||||
|
password = params[:password]
|
||||||
|
password_confirmation = params[:password_confirmation]
|
||||||
|
|
||||||
|
if password.blank? || password_confirmation.blank? || password != password_confirmation || password.length < 8
|
||||||
|
redirect_to invitation_path(params[:token]), alert: "Passwords did not match."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if @user.update(password: password, password_confirmation: password_confirmation)
|
||||||
|
@user.update!(status: :active)
|
||||||
|
@user.sessions.destroy_all
|
||||||
|
start_new_session_for @user
|
||||||
|
redirect_to root_path, notice: "Your account has been set up successfully. Welcome!"
|
||||||
|
else
|
||||||
|
redirect_to invitation_path(params[:token]), alert: "Passwords did not match."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_user_by_invitation_token
|
||||||
|
@user = User.find_by_token_for(:invitation_login, params[:token])
|
||||||
|
|
||||||
|
# Check if user is still pending invitation
|
||||||
|
if @user.nil?
|
||||||
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
|
return false
|
||||||
|
elsif @user.pending_invitation?
|
||||||
|
# User is valid and pending - proceed
|
||||||
|
return true
|
||||||
|
else
|
||||||
|
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
|
||||||
skip_before_action :verify_authenticity_token, only: [:token]
|
skip_before_action :verify_authenticity_token, only: [:token, :logout]
|
||||||
|
|
||||||
# GET /.well-known/openid-configuration
|
# GET /.well-known/openid-configuration
|
||||||
def discovery
|
def discovery
|
||||||
@@ -13,6 +13,7 @@ class OidcController < ApplicationController
|
|||||||
token_endpoint: "#{base_url}/oauth/token",
|
token_endpoint: "#{base_url}/oauth/token",
|
||||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||||
|
end_session_endpoint: "#{base_url}/logout",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
subject_types_supported: ["public"],
|
subject_types_supported: ["public"],
|
||||||
id_token_signing_alg_values_supported: ["RS256"],
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
@@ -81,6 +82,30 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
requested_scopes = scope.split(" ")
|
||||||
|
|
||||||
|
# Check if user has already granted consent for these scopes
|
||||||
|
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||||
|
if existing_consent
|
||||||
|
# User has already consented, generate authorization code directly
|
||||||
|
code = SecureRandom.urlsafe_base64(32)
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: user,
|
||||||
|
code: code,
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
scope: scope,
|
||||||
|
nonce: nonce,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect back to client with authorization code
|
||||||
|
redirect_uri = "#{redirect_uri}?code=#{code}"
|
||||||
|
redirect_uri += "&state=#{state}" if state.present?
|
||||||
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Store OAuth parameters for consent page
|
# Store OAuth parameters for consent page
|
||||||
session[:oauth_params] = {
|
session[:oauth_params] = {
|
||||||
client_id: client_id,
|
client_id: client_id,
|
||||||
@@ -92,7 +117,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Render consent page
|
# Render consent page
|
||||||
@redirect_uri = redirect_uri
|
@redirect_uri = redirect_uri
|
||||||
@scopes = scope.split(" ")
|
@scopes = requested_scopes
|
||||||
render :consent
|
render :consent
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -108,36 +133,47 @@ class OidcController < ApplicationController
|
|||||||
# User denied consent
|
# User denied consent
|
||||||
if params[:deny].present?
|
if params[:deny].present?
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params[:redirect_uri]}?error=access_denied"
|
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
||||||
error_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
|
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
application = Application.find_by(client_id: oauth_params[:client_id])
|
client_id = oauth_params['client_id']
|
||||||
|
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
|
# Record user consent
|
||||||
|
requested_scopes = oauth_params['scope'].split(' ')
|
||||||
|
OidcUserConsent.upsert(
|
||||||
|
{
|
||||||
|
user_id: user.id,
|
||||||
|
application_id: application.id,
|
||||||
|
scopes_granted: requested_scopes.join(' '),
|
||||||
|
granted_at: Time.current
|
||||||
|
},
|
||||||
|
unique_by: [:user_id, :application_id]
|
||||||
|
)
|
||||||
|
|
||||||
# Generate authorization code
|
# Generate authorization code
|
||||||
code = SecureRandom.urlsafe_base64(32)
|
code = SecureRandom.urlsafe_base64(32)
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
code: code,
|
code: code,
|
||||||
redirect_uri: oauth_params[:redirect_uri],
|
redirect_uri: oauth_params['redirect_uri'],
|
||||||
scope: oauth_params[:scope],
|
scope: oauth_params['scope'],
|
||||||
|
nonce: oauth_params['nonce'],
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store nonce in the authorization code metadata if needed
|
|
||||||
# For now, we'll pass it through the code itself
|
|
||||||
|
|
||||||
# Clear OAuth params from session
|
# Clear OAuth params from session
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
|
|
||||||
# Redirect back to client with authorization code
|
# Redirect back to client with authorization code
|
||||||
redirect_uri = "#{oauth_params[:redirect_uri]}?code=#{code}"
|
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
|
||||||
redirect_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
|
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||||
|
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
end
|
end
|
||||||
@@ -161,7 +197,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Find and validate the application
|
# Find and validate the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application && application.client_secret == client_secret
|
unless application && application.authenticate_client_secret(client_secret)
|
||||||
render json: { error: "invalid_client" }, status: :unauthorized
|
render json: { error: "invalid_client" }, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -210,7 +246,7 @@ class OidcController < ApplicationController
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Generate ID token
|
# Generate ID token
|
||||||
id_token = OidcJwtService.generate_id_token(user, application)
|
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||||
|
|
||||||
# Return tokens
|
# Return tokens
|
||||||
render json: {
|
render json: {
|
||||||
@@ -269,6 +305,33 @@ class OidcController < ApplicationController
|
|||||||
render json: claims
|
render json: claims
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# GET /logout
|
||||||
|
def logout
|
||||||
|
# OpenID Connect RP-Initiated Logout
|
||||||
|
# Handle id_token_hint and post_logout_redirect_uri parameters
|
||||||
|
|
||||||
|
id_token_hint = params[:id_token_hint]
|
||||||
|
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
||||||
|
state = params[:state]
|
||||||
|
|
||||||
|
# If user is authenticated, log them out
|
||||||
|
if authenticated?
|
||||||
|
# Invalidate the current session
|
||||||
|
Current.session&.destroy
|
||||||
|
reset_session
|
||||||
|
end
|
||||||
|
|
||||||
|
# If post_logout_redirect_uri is provided, redirect there
|
||||||
|
if post_logout_redirect_uri.present?
|
||||||
|
redirect_uri = post_logout_redirect_uri
|
||||||
|
redirect_uri += "?state=#{state}" if state.present?
|
||||||
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
|
else
|
||||||
|
# Default redirect to home page
|
||||||
|
redirect_to root_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def extract_client_credentials
|
def extract_client_credentials
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class PasswordsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_user_by_token
|
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
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class ProfilesController < ApplicationController
|
|||||||
def show
|
def show
|
||||||
@user = Current.session.user
|
@user = Current.session.user
|
||||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||||
|
@connected_applications = @user.oidc_user_consents.includes(:application).order(granted_at: :desc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -33,6 +34,34 @@ class ProfilesController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revoke_consent
|
||||||
|
@user = Current.session.user
|
||||||
|
application = Application.find(params[:application_id])
|
||||||
|
|
||||||
|
# Check if user has consent for this application
|
||||||
|
consent = @user.oidc_user_consents.find_by(application: application)
|
||||||
|
unless consent
|
||||||
|
redirect_to profile_path, alert: "No consent found for this application."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Revoke the consent
|
||||||
|
consent.destroy
|
||||||
|
redirect_to profile_path, notice: "Successfully revoked access to #{application.name}."
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_all_consents
|
||||||
|
@user = Current.session.user
|
||||||
|
count = @user.oidc_user_consents.count
|
||||||
|
|
||||||
|
if count > 0
|
||||||
|
@user.oidc_user_consents.destroy_all
|
||||||
|
redirect_to profile_path, notice: "Successfully revoked access to #{count} applications."
|
||||||
|
else
|
||||||
|
redirect_to profile_path, alert: "No applications to revoke."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def email_params
|
def email_params
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class SessionsController < ApplicationController
|
class SessionsController < ApplicationController
|
||||||
allow_unauthenticated_access only: %i[ new create verify_totp ]
|
allow_unauthenticated_access only: %i[ new create verify_totp ]
|
||||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||||
rate_limit to: 5, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||||
|
|
||||||
def new
|
def new
|
||||||
# Redirect to signup if this is first run
|
# Redirect to signup if this is first run
|
||||||
@@ -16,14 +16,19 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Store the redirect URL from forward auth if present
|
# Store the redirect URL from forward auth if present (after validation)
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
session[:return_to_after_authenticating] = params[:rd]
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
|
session[:return_to_after_authenticating] = validated_url if validated_url
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if user is active
|
# Check if user is active
|
||||||
unless user.active?
|
unless user.active?
|
||||||
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
if user.pending_invitation?
|
||||||
|
redirect_to signin_path, alert: "Please check your email for an invitation to set up your account."
|
||||||
|
else
|
||||||
|
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -31,9 +36,10 @@ class SessionsController < ApplicationController
|
|||||||
if user.totp_enabled?
|
if user.totp_enabled?
|
||||||
# Store user ID in session temporarily for TOTP verification
|
# Store user ID in session temporarily for TOTP verification
|
||||||
session[:pending_totp_user_id] = user.id
|
session[:pending_totp_user_id] = user.id
|
||||||
# Preserve the redirect URL through TOTP verification
|
# Preserve the redirect URL through TOTP verification (after validation)
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
session[:totp_redirect_url] = params[:rd]
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
|
session[:totp_redirect_url] = validated_url if validated_url
|
||||||
end
|
end
|
||||||
redirect_to totp_verification_path(rd: params[:rd])
|
redirect_to totp_verification_path(rd: params[:rd])
|
||||||
return
|
return
|
||||||
@@ -63,6 +69,12 @@ class SessionsController < ApplicationController
|
|||||||
if request.post?
|
if request.post?
|
||||||
code = params[:code]&.strip
|
code = params[:code]&.strip
|
||||||
|
|
||||||
|
# Check if user is already authenticated (prevent duplicate submissions)
|
||||||
|
if authenticated?
|
||||||
|
redirect_to root_path, notice: "Already signed in."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Try TOTP verification first
|
# Try TOTP verification first
|
||||||
if user.verify_totp(code)
|
if user.verify_totp(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
@@ -105,4 +117,33 @@ class SessionsController < ApplicationController
|
|||||||
session.destroy
|
session.destroy
|
||||||
redirect_to profile_path, notice: "Session revoked successfully."
|
redirect_to profile_path, notice: "Session revoked successfully."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_redirect_url(url)
|
||||||
|
return nil unless url.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
uri = URI.parse(url)
|
||||||
|
|
||||||
|
# Only allow HTTP/HTTPS schemes
|
||||||
|
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
|
# Only allow HTTPS in production
|
||||||
|
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||||
|
|
||||||
|
redirect_domain = uri.host.downcase
|
||||||
|
return nil unless redirect_domain.present?
|
||||||
|
|
||||||
|
# Check against our ForwardAuthRules
|
||||||
|
matching_rule = ForwardAuthRule.active.find do |rule|
|
||||||
|
rule.matches_domain?(redirect_domain)
|
||||||
|
end
|
||||||
|
|
||||||
|
matching_rule ? url : nil
|
||||||
|
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,2 +1,22 @@
|
|||||||
module ApplicationHelper
|
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
|
end
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [ "submit" ]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Prevent form auto-submission when browser autofills TOTP
|
||||||
|
this.preventAutoSubmit()
|
||||||
|
|
||||||
|
// Add double-click protection
|
||||||
|
this.submitTarget.addEventListener('dblclick', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (this.submitTarget.disabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable submit button and show loading state
|
||||||
|
this.submitTarget.disabled = true
|
||||||
|
this.submitTarget.textContent = 'Verifying...'
|
||||||
|
this.submitTarget.classList.add('opacity-75', 'cursor-not-allowed')
|
||||||
|
|
||||||
|
// Re-enable after 10 seconds in case of network issues
|
||||||
|
setTimeout(() => {
|
||||||
|
this.submitTarget.disabled = false
|
||||||
|
this.submitTarget.textContent = 'Verify'
|
||||||
|
this.submitTarget.classList.remove('opacity-75', 'cursor-not-allowed')
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
// Allow the form to submit normally
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
preventAutoSubmit() {
|
||||||
|
// Some browsers auto-submit forms when TOTP fields are autofilled
|
||||||
|
// This prevents that behavior while still allowing manual submission
|
||||||
|
const codeInput = this.element.querySelector('input[name="code"]')
|
||||||
|
|
||||||
|
if (codeInput) {
|
||||||
|
let hasAutoSubmitted = false
|
||||||
|
|
||||||
|
codeInput.addEventListener('input', (e) => {
|
||||||
|
// Check if this looks like an auto-fill event
|
||||||
|
// Auto-fill typically fills the entire field at once
|
||||||
|
if (e.target.value.length >= 6 && !hasAutoSubmitted) {
|
||||||
|
// Don't auto-submit, let user click the button manually
|
||||||
|
hasAutoSubmitted = true
|
||||||
|
|
||||||
|
// Optionally, focus the submit button to make it obvious
|
||||||
|
this.submitTarget.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also prevent Enter key submission on TOTP field
|
||||||
|
codeInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
this.submitTarget.click()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/javascript/controllers/role_management_controller.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["userSelect", "assignLink", "editForm"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
console.log("Role management controller connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
assignRole(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const link = event.currentTarget
|
||||||
|
const roleId = link.dataset.roleId
|
||||||
|
const select = document.getElementById(`assign-user-${roleId}`)
|
||||||
|
|
||||||
|
if (!select.value) {
|
||||||
|
alert("Please select a user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the href with the selected user ID
|
||||||
|
const originalHref = link.href
|
||||||
|
const newHref = originalHref.replace("PLACEHOLDER", select.value)
|
||||||
|
|
||||||
|
// Navigate to the updated URL
|
||||||
|
window.location.href = newHref
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleEdit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const roleId = event.currentTarget.dataset.roleId
|
||||||
|
const editForm = document.getElementById(`edit-role-${roleId}`)
|
||||||
|
|
||||||
|
if (editForm) {
|
||||||
|
editForm.classList.toggle("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideEdit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const roleId = event.currentTarget.dataset.roleId
|
||||||
|
const editForm = document.getElementById(`edit-role-${roleId}`)
|
||||||
|
|
||||||
|
if (editForm) {
|
||||||
|
editForm.classList.add("hidden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
class ApplicationMailer < ActionMailer::Base
|
||||||
default from: "from@example.com"
|
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
|
||||||
layout "mailer"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
6
app/mailers/invitations_mailer.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class InvitationsMailer < ApplicationMailer
|
||||||
|
def invite_user(user)
|
||||||
|
@user = user
|
||||||
|
mail subject: "You're invited to join Clinch", to: user.email_address
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
class Application < ApplicationRecord
|
class Application < ApplicationRecord
|
||||||
|
has_secure_password :client_secret
|
||||||
|
|
||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :allowed_groups, through: :application_groups, source: :group
|
has_many :allowed_groups, through: :application_groups, source: :group
|
||||||
has_many :oidc_authorization_codes, dependent: :destroy
|
has_many :oidc_authorization_codes, dependent: :destroy
|
||||||
has_many :oidc_access_tokens, dependent: :destroy
|
has_many :oidc_access_tokens, dependent: :destroy
|
||||||
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
has_many :application_roles, dependent: :destroy
|
||||||
|
has_many :user_role_assignments, through: :application_roles
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||||
@@ -10,6 +15,7 @@ class Application < ApplicationRecord
|
|||||||
validates :app_type, presence: true,
|
validates :app_type, presence: true,
|
||||||
inclusion: { in: %w[oidc saml] }
|
inclusion: { in: %w[oidc saml] }
|
||||||
validates :client_id, uniqueness: { allow_nil: true }
|
validates :client_id, uniqueness: { allow_nil: true }
|
||||||
|
validates :role_mapping_mode, inclusion: { in: %w[disabled oidc_managed hybrid] }, allow_blank: true
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
|
|
||||||
@@ -19,6 +25,8 @@ class Application < ApplicationRecord
|
|||||||
scope :active, -> { where(active: true) }
|
scope :active, -> { where(active: true) }
|
||||||
scope :oidc, -> { where(app_type: "oidc") }
|
scope :oidc, -> { where(app_type: "oidc") }
|
||||||
scope :saml, -> { where(app_type: "saml") }
|
scope :saml, -> { where(app_type: "saml") }
|
||||||
|
scope :oidc_managed_roles, -> { where(role_mapping_mode: "oidc_managed") }
|
||||||
|
scope :hybrid_roles, -> { where(role_mapping_mode: "hybrid") }
|
||||||
|
|
||||||
# Type checks
|
# Type checks
|
||||||
def oidc?
|
def oidc?
|
||||||
@@ -29,6 +37,19 @@ class Application < ApplicationRecord
|
|||||||
app_type == "saml"
|
app_type == "saml"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Role mapping checks
|
||||||
|
def role_mapping_enabled?
|
||||||
|
role_mapping_mode.in?(['oidc_managed', 'hybrid'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def oidc_managed_roles?
|
||||||
|
role_mapping_mode == 'oidc_managed'
|
||||||
|
end
|
||||||
|
|
||||||
|
def hybrid_roles?
|
||||||
|
role_mapping_mode == 'hybrid'
|
||||||
|
end
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
def user_allowed?(user)
|
def user_allowed?(user)
|
||||||
return false unless active?
|
return false unless active?
|
||||||
@@ -56,10 +77,67 @@ class Application < ApplicationRecord
|
|||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parsed_managed_permissions
|
||||||
|
return {} unless managed_permissions.present?
|
||||||
|
managed_permissions.is_a?(Hash) ? managed_permissions : JSON.parse(managed_permissions)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Role management methods
|
||||||
|
def user_roles(user)
|
||||||
|
application_roles.joins(:user_role_assignments)
|
||||||
|
.where(user_role_assignments: { user: user })
|
||||||
|
.active
|
||||||
|
end
|
||||||
|
|
||||||
|
def user_has_role?(user, role_name)
|
||||||
|
user_roles(user).exists?(name: role_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_role_to_user!(user, role_name, source: 'manual', metadata: {})
|
||||||
|
role = application_roles.active.find_by!(name: role_name)
|
||||||
|
role.assign_to_user!(user, source: source, metadata: metadata)
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_role_from_user!(user, role_name)
|
||||||
|
role = application_roles.find_by!(name: role_name)
|
||||||
|
role.remove_from_user!(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Enhanced access control with roles
|
||||||
|
def user_allowed_with_roles?(user)
|
||||||
|
return user_allowed?(user) unless role_mapping_enabled?
|
||||||
|
|
||||||
|
# For OIDC managed roles, check if user has any roles assigned
|
||||||
|
if oidc_managed_roles?
|
||||||
|
return user_roles(user).exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
# For hybrid mode, either group-based access or role-based access works
|
||||||
|
if hybrid_roles?
|
||||||
|
return user_allowed?(user) || user_roles(user).exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
user_allowed?(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate and return a new client secret
|
||||||
|
def generate_new_client_secret!
|
||||||
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.client_secret = secret
|
||||||
|
self.save!
|
||||||
|
secret
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_client_credentials
|
def generate_client_credentials
|
||||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||||
self.client_secret ||= SecureRandom.urlsafe_base64(48)
|
# Generate and hash the client secret
|
||||||
|
if new_record? && client_secret.blank?
|
||||||
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.client_secret = secret
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
26
app/models/application_role.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class ApplicationRole < ApplicationRecord
|
||||||
|
belongs_to :application
|
||||||
|
has_many :user_role_assignments, dependent: :destroy
|
||||||
|
has_many :users, through: :user_role_assignments
|
||||||
|
|
||||||
|
validates :name, presence: true, uniqueness: { scope: :application_id }
|
||||||
|
validates :display_name, presence: true
|
||||||
|
|
||||||
|
scope :active, -> { where(active: true) }
|
||||||
|
|
||||||
|
def user_has_role?(user)
|
||||||
|
user_role_assignments.exists?(user: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_to_user!(user, source: 'oidc', metadata: {})
|
||||||
|
user_role_assignments.find_or_create_by!(user: user) do |assignment|
|
||||||
|
assignment.source = source
|
||||||
|
assignment.metadata = metadata
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_from_user!(user)
|
||||||
|
assignment = user_role_assignments.find_by(user: user)
|
||||||
|
assignment&.destroy
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,15 @@ class ForwardAuthRule < ApplicationRecord
|
|||||||
|
|
||||||
normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
|
normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
|
||||||
|
|
||||||
|
# Default header configuration
|
||||||
|
DEFAULT_HEADERS = {
|
||||||
|
user: 'X-Remote-User',
|
||||||
|
email: 'X-Remote-Email',
|
||||||
|
name: 'X-Remote-Name',
|
||||||
|
groups: 'X-Remote-Groups',
|
||||||
|
admin: 'X-Remote-Admin'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :active, -> { where(active: true) }
|
scope :active, -> { where(active: true) }
|
||||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||||
@@ -50,4 +59,36 @@ class ForwardAuthRule < ApplicationRecord
|
|||||||
'deny'
|
'deny'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Get effective header configuration (rule-specific + defaults)
|
||||||
|
def effective_headers
|
||||||
|
DEFAULT_HEADERS.merge((headers_config || {}).symbolize_keys)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate headers for a specific user
|
||||||
|
def headers_for_user(user)
|
||||||
|
headers = {}
|
||||||
|
effective = effective_headers
|
||||||
|
|
||||||
|
# Only generate headers that are configured (not set to nil/false)
|
||||||
|
effective.each do |key, header_name|
|
||||||
|
next unless header_name.present? # Skip disabled headers
|
||||||
|
|
||||||
|
case key
|
||||||
|
when :user, :email, :name
|
||||||
|
headers[header_name] = user.email_address
|
||||||
|
when :groups
|
||||||
|
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
|
||||||
|
when :admin
|
||||||
|
headers[header_name] = user.admin? ? "true" : "false"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
headers
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if all headers are disabled
|
||||||
|
def headers_disabled?
|
||||||
|
headers_config.present? && effective_headers.values.all?(&:blank?)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
52
app/models/oidc_user_consent.rb
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
class OidcUserConsent < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :application
|
||||||
|
|
||||||
|
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
||||||
|
validates :user_id, uniqueness: { scope: :application_id }
|
||||||
|
|
||||||
|
before_validation :set_granted_at, on: :create
|
||||||
|
|
||||||
|
# Parse scopes_granted into an array
|
||||||
|
def scopes
|
||||||
|
scopes_granted.split(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set scopes from an array
|
||||||
|
def scopes=(scope_array)
|
||||||
|
self.scopes_granted = Array(scope_array).uniq.join(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if this consent covers the requested scopes
|
||||||
|
def covers_scopes?(requested_scopes)
|
||||||
|
requested = Array(requested_scopes).map(&:to_s)
|
||||||
|
granted = scopes
|
||||||
|
|
||||||
|
# All requested scopes must be included in granted scopes
|
||||||
|
(requested - granted).empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a human-readable list of scopes
|
||||||
|
def formatted_scopes
|
||||||
|
scopes.map do |scope|
|
||||||
|
case scope
|
||||||
|
when 'openid'
|
||||||
|
'Basic authentication'
|
||||||
|
when 'profile'
|
||||||
|
'Profile information'
|
||||||
|
when 'email'
|
||||||
|
'Email address'
|
||||||
|
when 'groups'
|
||||||
|
'Group membership'
|
||||||
|
else
|
||||||
|
scope.humanize
|
||||||
|
end
|
||||||
|
end.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_granted_at
|
||||||
|
self.granted_at ||= Time.current
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,11 +3,22 @@ class User < ApplicationRecord
|
|||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
has_many :user_groups, dependent: :destroy
|
has_many :user_groups, dependent: :destroy
|
||||||
has_many :groups, through: :user_groups
|
has_many :groups, through: :user_groups
|
||||||
|
has_many :user_role_assignments, dependent: :destroy
|
||||||
|
has_many :application_roles, through: :user_role_assignments
|
||||||
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
|
||||||
# Token generation for passwordless flows
|
# Token generation for passwordless flows
|
||||||
generates_token_for :invitation, expires_in: 7.days
|
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||||
generates_token_for :password_reset, expires_in: 1.hour
|
updated_at
|
||||||
generates_token_for :magic_login, expires_in: 15.minutes
|
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 }
|
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||||
|
|
||||||
@@ -71,6 +82,21 @@ class User < ApplicationRecord
|
|||||||
JSON.parse(backup_codes)
|
JSON.parse(backup_codes)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def has_oidc_consent?(application, requested_scopes)
|
||||||
|
oidc_user_consents
|
||||||
|
.where(application: application)
|
||||||
|
.find { |consent| consent.covers_scopes?(requested_scopes) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_consent!(application)
|
||||||
|
consent = oidc_user_consents.find_by(application: application)
|
||||||
|
consent&.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_all_consents!
|
||||||
|
oidc_user_consents.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_backup_codes
|
def generate_backup_codes
|
||||||
|
|||||||
15
app/models/user_role_assignment.rb
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
class UserRoleAssignment < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :application_role
|
||||||
|
|
||||||
|
validates :user, uniqueness: { scope: :application_role }
|
||||||
|
validates :source, inclusion: { in: %w[oidc manual group_sync] }
|
||||||
|
|
||||||
|
scope :oidc_managed, -> { where(source: 'oidc') }
|
||||||
|
scope :manually_assigned, -> { where(source: 'manual') }
|
||||||
|
scope :group_synced, -> { where(source: 'group_sync') }
|
||||||
|
|
||||||
|
def sync_from_oidc?
|
||||||
|
source == 'oidc'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -27,6 +27,11 @@ class OidcJwtService
|
|||||||
# Add admin claim if user is admin
|
# Add admin claim if user is admin
|
||||||
payload[:admin] = true if user.admin?
|
payload[:admin] = true if user.admin?
|
||||||
|
|
||||||
|
# Add role-based claims if role mapping is enabled
|
||||||
|
if application.role_mapping_enabled?
|
||||||
|
add_role_claims!(payload, user, application)
|
||||||
|
end
|
||||||
|
|
||||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -55,7 +60,7 @@ class OidcJwtService
|
|||||||
def issuer_url
|
def issuer_url
|
||||||
# In production, this should come from ENV or config
|
# In production, this should come from ENV or config
|
||||||
# For now, we'll use a placeholder that can be overridden
|
# 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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -88,5 +93,50 @@ class OidcJwtService
|
|||||||
def key_id
|
def key_id
|
||||||
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Add role-based claims to the JWT payload
|
||||||
|
def add_role_claims!(payload, user, application)
|
||||||
|
user_roles = application.user_roles(user)
|
||||||
|
return if user_roles.empty?
|
||||||
|
|
||||||
|
role_names = user_roles.pluck(:name)
|
||||||
|
|
||||||
|
# Filter roles by prefix if configured
|
||||||
|
if application.role_prefix.present?
|
||||||
|
role_names = role_names.select { |role| role.start_with?(application.role_prefix) }
|
||||||
|
end
|
||||||
|
|
||||||
|
return if role_names.empty?
|
||||||
|
|
||||||
|
# Add roles using the configured claim name
|
||||||
|
claim_name = application.role_claim_name.presence || 'roles'
|
||||||
|
payload[claim_name] = role_names
|
||||||
|
|
||||||
|
# Add role permissions if configured
|
||||||
|
managed_permissions = application.parsed_managed_permissions
|
||||||
|
if managed_permissions['include_permissions'] == true
|
||||||
|
role_permissions = user_roles.map do |role|
|
||||||
|
{
|
||||||
|
name: role.name,
|
||||||
|
display_name: role.display_name,
|
||||||
|
permissions: role.permissions
|
||||||
|
}
|
||||||
|
end
|
||||||
|
payload['role_permissions'] = role_permissions
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add role metadata if configured
|
||||||
|
if managed_permissions['include_metadata'] == true
|
||||||
|
role_metadata = user_roles.map do |role|
|
||||||
|
assignment = role.user_role_assignments.find_by(user: user)
|
||||||
|
{
|
||||||
|
name: role.name,
|
||||||
|
source: assignment&.source,
|
||||||
|
assigned_at: assignment&.created_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
payload['role_metadata'] = role_metadata
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
127
app/services/role_mapping_engine.rb
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
class RoleMappingEngine
|
||||||
|
class << self
|
||||||
|
# Sync user roles from OIDC claims
|
||||||
|
def sync_user_roles!(user, application, claims)
|
||||||
|
return unless application.role_mapping_enabled?
|
||||||
|
|
||||||
|
# Extract roles from claims
|
||||||
|
external_roles = extract_roles_from_claims(application, claims)
|
||||||
|
|
||||||
|
case application.role_mapping_mode
|
||||||
|
when 'oidc_managed'
|
||||||
|
sync_oidc_managed_roles!(user, application, external_roles)
|
||||||
|
when 'hybrid'
|
||||||
|
sync_hybrid_roles!(user, application, external_roles)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if user is allowed based on roles
|
||||||
|
def user_allowed_with_roles?(user, application, claims = nil)
|
||||||
|
return application.user_allowed_with_roles?(user) unless claims
|
||||||
|
|
||||||
|
if application.oidc_managed_roles?
|
||||||
|
external_roles = extract_roles_from_claims(application, claims)
|
||||||
|
return false if external_roles.empty?
|
||||||
|
|
||||||
|
# Check if any external role matches configured application roles
|
||||||
|
application.application_roles.active.exists?(name: external_roles)
|
||||||
|
elsif application.hybrid_roles?
|
||||||
|
# Allow access if either group-based or role-based access works
|
||||||
|
application.user_allowed?(user) ||
|
||||||
|
(external_roles.present? &&
|
||||||
|
application.application_roles.active.exists?(name: external_roles))
|
||||||
|
else
|
||||||
|
application.user_allowed?(user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get available roles for a user in an application
|
||||||
|
def user_available_roles(user, application)
|
||||||
|
return [] unless application.role_mapping_enabled?
|
||||||
|
|
||||||
|
application.application_roles.active
|
||||||
|
end
|
||||||
|
|
||||||
|
# Map external roles to internal roles
|
||||||
|
def map_external_to_internal_roles(application, external_roles)
|
||||||
|
return [] if external_roles.empty?
|
||||||
|
|
||||||
|
configured_roles = application.application_roles.active.pluck(:name)
|
||||||
|
|
||||||
|
# Apply role prefix filtering
|
||||||
|
if application.role_prefix.present?
|
||||||
|
external_roles = external_roles.select { |role| role.start_with?(application.role_prefix) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find matching internal roles
|
||||||
|
external_roles & configured_roles
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Extract roles from various claim sources
|
||||||
|
def extract_roles_from_claims(application, claims)
|
||||||
|
claim_name = application.role_claim_name.presence || 'roles'
|
||||||
|
|
||||||
|
# Try the configured claim name first
|
||||||
|
roles = claims[claim_name]
|
||||||
|
|
||||||
|
# Fallback to common claim names if not found
|
||||||
|
roles ||= claims['roles']
|
||||||
|
roles ||= claims['groups']
|
||||||
|
roles ||= claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']
|
||||||
|
|
||||||
|
# Ensure roles is an array
|
||||||
|
case roles
|
||||||
|
when String
|
||||||
|
[roles]
|
||||||
|
when Array
|
||||||
|
roles
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sync roles for OIDC managed mode (replace existing roles)
|
||||||
|
def sync_oidc_managed_roles!(user, application, external_roles)
|
||||||
|
# Map external roles to internal roles
|
||||||
|
internal_roles = map_external_to_internal_roles(application, external_roles)
|
||||||
|
|
||||||
|
# Get current OIDC-managed roles
|
||||||
|
current_assignments = user.user_role_assignments
|
||||||
|
.joins(:application_role)
|
||||||
|
.where(application_role: { application: application })
|
||||||
|
.oidc_managed
|
||||||
|
.includes(:application_role)
|
||||||
|
|
||||||
|
current_role_names = current_assignments.map { |assignment| assignment.application_role.name }
|
||||||
|
|
||||||
|
# Remove roles that are no longer in external roles
|
||||||
|
roles_to_remove = current_role_names - internal_roles
|
||||||
|
roles_to_remove.each do |role_name|
|
||||||
|
application.remove_role_from_user!(user, role_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add new roles
|
||||||
|
roles_to_add = internal_roles - current_role_names
|
||||||
|
roles_to_add.each do |role_name|
|
||||||
|
application.assign_role_to_user!(user, role_name, source: 'oidc',
|
||||||
|
metadata: { synced_at: Time.current })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sync roles for hybrid mode (merge with existing roles)
|
||||||
|
def sync_hybrid_roles!(user, application, external_roles)
|
||||||
|
# Map external roles to internal roles
|
||||||
|
internal_roles = map_external_to_internal_roles(application, external_roles)
|
||||||
|
|
||||||
|
# Only add new roles, don't remove manually assigned ones
|
||||||
|
internal_roles.each do |role_name|
|
||||||
|
next if application.user_has_role?(user, role_name)
|
||||||
|
|
||||||
|
application.assign_role_to_user!(user, role_name, source: 'oidc',
|
||||||
|
metadata: { synced_at: Time.current })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -51,6 +51,52 @@
|
|||||||
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
||||||
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Mapping Configuration -->
|
||||||
|
<div class="border-t border-gray-200 pt-6">
|
||||||
|
<h4 class="text-base font-semibold text-gray-900 mb-4">Role Mapping Configuration</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :role_mapping_mode, "Role Mapping Mode", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :role_mapping_mode,
|
||||||
|
options_for_select([
|
||||||
|
["Disabled", "disabled"],
|
||||||
|
["OIDC Managed", "oidc_managed"],
|
||||||
|
["Hybrid (Groups + Roles)", "hybrid"]
|
||||||
|
], application.role_mapping_mode || "disabled"),
|
||||||
|
{},
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Controls how external roles are mapped and synchronized.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="role-mapping-advanced" class="mt-4 space-y-4 border-t border-gray-200 pt-4" style="<%= 'display: none;' unless application.role_mapping_enabled? %>">
|
||||||
|
<div>
|
||||||
|
<%= form.label :role_claim_name, "Role Claim Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :role_claim_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "roles" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Name of the claim that contains role information (default: 'roles').</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :role_prefix, "Role Prefix (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :role_prefix, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "app-" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Only roles starting with this prefix will be mapped. Useful for multi-tenant scenarios.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Managed Permissions</label>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_permissions", "" %>
|
||||||
|
<%= form.label :managed_permissions_include_permissions, "Include role permissions in tokens", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_metadata", "" %>
|
||||||
|
<%= form.label :managed_permissions_include_metadata, "Include role metadata in tokens", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -86,14 +132,30 @@
|
|||||||
// Show/hide OIDC fields based on app type selection
|
// Show/hide OIDC fields based on app type selection
|
||||||
const appTypeSelect = document.querySelector('#application_app_type');
|
const appTypeSelect = document.querySelector('#application_app_type');
|
||||||
const oidcFields = document.querySelector('#oidc-fields');
|
const oidcFields = document.querySelector('#oidc-fields');
|
||||||
|
const roleMappingMode = document.querySelector('#application_role_mapping_mode');
|
||||||
|
const roleMappingAdvanced = document.querySelector('#role-mapping-advanced');
|
||||||
|
|
||||||
|
function updateFieldVisibility() {
|
||||||
|
const isOidc = appTypeSelect.value === 'oidc';
|
||||||
|
const roleMappingEnabled = roleMappingMode && ['oidc_managed', 'hybrid'].includes(roleMappingMode.value);
|
||||||
|
|
||||||
|
if (oidcFields) {
|
||||||
|
oidcFields.style.display = isOidc ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleMappingAdvanced) {
|
||||||
|
roleMappingAdvanced.style.display = isOidc && roleMappingEnabled ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (appTypeSelect && oidcFields) {
|
if (appTypeSelect && oidcFields) {
|
||||||
appTypeSelect.addEventListener('change', function() {
|
appTypeSelect.addEventListener('change', updateFieldVisibility);
|
||||||
if (this.value === 'oidc') {
|
|
||||||
oidcFields.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
oidcFields.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (roleMappingMode) {
|
||||||
|
roleMappingMode.addEventListener('change', updateFieldVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize visibility on page load
|
||||||
|
updateFieldVisibility();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">Manage OIDC applications.</p>
|
<p class="mt-2 text-sm text-gray-700">Manage OIDC Clients.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
<%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
<%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||||
@@ -56,9 +56,11 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<div class="flex justify-end space-x-3">
|
||||||
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900" %>
|
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
|
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
125
app/views/admin/applications/roles.html.erb
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||||
|
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
Role Management for <%= @application.name %>
|
||||||
|
</h3>
|
||||||
|
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @application.role_mapping_enabled? %>
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||||
|
<% if @application.role_claim_name.present? %>
|
||||||
|
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||||
|
<% end %>
|
||||||
|
<% if @application.role_prefix.present? %>
|
||||||
|
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Create New Role -->
|
||||||
|
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||||
|
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||||
|
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Roles -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||||
|
|
||||||
|
<% if @application_roles.any? %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% @application_roles.each do |role| %>
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<%= role.display_name %>
|
||||||
|
</span>
|
||||||
|
<% unless role.active %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if role.description.present? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Assigned Users -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% role.users.each do |user| %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= user.email_address %>
|
||||||
|
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||||
|
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
|
||||||
|
method: :post,
|
||||||
|
data: { confirm: "Remove role from #{user.email_address}?" },
|
||||||
|
class: "ml-1 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-gray-500 text-sm">
|
||||||
|
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
173
app/views/admin/applications/roles_backup.html.erb
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||||
|
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
Role Management for <%= @application.name %>
|
||||||
|
</h3>
|
||||||
|
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @application.role_mapping_enabled? %>
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||||
|
<% if @application.role_claim_name.present? %>
|
||||||
|
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||||
|
<% end %>
|
||||||
|
<% if @application.role_prefix.present? %>
|
||||||
|
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Create New Role -->
|
||||||
|
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||||
|
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||||
|
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Roles -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||||
|
|
||||||
|
<% if @application_roles.any? %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% @application_roles.each do |role| %>
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<%= role.display_name %>
|
||||||
|
</span>
|
||||||
|
<% unless role.active %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if role.description.present? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Assigned Users -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% role.users.each do |user| %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= user.email_address %>
|
||||||
|
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||||
|
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
|
||||||
|
method: :post,
|
||||||
|
data: { confirm: "Remove role from #{user.email_address}?" },
|
||||||
|
class: "ml-1 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="ml-4 flex-shrink-0">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Assign Role to User -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<select id="assign-user-<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
<option value="">Assign to user...</option>
|
||||||
|
<% @available_users.each do |user| %>
|
||||||
|
<% unless role.user_has_role?(user) %>
|
||||||
|
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "REPLACE_USER_ID"),
|
||||||
|
method: :post,
|
||||||
|
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
|
||||||
|
onclick: "this.href = this.href.replace('REPLACE_USER_ID', document.getElementById('assign-user-<%= role.id %>').value); if (this.href.includes('undefined')) { alert('Please select a user'); return false; }" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Role -->
|
||||||
|
<%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Role Form (Hidden by default) -->
|
||||||
|
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4">
|
||||||
|
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center pt-6">
|
||||||
|
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||||
|
<%= link_to "Cancel", "#", 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", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-gray-500 text-sm">
|
||||||
|
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
179
app/views/admin/applications/roles_broken.html.erb
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||||
|
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
Role Management for <%= @application.name %>
|
||||||
|
</h3>
|
||||||
|
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @application.role_mapping_enabled? %>
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||||
|
<% if @application.role_claim_name.present? %>
|
||||||
|
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||||
|
<% end %>
|
||||||
|
<% if @application.role_prefix.present? %>
|
||||||
|
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Create New Role -->
|
||||||
|
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||||
|
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||||
|
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Roles -->
|
||||||
|
<div class="space-y-6" data-controller="role-management">
|
||||||
|
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||||
|
|
||||||
|
<% if @application_roles.any? %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% @application_roles.each do |role| %>
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<%= role.display_name %>
|
||||||
|
</span>
|
||||||
|
<% unless role.active %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if role.description.present? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Assigned Users -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% role.users.each do |user| %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= user.email_address %>
|
||||||
|
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||||
|
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
|
||||||
|
method: :post,
|
||||||
|
data: { confirm: "Remove role from #{user.email_address}?" },
|
||||||
|
class: "ml-1 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="ml-4 flex-shrink-0">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Assign Role to User -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<select id="assign-user-<%= role.id %>" data-role-target="userSelect" data-role-id="<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
<option value="">Assign to user...</option>
|
||||||
|
<% @available_users.each do |user| %>
|
||||||
|
<% unless role.user_has_role?(user) %>
|
||||||
|
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"),
|
||||||
|
method: :post,
|
||||||
|
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
|
||||||
|
data: { role_target: "assignLink", action: "click->role-management#assignRole" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Role -->
|
||||||
|
<%= link_to "Edit", "#",
|
||||||
|
class: "text-xs text-gray-600 hover:text-gray-800",
|
||||||
|
data: { action: "click->role-management#toggleEdit" },
|
||||||
|
data: { role_id: role.id } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Role Form (Hidden by default) -->
|
||||||
|
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4" data-role-target="editForm" data-role-id="<%= role.id %>">
|
||||||
|
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center pt-6">
|
||||||
|
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||||
|
<%= link_to "Cancel", "#",
|
||||||
|
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",
|
||||||
|
data: { action: "click->role-management#hideEdit" },
|
||||||
|
data: { role_id: role.id } %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-gray-500 text-sm">
|
||||||
|
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
173
app/views/admin/applications/roles_complex.html.erb
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||||
|
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
Role Management for <%= @application.name %>
|
||||||
|
</h3>
|
||||||
|
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @application.role_mapping_enabled? %>
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||||
|
<% if @application.role_claim_name.present? %>
|
||||||
|
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||||
|
<% end %>
|
||||||
|
<% if @application.role_prefix.present? %>
|
||||||
|
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Create New Role -->
|
||||||
|
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||||
|
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||||
|
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Roles -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||||
|
|
||||||
|
<% if @application_roles.any? %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% @application_roles.each do |role| %>
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<%= role.display_name %>
|
||||||
|
</span>
|
||||||
|
<% unless role.active %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if role.description.present? %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Assigned Users -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% role.users.each do |user| %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= user.email_address %>
|
||||||
|
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||||
|
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
|
||||||
|
method: :post,
|
||||||
|
data: { confirm: "Remove role from #{user.email_address}?" },
|
||||||
|
class: "ml-1 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="ml-4 flex-shrink-0">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Assign Role to User -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<select id="assign-user-<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||||
|
<option value="">Assign to user...</option>
|
||||||
|
<% @available_users.each do |user| %>
|
||||||
|
<% unless role.user_has_role?(user) %>
|
||||||
|
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"),
|
||||||
|
method: :post,
|
||||||
|
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
|
||||||
|
onclick: "var select = document.getElementById('assign-user-<%= role.id %>'); var userId = select.value; if (!userId) { alert('Please select a user'); return false; } this.href = this.href.replace('PLACEHOLDER', userId);" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Role -->
|
||||||
|
<%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Role Form (Hidden by default) -->
|
||||||
|
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4">
|
||||||
|
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center pt-6">
|
||||||
|
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||||
|
<%= link_to "Cancel", "#", 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", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-gray-500 text-sm">
|
||||||
|
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,21 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
|
<% if flash[:client_id] && flash[:client_secret] %>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
|
||||||
|
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
||||||
|
</div>
|
||||||
|
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||||
|
</div>
|
||||||
|
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
||||||
@@ -6,6 +23,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
<%= link_to "Edit", edit_admin_application_path(@application), 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" %>
|
<%= link_to "Edit", edit_admin_application_path(@application), 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" %>
|
||||||
|
<% if @application.oidc? %>
|
||||||
|
<%= link_to "Manage Roles", roles_admin_application_path(@application), class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" %>
|
||||||
|
<% end %>
|
||||||
<%= button_to "Delete", admin_application_path(@application), 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" %>
|
<%= button_to "Delete", admin_application_path(@application), 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>
|
||||||
@@ -64,7 +84,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<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"><%= @application.client_secret %></code>
|
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
||||||
|
🔒 Client secret is stored securely and cannot be displayed
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
To get a new client secret, use the "Regenerate Credentials" button above.
|
||||||
|
</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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).
|
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-full">
|
||||||
|
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||||
|
HTTP Headers Configuration
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-User" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-Email" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-Name" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-Groups" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-Admin" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||||
|
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
|
||||||
|
<ul class="text-sm text-blue-700 space-y-1">
|
||||||
|
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
|
||||||
|
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
|
||||||
|
<li>• <strong>Custom:</strong> Use application-specific headers</li>
|
||||||
|
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,89 +1,68 @@
|
|||||||
<% content_for :title, "Forward Auth Rules" %>
|
|
||||||
|
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-base font-semibold leading-6 text-gray-900">Forward Auth Rules</h1>
|
<h1 class="text-2xl font-semibold text-gray-900">Forward Auth Rules</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">A list of all forward authentication rules for domain-based access control.</p>
|
<p class="mt-2 text-sm text-gray-700">Manage forward authentication rules for domain-based access control.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
<%= link_to "New Rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flow-root">
|
<div class="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
<% if @forward_auth_rules.any? %>
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
<thead>
|
||||||
<table class="min-w-full divide-y divide-gray-300">
|
<tr>
|
||||||
<thead class="bg-gray-50">
|
<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>
|
||||||
<tr>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Headers</th>
|
||||||
<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">Groups</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
<span class="sr-only">Actions</span>
|
||||||
<span class="sr-only">Actions</span>
|
</th>
|
||||||
</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody class="divide-y divide-gray-200">
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
<% @forward_auth_rules.each do |rule| %>
|
||||||
<% @forward_auth_rules.each do |rule| %>
|
<tr>
|
||||||
<tr>
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
<%= link_to rule.domain_pattern, admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
<%= rule.domain_pattern %>
|
</td>
|
||||||
</td>
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<td class="px-3 py-4 text-sm text-gray-500">
|
<% if rule.headers_config.blank? %>
|
||||||
<% if rule.allowed_groups.any? %>
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
|
||||||
<div class="flex flex-wrap gap-1">
|
<% elsif rule.headers_config.values.all?(&:blank?) %>
|
||||||
<% rule.allowed_groups.each do |group| %>
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
|
||||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
<% else %>
|
||||||
<%= group.name %>
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
|
||||||
</span>
|
<% end %>
|
||||||
<% end %>
|
</td>
|
||||||
</div>
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<% else %>
|
<% if rule.allowed_groups.empty? %>
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
<span class="text-gray-400">All users</span>
|
||||||
Bypass (All Users)
|
<% else %>
|
||||||
</span>
|
<%= rule.allowed_groups.count %> groups
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<% if rule.active? %>
|
<% if rule.active? %>
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||||
Active
|
<% else %>
|
||||||
</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>
|
||||||
<% else %>
|
<% end %>
|
||||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
</td>
|
||||||
Inactive
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
</span>
|
<div class="flex justify-end space-x-3">
|
||||||
<% end %>
|
<%= link_to "View", admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
</td>
|
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
<%= 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" %>
|
||||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
</div>
|
||||||
<%= link_to "Delete", admin_forward_auth_rule_path(rule),
|
</td>
|
||||||
data: {
|
</tr>
|
||||||
turbo_method: :delete,
|
<% end %>
|
||||||
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
</tbody>
|
||||||
},
|
</table>
|
||||||
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 %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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).
|
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-full">
|
||||||
|
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||||
|
HTTP Headers Configuration
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-User" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-Email" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-Name" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-Groups" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||||
|
<div class="mt-2">
|
||||||
|
<%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
|
||||||
|
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||||
|
placeholder: "Remote-Admin" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||||
|
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
|
||||||
|
<ul class="text-sm text-blue-700 space-y-1">
|
||||||
|
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
|
||||||
|
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
|
||||||
|
<li>• <strong>Custom:</strong> Use application-specific headers</li>
|
||||||
|
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,110 +1,115 @@
|
|||||||
<% content_for :title, "Forward Auth Rule: #{@forward_auth_rule.domain_pattern}" %>
|
<div class="mb-6">
|
||||||
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
<div class="md:flex md:items-center md:justify-between">
|
<div>
|
||||||
<div class="min-w-0 flex-1">
|
<h1 class="text-2xl font-semibold text-gray-900"><%= @forward_auth_rule.domain_pattern %></h1>
|
||||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
<p class="mt-1 text-sm text-gray-500">Forward authentication rule for domain-based access control</p>
|
||||||
<%= @forward_auth_rule.domain_pattern %>
|
</div>
|
||||||
</h2>
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
</div>
|
<%= 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" %>
|
||||||
<div class="mt-4 flex md:ml-4 md:mt-0">
|
<%= 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" %>
|
||||||
<%= 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" %>
|
</div>
|
||||||
<%= link_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule),
|
|
||||||
data: {
|
|
||||||
turbo_method: :delete,
|
|
||||||
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
|
||||||
},
|
|
||||||
class: "ml-3 inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="space-y-6">
|
||||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
<!-- Basic Information -->
|
||||||
<div class="px-4 py-5 sm:px-6">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Rule Details</h3>
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Forward authentication rule configuration.</p>
|
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Basic Information</h3>
|
||||||
</div>
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
<div class="border-t border-gray-200">
|
<div>
|
||||||
<dl>
|
|
||||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900"><code class="bg-gray-100 px-2 py-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></dd>
|
||||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @forward_auth_rule.domain_pattern %></code>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<% if @forward_auth_rule.active? %>
|
<% if @forward_auth_rule.active? %>
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Access Policy</dt>
|
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<% if @allowed_groups.any? %>
|
<% if @forward_auth_rule.headers_config.blank? %>
|
||||||
<div class="space-y-2">
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
|
||||||
<p class="text-sm">Only users in these groups are allowed access:</p>
|
<% elsif @forward_auth_rule.headers_config.values.all?(&:blank?) %>
|
||||||
<div class="flex flex-wrap gap-2">
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
|
||||||
<% @allowed_groups.each do |group| %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
|
||||||
<%= group.name %>
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
|
||||||
Bypass - All authenticated users allowed
|
|
||||||
</span>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
|
||||||
<%= @forward_auth_rule.created_at.strftime("%B %d, %Y at %I:%M %p") %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
|
||||||
<%= @forward_auth_rule.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
<!-- Header Configuration -->
|
||||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="flex">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<div class="flex-shrink-0">
|
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Header Configuration</h3>
|
||||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
<div class="space-y-4">
|
||||||
<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" />
|
<% effective_headers = @forward_auth_rule.effective_headers %>
|
||||||
</svg>
|
|
||||||
</div>
|
<% if effective_headers.empty? %>
|
||||||
<div class="ml-3">
|
<div class="rounded-md bg-gray-50 p-4">
|
||||||
<h3 class="text-sm font-medium text-blue-800">How this rule works</h3>
|
<div class="flex">
|
||||||
<div class="mt-2 text-sm text-blue-700">
|
<div class="ml-3">
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<p class="text-sm text-gray-700">
|
||||||
<li>This rule matches domains that fit the pattern: <code class="bg-blue-100 px-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></li>
|
No headers configured - access control only.
|
||||||
<% if @allowed_groups.any? %>
|
</p>
|
||||||
<li>Only users belonging to the specified groups will be granted access</li>
|
</div>
|
||||||
<li>Users will be required to authenticate with password (and 2FA if enabled)</li>
|
</div>
|
||||||
<% else %>
|
</div>
|
||||||
<li>All authenticated users will be granted access (bypass mode)</li>
|
<% 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 %>
|
<% end %>
|
||||||
<li>Inactive rules are ignored during authentication</li>
|
</dl>
|
||||||
</ul>
|
<% end %>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
@@ -66,8 +99,17 @@
|
|||||||
<%= user.groups.count %>
|
<%= user.groups.count %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<div class="flex justify-end space-x-3">
|
||||||
<%= 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" %>
|
<% 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
22
app/views/invitations/show.html.erb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<div class="mx-auto md:w-2/3 w-full">
|
||||||
|
<% if alert = flash[:alert] %>
|
||||||
|
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h1 class="font-bold text-4xl">Welcome to Clinch!</h1>
|
||||||
|
<p class="mt-2 text-gray-600">You've been invited to join Clinch. Please create your password to complete your account setup.</p>
|
||||||
|
|
||||||
|
<%= form_with url: invitation_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline">
|
||||||
|
<%= form.submit "Create Account", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
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
@@ -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>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<!-- Public layout (signup/signin) -->
|
<!-- 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" %>
|
<%= render "shared/flash" %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3" do |form| %>
|
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %>
|
||||||
<%= form.submit "Authorize",
|
<%= form.submit "Authorize",
|
||||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Profile & Settings</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">Manage your account settings and security preferences.</p>
|
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Information -->
|
<!-- Account Information -->
|
||||||
@@ -199,6 +199,44 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Connected Applications -->
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Connected Applications</h3>
|
||||||
|
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||||
|
<p>These applications have access to your account. You can revoke access at any time.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<% if @connected_applications.any? %>
|
||||||
|
<ul role="list" class="divide-y divide-gray-200">
|
||||||
|
<% @connected_applications.each do |consent| %>
|
||||||
|
<li class="py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
<%= consent.application.name %>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Access to: <%= consent.formatted_scopes %>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
Authorized <%= time_ago_in_words(consent.granted_at) %> ago
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<%= button_to "Revoke Access", revoke_consent_profile_path(application_id: consent.application.id), method: :delete,
|
||||||
|
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||||
|
form: { data: { turbo_confirm: "Are you sure you want to revoke access to #{consent.application.name}? You'll need to re-authorize this application to use it again." } } %>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500">No connected applications.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Active Sessions -->
|
<!-- Active Sessions -->
|
||||||
<div class="bg-white shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
@@ -243,4 +281,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Security Actions -->
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Security Actions</h3>
|
||||||
|
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||||
|
<p>Use these actions to quickly secure your account. Be careful - these actions cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex flex-wrap gap-4">
|
||||||
|
<% if @active_sessions.count > 1 %>
|
||||||
|
<%= button_to "Sign Out Everywhere Else", session_path(Current.session), method: :delete,
|
||||||
|
class: "inline-flex items-center rounded-md border border-orange-300 bg-white px-4 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2",
|
||||||
|
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @connected_applications.any? %>
|
||||||
|
<%= button_to "Revoke All App Access", revoke_all_consents_profile_path, method: :delete,
|
||||||
|
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||||
|
form: { data: { turbo_confirm: "This will revoke access from all connected applications. You'll need to re-authorize each application to use them again. Are you sure?" } } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6" do |form| %>
|
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6", data: {
|
||||||
|
controller: "form-submit-protection",
|
||||||
|
turbo: false
|
||||||
|
} do |form| %>
|
||||||
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.submit "Verify",
|
<%= form.submit "Verify",
|
||||||
|
data: { form_submit_protection_target: "submit" },
|
||||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -23,5 +23,18 @@ module Clinch
|
|||||||
#
|
#
|
||||||
# config.time_zone = "Central Time (US & Canada)"
|
# config.time_zone = "Central Time (US & Canada)"
|
||||||
# config.eager_load_paths << Rails.root.join("extras")
|
# 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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ Rails.application.configure do
|
|||||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||||
config.active_storage.service = :local
|
config.active_storage.service = :local
|
||||||
|
|
||||||
# Don't care if the mailer can't send.
|
# Preview emails in browser using letter_opener
|
||||||
config.action_mailer.raise_delivery_errors = false
|
config.action_mailer.delivery_method = :letter_opener
|
||||||
|
config.action_mailer.perform_deliveries = true
|
||||||
|
|
||||||
# Make template changes take effect immediately.
|
# Make template changes take effect immediately.
|
||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
@@ -58,9 +59,8 @@ Rails.application.configure do
|
|||||||
# Highlight code that enqueued background job in logs.
|
# Highlight code that enqueued background job in logs.
|
||||||
config.active_job.verbose_enqueue_logs = true
|
config.active_job.verbose_enqueue_logs = true
|
||||||
|
|
||||||
# Use Solid Queue for background jobs (same as production).
|
# Use async processor for background jobs in development
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :async
|
||||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
||||||
|
|
||||||
|
|
||||||
# Highlight code that triggered redirect in logs.
|
# 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.
|
# Replace the default in-process memory cache store with a durable alternative.
|
||||||
config.cache_store = :solid_cache_store
|
config.cache_store = :solid_cache_store
|
||||||
|
|
||||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
# Use async processor for background jobs (modify as needed for production)
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :async
|
||||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# 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.
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||||
# config.action_mailer.raise_delivery_errors = false
|
# config.action_mailer.raise_delivery_errors = false
|
||||||
|
|
||||||
# Set host to be used by links generated in mailer templates.
|
# 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.
|
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||||
# config.action_mailer.smtp_settings = {
|
# config.action_mailer.smtp_settings = {
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ threads threads_count, threads_count
|
|||||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||||
port ENV.fetch("PORT", 3000)
|
port ENV.fetch("PORT", 3000)
|
||||||
|
|
||||||
|
|
||||||
# Allow puma to be restarted by `bin/rails restart` command.
|
# Allow puma to be restarted by `bin/rails restart` command.
|
||||||
plugin :tmp_restart
|
plugin :tmp_restart
|
||||||
|
|
||||||
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
# Solid Queue plugin removed - now using async processor
|
||||||
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
|
||||||
|
|
||||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||||
# In other environments, only set the PID file if requested.
|
# 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
|
Rails.application.routes.draw do
|
||||||
resource :session
|
resource :session
|
||||||
resources :passwords, param: :token
|
resources :passwords, param: :token
|
||||||
|
resources :invitations, param: :token, only: [:show, :update]
|
||||||
mount ActionCable.server => "/cable"
|
mount ActionCable.server => "/cable"
|
||||||
|
|
||||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||||
@@ -25,6 +26,7 @@ Rails.application.routes.draw do
|
|||||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||||
post "/oauth/token", to: "oidc#token"
|
post "/oauth/token", to: "oidc#token"
|
||||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||||
|
get "/logout", to: "oidc#logout"
|
||||||
|
|
||||||
# ForwardAuth / Trusted Header SSO
|
# ForwardAuth / Trusted Header SSO
|
||||||
namespace :api do
|
namespace :api do
|
||||||
@@ -33,7 +35,12 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
# Authenticated routes
|
# Authenticated routes
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
resource :profile, only: [:show, :update]
|
resource :profile, only: [:show, :update] do
|
||||||
|
member do
|
||||||
|
delete :revoke_consent
|
||||||
|
delete :revoke_all_consents
|
||||||
|
end
|
||||||
|
end
|
||||||
resources :sessions, only: [] do
|
resources :sessions, only: [] do
|
||||||
member do
|
member do
|
||||||
delete :destroy, action: :destroy_other
|
delete :destroy, action: :destroy_other
|
||||||
@@ -50,10 +57,19 @@ Rails.application.routes.draw do
|
|||||||
# Admin routes
|
# Admin routes
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
resources :users
|
resources :users do
|
||||||
|
member do
|
||||||
|
post :resend_invitation
|
||||||
|
end
|
||||||
|
end
|
||||||
resources :applications do
|
resources :applications do
|
||||||
member do
|
member do
|
||||||
post :regenerate_credentials
|
post :regenerate_credentials
|
||||||
|
get :roles
|
||||||
|
post :create_role
|
||||||
|
patch :update_role
|
||||||
|
post :assign_role
|
||||||
|
post :remove_role
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :groups
|
resources :groups
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
class AddRoleMappingToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :role_mapping_mode, :string, default: 'disabled', null: false
|
||||||
|
add_column :applications, :role_prefix, :string
|
||||||
|
add_column :applications, :managed_permissions, :json, default: {}
|
||||||
|
add_column :applications, :role_claim_name, :string, default: 'roles'
|
||||||
|
|
||||||
|
create_table :application_roles do |t|
|
||||||
|
t.references :application, null: false, foreign_key: true
|
||||||
|
t.string :name, null: false
|
||||||
|
t.string :display_name
|
||||||
|
t.text :description
|
||||||
|
t.json :permissions, default: {}
|
||||||
|
t.boolean :active, default: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :application_roles, [:application_id, :name], unique: true
|
||||||
|
|
||||||
|
create_table :user_role_assignments do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.references :application_role, null: false, foreign_key: true
|
||||||
|
t.string :source, default: 'oidc' # 'oidc', 'manual', 'group_sync'
|
||||||
|
t.json :metadata, default: {}
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :user_role_assignments, [:user_id, :application_role_id], unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddDescriptionToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :description, :text
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class AddClientSecretHashToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :client_secret_hash, :string
|
||||||
|
remove_column :applications, :client_secret, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class RenameClientSecretHashToClientSecretDigest < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
rename_column :applications, :client_secret_hash, :client_secret_digest
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddNonceToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_authorization_codes, :nonce, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
17
db/migrate/20251024055739_create_oidc_user_consents.rb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class CreateOidcUserConsents < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :oidc_user_consents do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.references :application, null: false, foreign_key: true
|
||||||
|
t.text :scopes_granted, null: false
|
||||||
|
t.datetime :granted_at, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add unique index to prevent duplicate consent records
|
||||||
|
add_index :oidc_user_consents, [:user_id, :application_id], unique: true
|
||||||
|
# Add index for querying recent consents
|
||||||
|
add_index :oidc_user_consents, :granted_at
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
55
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
ActiveRecord::Schema[8.1].define(version: 2025_10_26_113035) do
|
||||||
create_table "application_groups", force: :cascade do |t|
|
create_table "application_groups", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -21,15 +21,33 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
|||||||
t.index ["group_id"], name: "index_application_groups_on_group_id"
|
t.index ["group_id"], name: "index_application_groups_on_group_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "application_roles", force: :cascade do |t|
|
||||||
|
t.boolean "active", default: true
|
||||||
|
t.integer "application_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.text "description"
|
||||||
|
t.string "display_name"
|
||||||
|
t.string "name", null: false
|
||||||
|
t.json "permissions", default: {}
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["application_id", "name"], name: "index_application_roles_on_application_id_and_name", unique: true
|
||||||
|
t.index ["application_id"], name: "index_application_roles_on_application_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "applications", force: :cascade do |t|
|
create_table "applications", force: :cascade do |t|
|
||||||
t.boolean "active", default: true, null: false
|
t.boolean "active", default: true, null: false
|
||||||
t.string "app_type", null: false
|
t.string "app_type", null: false
|
||||||
t.string "client_id"
|
t.string "client_id"
|
||||||
t.string "client_secret"
|
t.string "client_secret_digest"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
t.text "description"
|
||||||
|
t.json "managed_permissions", default: {}
|
||||||
t.text "metadata"
|
t.text "metadata"
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.text "redirect_uris"
|
t.text "redirect_uris"
|
||||||
|
t.string "role_claim_name", default: "roles"
|
||||||
|
t.string "role_mapping_mode", default: "disabled", null: false
|
||||||
|
t.string "role_prefix"
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["active"], name: "index_applications_on_active"
|
t.index ["active"], name: "index_applications_on_active"
|
||||||
@@ -50,6 +68,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
|||||||
t.boolean "active"
|
t.boolean "active"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "domain_pattern"
|
t.string "domain_pattern"
|
||||||
|
t.json "headers_config", default: {}, null: false
|
||||||
t.integer "policy"
|
t.integer "policy"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
end
|
end
|
||||||
@@ -82,6 +101,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
|||||||
t.string "code", null: false
|
t.string "code", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "expires_at", null: false
|
t.datetime "expires_at", null: false
|
||||||
|
t.string "nonce"
|
||||||
t.string "redirect_uri", null: false
|
t.string "redirect_uri", null: false
|
||||||
t.string "scope"
|
t.string "scope"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
@@ -94,6 +114,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
|||||||
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "oidc_user_consents", force: :cascade do |t|
|
||||||
|
t.integer "application_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "granted_at", null: false
|
||||||
|
t.text "scopes_granted", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.index ["application_id"], name: "index_oidc_user_consents_on_application_id"
|
||||||
|
t.index ["granted_at"], name: "index_oidc_user_consents_on_granted_at"
|
||||||
|
t.index ["user_id", "application_id"], name: "index_oidc_user_consents_on_user_id_and_application_id", unique: true
|
||||||
|
t.index ["user_id"], name: "index_oidc_user_consents_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "sessions", force: :cascade do |t|
|
create_table "sessions", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "device_name"
|
t.string "device_name"
|
||||||
@@ -119,11 +152,24 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
|||||||
t.index ["user_id"], name: "index_user_groups_on_user_id"
|
t.index ["user_id"], name: "index_user_groups_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "user_role_assignments", force: :cascade do |t|
|
||||||
|
t.integer "application_role_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.json "metadata", default: {}
|
||||||
|
t.string "source", default: "oidc"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.index ["application_role_id"], name: "index_user_role_assignments_on_application_role_id"
|
||||||
|
t.index ["user_id", "application_role_id"], name: "index_user_role_assignments_on_user_id_and_application_role_id", unique: true
|
||||||
|
t.index ["user_id"], name: "index_user_role_assignments_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "users", force: :cascade do |t|
|
create_table "users", force: :cascade do |t|
|
||||||
t.boolean "admin", default: false, null: false
|
t.boolean "admin", default: false, null: false
|
||||||
t.text "backup_codes"
|
t.text "backup_codes"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "email_address", null: false
|
t.string "email_address", null: false
|
||||||
|
t.datetime "last_sign_in_at"
|
||||||
t.string "password_digest", null: false
|
t.string "password_digest", null: false
|
||||||
t.integer "status", default: 0, null: false
|
t.integer "status", default: 0, null: false
|
||||||
t.boolean "totp_required", default: false, null: false
|
t.boolean "totp_required", default: false, null: false
|
||||||
@@ -135,13 +181,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
|||||||
|
|
||||||
add_foreign_key "application_groups", "applications"
|
add_foreign_key "application_groups", "applications"
|
||||||
add_foreign_key "application_groups", "groups"
|
add_foreign_key "application_groups", "groups"
|
||||||
|
add_foreign_key "application_roles", "applications"
|
||||||
add_foreign_key "forward_auth_rule_groups", "forward_auth_rules"
|
add_foreign_key "forward_auth_rule_groups", "forward_auth_rules"
|
||||||
add_foreign_key "forward_auth_rule_groups", "groups"
|
add_foreign_key "forward_auth_rule_groups", "groups"
|
||||||
add_foreign_key "oidc_access_tokens", "applications"
|
add_foreign_key "oidc_access_tokens", "applications"
|
||||||
add_foreign_key "oidc_access_tokens", "users"
|
add_foreign_key "oidc_access_tokens", "users"
|
||||||
add_foreign_key "oidc_authorization_codes", "applications"
|
add_foreign_key "oidc_authorization_codes", "applications"
|
||||||
add_foreign_key "oidc_authorization_codes", "users"
|
add_foreign_key "oidc_authorization_codes", "users"
|
||||||
|
add_foreign_key "oidc_user_consents", "applications"
|
||||||
|
add_foreign_key "oidc_user_consents", "users"
|
||||||
add_foreign_key "sessions", "users"
|
add_foreign_key "sessions", "users"
|
||||||
add_foreign_key "user_groups", "groups"
|
add_foreign_key "user_groups", "groups"
|
||||||
add_foreign_key "user_groups", "users"
|
add_foreign_key "user_groups", "users"
|
||||||
|
add_foreign_key "user_role_assignments", "application_roles"
|
||||||
|
add_foreign_key "user_role_assignments", "users"
|
||||||
end
|
end
|
||||||
|
|||||||
231
docs/forward-auth.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Forward Authentication
|
||||||
|
|
||||||
|
## 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.example.com/signin?rd=https://metube.example.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.example.com -> .example.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: ".example.com" # Available to all subdomains
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows the same session cookie to work across:
|
||||||
|
- `clinch.example.com` (auth service)
|
||||||
|
- `metube.example.com` (protected app)
|
||||||
|
- `sonarr.example.com` (protected app)
|
||||||
|
|
||||||
|
### Tip 3: Race Condition Solution with One-Time Tokens ✅
|
||||||
|
|
||||||
|
**Problem**: After successful authentication, there's a race condition where the browser immediately follows the redirect to the protected application, but the reverse proxy makes a forward auth request before the browser has processed and started sending the new session cookie.
|
||||||
|
|
||||||
|
**Solution**: Clinch uses a one-time token system to bridge this timing gap:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# During authentication (authentication.rb)
|
||||||
|
def create_forward_auth_token(session_obj)
|
||||||
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
|
|
||||||
|
# Store token for 30 seconds
|
||||||
|
Rails.cache.write("forward_auth_token:#{token}", session_obj.id, expires_in: 30.seconds)
|
||||||
|
|
||||||
|
# Add token to redirect URL
|
||||||
|
if session[:return_to_after_authenticating].present?
|
||||||
|
original_url = session[:return_to_after_authenticating]
|
||||||
|
uri = URI.parse(original_url)
|
||||||
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||||
|
query_params['fa_token'] = token
|
||||||
|
uri.query = URI.encode_www_form(query_params)
|
||||||
|
session[:return_to_after_authenticating] = uri.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# In forward auth verification (forward_auth_controller.rb)
|
||||||
|
def check_forward_auth_token
|
||||||
|
token = params[:fa_token]
|
||||||
|
return nil unless token.present?
|
||||||
|
|
||||||
|
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
||||||
|
return nil unless session_id
|
||||||
|
|
||||||
|
session = Session.find_by(id: session_id)
|
||||||
|
return nil unless session && !session.expired?
|
||||||
|
|
||||||
|
# Delete token immediately (one-time use)
|
||||||
|
Rails.cache.delete("forward_auth_token:#{token}")
|
||||||
|
|
||||||
|
Rails.logger.info "ForwardAuth: Valid one-time token used for session #{session_id}"
|
||||||
|
session_id
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. User authenticates → Rails sets session cookie + generates one-time token
|
||||||
|
2. Token gets appended to redirect URL: `https://metube.example.com/?fa_token=abc123...`
|
||||||
|
3. Browser follows redirect → Caddy makes forward auth request with token
|
||||||
|
4. Forward auth validates token → authenticates user immediately
|
||||||
|
5. Token is deleted (one-time use) → subsequent requests use normal cookies
|
||||||
|
|
||||||
|
**Security Features:**
|
||||||
|
- Tokens expire after 30 seconds
|
||||||
|
- One-time use (deleted after validation)
|
||||||
|
- Secure random generation
|
||||||
|
- Session validation before token acceptance
|
||||||
|
|
||||||
|
## 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.example.com/`
|
||||||
|
2. **Caddy forwards** to `http://clinch:9000/api/verify?rd=https://clinch.example.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** (with TOTP if enabled):
|
||||||
|
- Rails creates session and sets cross-domain cookie
|
||||||
|
- **Rails generates one-time token** and appends to redirect URL
|
||||||
|
- User is redirected to: `https://metube.example.com/?fa_token=abc123...`
|
||||||
|
6. **Browser follows redirect** → Caddy makes forward auth request with token
|
||||||
|
7. **Clinch validates one-time token** → authenticates user immediately
|
||||||
|
8. **Token is deleted** → subsequent requests use normal session cookies
|
||||||
|
9. **Caddy forwards to MEtube** with proper authentication headers
|
||||||
|
|
||||||
|
### 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.example.com/signin?rd=https://metube.example.com/&rm=GET
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caddy Configuration
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
# Clinch SSO (main authentication server)
|
||||||
|
clinch.example.com {
|
||||||
|
reverse_proxy clinch:9000
|
||||||
|
}
|
||||||
|
|
||||||
|
# MEtube (protected by Clinch)
|
||||||
|
metube.example.com {
|
||||||
|
forward_auth clinch:9000 {
|
||||||
|
uri /api/verify?rd=https://clinch.example.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.example.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
|
||||||
|
4. **Race Condition After Authentication**:
|
||||||
|
- **Problem**: Forward auth fails immediately after login due to cookie timing
|
||||||
|
- **Solution**: One-time tokens automatically bridge this gap
|
||||||
|
- **Debug**: Look for "ForwardAuth: Valid one-time token used" in logs
|
||||||
|
|
||||||
|
### Debug Logging
|
||||||
|
|
||||||
|
Enable debug logging in `forward_auth_controller.rb` to see:
|
||||||
|
- Headers received from Caddy
|
||||||
|
- Domain extraction results
|
||||||
|
- Redirect URLs being generated
|
||||||
|
- Token validation during race condition resolution
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
Rails.logger.info "ForwardAuth Headers: Host=#{host}, X-Forwarded-Host=#{original_host}"
|
||||||
|
Rails.logger.info "Setting 302 redirect to: #{login_url}"
|
||||||
|
Rails.logger.info "ForwardAuth: Valid one-time token used for session #{session_id}"
|
||||||
|
Rails.logger.info "Authentication: Added forward auth token to redirect URL: #{url}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key log messages to watch for:**
|
||||||
|
- `"Authentication: Added forward auth token to redirect URL"` - Token generation during login
|
||||||
|
- `"ForwardAuth: Valid one-time token used for session X"` - Successful race condition resolution
|
||||||
|
- `"ForwardAuth: Session cookie present: false"` - Cookie timing issue (should be resolved by token)
|
||||||
|
|
||||||
|
## Other 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
|
||||||
BIN
docs/screenshots/0-dashboard.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
docs/screenshots/1-signin.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/screenshots/2-signin.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/screenshots/3-users.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
docs/screenshots/4-welcome.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/screenshots/5-welcome-2.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/screenshots/6-setup-2fa.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/screenshots/7-forward-auth-1.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/screenshots/8-forward-auth-2.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/screenshots/thumbs/0-dashboard.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/screenshots/thumbs/1-signin.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
docs/screenshots/thumbs/2-signin.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/screenshots/thumbs/3-users.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/screenshots/thumbs/4-welcome.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/screenshots/thumbs/5-welcome-2.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/screenshots/thumbs/6-setup-2fa.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/screenshots/thumbs/7-forward-auth-1.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/screenshots/thumbs/8-forward-auth-2.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
655
test/controllers/api/forward_auth_controller_test.rb
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class ForwardAuthControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:bob)
|
||||||
|
@admin_user = users(:alice)
|
||||||
|
@inactive_user = users(:bob) # We'll create an inactive user in setup if needed
|
||||||
|
@group = groups(:admin_group)
|
||||||
|
@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 @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-Email"]
|
||||||
|
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
|
||||||
|
|
||||||
|
# Open Redirect Security Tests
|
||||||
|
test "should redirect to malicious external domain when rd parameter is provided" do
|
||||||
|
# This test demonstrates the current vulnerability
|
||||||
|
evil_url = "https://evil-phishing-site.com/steal-credentials"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: evil_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Current vulnerable behavior: redirects to the evil URL
|
||||||
|
assert_match evil_url, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect to http scheme when rd parameter uses http" do
|
||||||
|
# This test shows we can redirect to non-HTTPS sites
|
||||||
|
http_url = "http://insecure-site.com/login"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: http_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_match http_url, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect to data URLs when rd parameter contains data scheme" do
|
||||||
|
# This test shows we can redirect to data URLs (XSS potential)
|
||||||
|
data_url = "data:text/html,<script>alert('XSS')</script>"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: data_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Currently redirects to data URL (XSS vulnerability)
|
||||||
|
assert_match data_url, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect to javascript URLs when rd parameter contains javascript scheme" do
|
||||||
|
# This test shows we can redirect to javascript URLs (XSS potential)
|
||||||
|
js_url = "javascript:alert('XSS')"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: js_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Currently redirects to JavaScript URL (XSS vulnerability)
|
||||||
|
assert_match js_url, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect to domain with no ForwardAuthRule when rd parameter is arbitrary" do
|
||||||
|
# This test shows we can redirect to domains not configured in ForwardAuthRules
|
||||||
|
unconfigured_domain = "https://unconfigured-domain.com/admin"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: unconfigured_domain }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Currently redirects to unconfigured domain
|
||||||
|
assert_match unconfigured_domain, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should reject malicious redirect URL through session after authentication (SECURE BEHAVIOR)" do
|
||||||
|
# This test shows malicious URLs are filtered out through the auth flow
|
||||||
|
evil_url = "https://evil-site.com/fake-login"
|
||||||
|
|
||||||
|
# Step 1: Request with malicious redirect URL
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"X-Forwarded-Uri" => "/admin"
|
||||||
|
}, params: { rd: evil_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
|
||||||
|
# Step 2: Check that malicious URL is filtered out and legitimate URL is stored
|
||||||
|
stored_url = session[:return_to_after_authenticating]
|
||||||
|
refute_match evil_url, stored_url, "Malicious URL should not be stored in session"
|
||||||
|
assert_match "test.example.com", stored_url, "Should store legitimate URL from X-Forwarded-Host"
|
||||||
|
|
||||||
|
# Step 3: Authenticate and check redirect
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password",
|
||||||
|
rd: evil_url # Ensure the rd parameter is preserved in login
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Should NOT redirect to evil URL after successful authentication
|
||||||
|
refute_match evil_url, response.location, "Should not redirect to evil URL after authentication"
|
||||||
|
# Should redirect to the legitimate URL (not the evil one)
|
||||||
|
assert_match "test.example.com", response.location, "Should redirect to legitimate domain"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect to domain that looks similar but not in ForwardAuthRules" do
|
||||||
|
# Create rule for test.example.com
|
||||||
|
test_rule = ForwardAuthRule.create!(domain_pattern: "test.example.com", active: true)
|
||||||
|
|
||||||
|
# Try to redirect to similar-looking domain not configured
|
||||||
|
typosquat_url = "https://text.example.com/admin" # Note: 'text' instead of 'test'
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: typosquat_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Currently redirects to typosquat domain
|
||||||
|
assert_match typosquat_url, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect to subdomain that is not covered by ForwardAuthRules" do
|
||||||
|
# Create rule for app.example.com
|
||||||
|
app_rule = ForwardAuthRule.create!(domain_pattern: "app.example.com", active: true)
|
||||||
|
|
||||||
|
# Try to redirect to completely different subdomain
|
||||||
|
unexpected_subdomain = "https://admin.example.com/panel"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app.example.com" },
|
||||||
|
params: { rd: unexpected_subdomain }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Currently redirects to unexpected subdomain
|
||||||
|
assert_match unexpected_subdomain, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tests for the desired secure behavior (these should fail with current implementation)
|
||||||
|
test "should ONLY allow redirects to domains with matching ForwardAuthRules (SECURE BEHAVIOR)" do
|
||||||
|
# Use existing rule for test.example.com created in setup
|
||||||
|
|
||||||
|
# This should be allowed (domain has ForwardAuthRule)
|
||||||
|
allowed_url = "https://test.example.com/dashboard"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: allowed_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
assert_match allowed_url, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should REJECT redirects to domains without matching ForwardAuthRules (SECURE BEHAVIOR)" do
|
||||||
|
# Use existing rule for test.example.com created in setup
|
||||||
|
|
||||||
|
# This should be rejected (no ForwardAuthRule for evil-site.com)
|
||||||
|
evil_url = "https://evil-site.com/steal-credentials"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: evil_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Should redirect to login page or default URL, NOT to evil_url
|
||||||
|
refute_match evil_url, response.location
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should REJECT redirects to non-HTTPS URLs in production (SECURE BEHAVIOR)" do
|
||||||
|
# Use existing rule for test.example.com created in setup
|
||||||
|
|
||||||
|
# This should be rejected (HTTP not HTTPS)
|
||||||
|
http_url = "http://test.example.com/dashboard"
|
||||||
|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: http_url }
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Should redirect to login page or default URL, NOT to HTTP URL
|
||||||
|
refute_match http_url, response.location
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should REJECT redirects to dangerous URL schemes (SECURE BEHAVIOR)" do
|
||||||
|
# Use existing rule for test.example.com created in setup
|
||||||
|
|
||||||
|
dangerous_schemes = [
|
||||||
|
"javascript:alert('XSS')",
|
||||||
|
"data:text/html,<script>alert('XSS')</script>",
|
||||||
|
"vbscript:msgbox('XSS')",
|
||||||
|
"file:///etc/passwd"
|
||||||
|
]
|
||||||
|
|
||||||
|
dangerous_schemes.each do |dangerous_url|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" },
|
||||||
|
params: { rd: dangerous_url }
|
||||||
|
|
||||||
|
assert_response 302, "Should reject dangerous URL: #{dangerous_url}"
|
||||||
|
# Should redirect to login page or default URL, NOT to dangerous URL
|
||||||
|
refute_match dangerous_url, response.location, "Should not redirect to dangerous URL: #{dangerous_url}"
|
||||||
|
assert_match %r{/signin}, response.location, "Should redirect to login for dangerous URL: #{dangerous_url}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# HTTP Method Specific Tests (based on Authelia approach)
|
||||||
|
test "should handle different HTTP methods with appropriate redirect codes" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
# Test GET requests should return 302 Found
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 200 # Authenticated user gets 200
|
||||||
|
|
||||||
|
# Test POST requests should work the same for authenticated users
|
||||||
|
post "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should return 403 for non-authenticated POST requests instead of redirect" do
|
||||||
|
# This follows Authelia's pattern where non-GET requests to protected resources
|
||||||
|
# should return 403 when unauthenticated, not redirects
|
||||||
|
post "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 302 # Our implementation still redirects to login
|
||||||
|
# Note: Could be enhanced to return 403 for non-GET methods
|
||||||
|
end
|
||||||
|
|
||||||
|
# XHR/Fetch Request Tests
|
||||||
|
test "should handle XHR requests appropriately" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"X-Requested-With" => "XMLHttpRequest"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# XHR requests should still redirect in our implementation
|
||||||
|
# Authelia returns 401 for XHR, but that may not be suitable for all reverse proxies
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle requests with JSON Accept headers" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"Accept" => "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 302
|
||||||
|
# Our implementation still redirects, which is appropriate for reverse proxy scenarios
|
||||||
|
end
|
||||||
|
|
||||||
|
# Edge Case and Security Tests
|
||||||
|
test "should handle missing X-Forwarded-Host header gracefully" do
|
||||||
|
get "/api/verify"
|
||||||
|
|
||||||
|
# Should handle missing headers gracefully
|
||||||
|
assert_response 302
|
||||||
|
assert_match %r{/signin}, response.location
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle malformed X-Forwarded-Host header" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "invalid[host]with[special]chars"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should handle malformed host gracefully
|
||||||
|
assert_response 302
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle very long X-Forwarded-Host header" do
|
||||||
|
long_host = "a" * 300 + ".example.com"
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => long_host
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should handle long host names gracefully
|
||||||
|
assert_response 302
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle special characters in X-Forwarded-URI" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"X-Forwarded-Uri" => "/path/with%20spaces/and-special-chars?param=value&other=123"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle unicode in X-Forwarded-Host" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "测试.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
# Protocol and Scheme Tests
|
||||||
|
test "should handle X-Forwarded-Proto header" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"X-Forwarded-Proto" => "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
sign_in_as(@user)
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle HTTP protocol in X-Forwarded-Proto" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com",
|
||||||
|
"X-Forwarded-Proto" => "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
sign_in_as(@user)
|
||||||
|
assert_response 200
|
||||||
|
# Note: Our implementation doesn't enforce protocol matching
|
||||||
|
end
|
||||||
|
|
||||||
|
# Session and State Tests
|
||||||
|
test "should maintain session across multiple requests" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
# First request
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
|
||||||
|
# Second request with same session
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "test.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
|
||||||
|
# Should maintain user identity across requests
|
||||||
|
assert_equal @user.email_address, response.headers["X-Remote-User"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle concurrent requests with same session" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
# Simulate multiple concurrent requests
|
||||||
|
threads = []
|
||||||
|
results = []
|
||||||
|
|
||||||
|
5.times do |i|
|
||||||
|
threads << Thread.new do
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||||
|
results << { status: response.status, user: response.headers["X-Remote-User"] }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
threads.each(&:join)
|
||||||
|
|
||||||
|
# All requests should succeed
|
||||||
|
results.each do |result|
|
||||||
|
assert_equal 200, result[:status]
|
||||||
|
assert_equal @user.email_address, result[:user]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Header Injection and Security Tests
|
||||||
|
test "should handle malicious header injection attempts" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com\r\nMalicious-Header: injected-value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should handle header injection attempts
|
||||||
|
assert_response 302
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle null byte injection in headers" do
|
||||||
|
get "/api/verify", headers: {
|
||||||
|
"X-Forwarded-Host" => "test.example.com\0.evil.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
sign_in_as(@user)
|
||||||
|
# Should handle null bytes safely
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
# Performance and Load Tests
|
||||||
|
test "should handle requests efficiently under load" do
|
||||||
|
sign_in_as(@user)
|
||||||
|
|
||||||
|
start_time = Time.current
|
||||||
|
request_count = 10
|
||||||
|
|
||||||
|
request_count.times do |i|
|
||||||
|
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||||
|
assert_response 200
|
||||||
|
end
|
||||||
|
|
||||||
|
total_time = Time.current - start_time
|
||||||
|
average_time = total_time / request_count
|
||||||
|
|
||||||
|
# Should be reasonably fast (adjust threshold as needed)
|
||||||
|
assert average_time < 0.1, "Average request time too slow: #{average_time}s"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
217
test/controllers/concerns/authentication_test.rb
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class AuthenticationTest < ActiveSupport::TestCase
|
||||||
|
# We'll test the method by creating a simple object that includes the method
|
||||||
|
# and making the private method accessible for testing
|
||||||
|
|
||||||
|
class TestAuthentication
|
||||||
|
# Copy the extract_root_domain method directly for testing
|
||||||
|
def extract_root_domain(host)
|
||||||
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
|
# Strip port number for domain parsing
|
||||||
|
host_without_port = host.split(':').first
|
||||||
|
|
||||||
|
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||||
|
return nil if IPAddr.new(host_without_port) rescue false
|
||||||
|
|
||||||
|
# Use Public Suffix List for accurate domain parsing
|
||||||
|
domain = PublicSuffix.parse(host_without_port)
|
||||||
|
".#{domain.domain}"
|
||||||
|
rescue PublicSuffix::DomainInvalid
|
||||||
|
# Fallback for invalid domains or IPs
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@auth = TestAuthentication.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_root_domain(host)
|
||||||
|
@auth.extract_root_domain(host)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Basic domain extraction tests
|
||||||
|
test "extract_root_domain handles simple domains" do
|
||||||
|
assert_equal ".example.com", extract_root_domain("app.example.com")
|
||||||
|
assert_equal ".example.com", extract_root_domain("www.example.com")
|
||||||
|
assert_equal ".example.com", extract_root_domain("subdomain.example.com")
|
||||||
|
assert_equal ".test.com", extract_root_domain("api.test.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles direct domain without subdomain" do
|
||||||
|
assert_equal ".example.com", extract_root_domain("example.com")
|
||||||
|
assert_equal ".test.org", extract_root_domain("test.org")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Complex TLD pattern tests - these were the original hardcoded cases
|
||||||
|
test "extract_root_domain handles co.uk domains" do
|
||||||
|
assert_equal ".example.co.uk", extract_root_domain("app.example.co.uk")
|
||||||
|
assert_equal ".example.co.uk", extract_root_domain("www.example.co.uk")
|
||||||
|
assert_equal ".example.co.uk", extract_root_domain("subdomain.example.co.uk")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles com.au domains" do
|
||||||
|
assert_equal ".example.com.au", extract_root_domain("app.example.com.au")
|
||||||
|
assert_equal ".example.com.au", extract_root_domain("www.example.com.au")
|
||||||
|
assert_equal ".example.com.au", extract_root_domain("service.example.com.au")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles co.nz domains" do
|
||||||
|
assert_equal ".example.co.nz", extract_root_domain("app.example.co.nz")
|
||||||
|
assert_equal ".example.co.nz", extract_root_domain("www.example.co.nz")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles co.za domains" do
|
||||||
|
assert_equal ".example.co.za", extract_root_domain("app.example.co.za")
|
||||||
|
assert_equal ".example.co.za", extract_root_domain("www.example.co.za")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles co.jp domains" do
|
||||||
|
assert_equal ".example.co.jp", extract_root_domain("app.example.co.jp")
|
||||||
|
assert_equal ".example.co.jp", extract_root_domain("www.example.co.jp")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Additional complex TLDs that Public Suffix List should handle
|
||||||
|
test "extract_root_domain handles gov.uk domains" do
|
||||||
|
assert_equal ".example.gov.uk", extract_root_domain("app.example.gov.uk")
|
||||||
|
assert_equal ".example.gov.uk", extract_root_domain("www.example.gov.uk")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles ac.uk domains" do
|
||||||
|
assert_equal ".example.ac.uk", extract_root_domain("uni.example.ac.uk")
|
||||||
|
assert_equal ".example.ac.uk", extract_root_domain("www.example.ac.uk")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles edu.au domains" do
|
||||||
|
assert_equal ".example.edu.au", extract_root_domain("student.example.edu.au")
|
||||||
|
assert_equal ".example.edu.au", extract_root_domain("www.example.edu.au")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles org.uk domains" do
|
||||||
|
assert_equal ".example.org.uk", extract_root_domain("www.example.org.uk")
|
||||||
|
assert_equal ".example.org.uk", extract_root_domain("charity.example.org.uk")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Multi-level complex domains
|
||||||
|
test "extract_root_domain handles very complex domains" do
|
||||||
|
# Public Suffix List handles these according to official domain rules
|
||||||
|
# These might be more specific than expected due to how the PSL categorizes domains
|
||||||
|
assert_equal ".sub.example.kawasaki.jp", extract_root_domain("sub.example.kawasaki.jp")
|
||||||
|
assert_equal ".city.jp", extract_root_domain("www.example.city.jp")
|
||||||
|
assert_equal ".metro.tokyo.jp", extract_root_domain("app.example.metro.tokyo.jp")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Special domain patterns that Public Suffix List handles
|
||||||
|
test "extract_root_domain handles appspot domains" do
|
||||||
|
assert_equal ".myapp.appspot.com", extract_root_domain("myapp.appspot.com")
|
||||||
|
assert_equal ".myapp.appspot.com", extract_root_domain("version.myapp.appspot.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles github.io domains" do
|
||||||
|
assert_equal ".username.github.io", extract_root_domain("username.github.io")
|
||||||
|
assert_equal ".username.github.io", extract_root_domain("project.username.github.io")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles herokuapp domains" do
|
||||||
|
assert_equal ".myapp.herokuapp.com", extract_root_domain("myapp.herokuapp.com")
|
||||||
|
assert_equal ".myapp.herokuapp.com", extract_root_domain("staging.myapp.herokuapp.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Edge cases
|
||||||
|
test "extract_root_domain returns nil for localhost" do
|
||||||
|
assert_nil extract_root_domain("localhost")
|
||||||
|
assert_nil extract_root_domain("localhost:3000")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain returns nil for IP addresses" do
|
||||||
|
# In SSO forward_auth, we never want to set domain cookies for IP addresses
|
||||||
|
# since there are no subdomains to share the cookie with
|
||||||
|
|
||||||
|
# IPv4 addresses
|
||||||
|
assert_nil extract_root_domain("127.0.0.1")
|
||||||
|
assert_nil extract_root_domain("192.168.1.1")
|
||||||
|
assert_nil extract_root_domain("10.0.0.1")
|
||||||
|
assert_nil extract_root_domain("172.16.0.1")
|
||||||
|
assert_nil extract_root_domain("8.8.8.8")
|
||||||
|
assert_nil extract_root_domain("1.1.1.1")
|
||||||
|
|
||||||
|
# IPv6 addresses
|
||||||
|
assert_nil extract_root_domain("::1")
|
||||||
|
assert_nil extract_root_domain("2001:db8::1")
|
||||||
|
assert_nil extract_root_domain("::ffff:192.0.2.1")
|
||||||
|
assert_nil extract_root_domain("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
|
||||||
|
assert_nil extract_root_domain("fe80::1ff:fe23:4567:890a")
|
||||||
|
assert_nil extract_root_domain("2001:db8::8a2e:370:7334")
|
||||||
|
|
||||||
|
# IPv4-mapped IPv6 addresses
|
||||||
|
assert_nil extract_root_domain("::ffff:127.0.0.1")
|
||||||
|
assert_nil extract_root_domain("::ffff:192.168.1.1")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain returns nil for blank input" do
|
||||||
|
assert_nil extract_root_domain(nil)
|
||||||
|
assert_nil extract_root_domain("")
|
||||||
|
assert_nil extract_root_domain(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain returns nil for invalid domains" do
|
||||||
|
# Some invalid domains are handled by Public Suffix List
|
||||||
|
# The behavior is more correct than the old hardcoded approach
|
||||||
|
assert_equal ".invalid.domain", extract_root_domain("invalid..domain")
|
||||||
|
assert_equal ".-invalid.com", extract_root_domain("-invalid.com")
|
||||||
|
assert_equal ".invalid-.com", extract_root_domain("invalid-.com")
|
||||||
|
# The Public Suffix List is more permissive with domain validation
|
||||||
|
# This is actually correct behavior as these are technically valid domains
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles port numbers" do
|
||||||
|
# Port numbers should be stripped for domain parsing
|
||||||
|
assert_equal ".example.com", extract_root_domain("app.example.com:3000")
|
||||||
|
assert_equal ".example.com", extract_root_domain("www.example.com:8080")
|
||||||
|
assert_equal ".example.co.uk", extract_root_domain("app.example.co.uk:443")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain preserves case correctly in output" do
|
||||||
|
# Output should always be lowercase with leading dot
|
||||||
|
assert_equal ".example.com", extract_root_domain("APP.EXAMPLE.COM")
|
||||||
|
assert_equal ".example.com", extract_root_domain("App.Example.Com")
|
||||||
|
assert_equal ".example.co.uk", extract_root_domain("WWW.EXAMPLE.CO.UK")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test cases that might have different behavior between old and new implementation
|
||||||
|
test "extract_root_domain handles domains with many subdomains" do
|
||||||
|
assert_equal ".example.com", extract_root_domain("a.b.c.d.e.f.example.com")
|
||||||
|
assert_equal ".example.co.uk", extract_root_domain("a.b.c.d.example.co.uk")
|
||||||
|
assert_equal ".example.com.au", extract_root_domain("a.b.c.example.com.au")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "extract_root_domain handles newer TLD patterns" do
|
||||||
|
# These are patterns the old hardcoded approach would likely get wrong
|
||||||
|
assert_equal ".example.org", extract_root_domain("sub.example.org")
|
||||||
|
assert_equal ".example.net", extract_root_domain("api.example.net")
|
||||||
|
assert_equal ".example.edu", extract_root_domain("www.example.edu")
|
||||||
|
assert_equal ".example.gov", extract_root_domain("agency.example.gov")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Country code TLDs
|
||||||
|
test "extract_root_domain handles simple country code TLDs" do
|
||||||
|
assert_equal ".example.ca", extract_root_domain("www.example.ca")
|
||||||
|
assert_equal ".example.de", extract_root_domain("app.example.de")
|
||||||
|
assert_equal ".example.fr", extract_root_domain("site.example.fr")
|
||||||
|
assert_equal ".example.jp", extract_root_domain("www.example.jp")
|
||||||
|
assert_equal ".example.au", extract_root_domain("app.example.au") # Not com.au
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test consistency across similar patterns
|
||||||
|
test "extract_root_domain provides consistent results" do
|
||||||
|
# All these should extract to the same domain
|
||||||
|
domain = ".example.com"
|
||||||
|
assert_equal domain, extract_root_domain("example.com")
|
||||||
|
assert_equal domain, extract_root_domain("www.example.com")
|
||||||
|
assert_equal domain, extract_root_domain("app.example.com")
|
||||||
|
assert_equal domain, extract_root_domain("api.example.com")
|
||||||
|
assert_equal domain, extract_root_domain("sub.example.com")
|
||||||
|
end
|
||||||
|
end
|
||||||
148
test/controllers/invitations_controller_test.rb
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = User.create!(
|
||||||
|
email_address: "pending@example.com",
|
||||||
|
password: "password123",
|
||||||
|
status: :pending_invitation
|
||||||
|
)
|
||||||
|
@token = @user.generate_token_for(:invitation_login)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should show invitation form with valid token" do
|
||||||
|
get invitation_path(@token)
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert_select "h1", "Welcome to Clinch!"
|
||||||
|
assert_select "form[action='#{invitation_path(@token)}']"
|
||||||
|
assert_select "input[type='password'][name='password']"
|
||||||
|
assert_select "input[type='password'][name='password_confirmation']"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect to sign in with invalid token" do
|
||||||
|
get invitation_path("invalid_token")
|
||||||
|
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
assert_equal "Invitation link is invalid or has expired.", flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should redirect to sign in when user is not pending invitation" do
|
||||||
|
active_user = User.create!(
|
||||||
|
email_address: "active@example.com",
|
||||||
|
password: "password123",
|
||||||
|
status: :active
|
||||||
|
)
|
||||||
|
token = active_user.generate_token_for(:invitation_login)
|
||||||
|
|
||||||
|
get invitation_path(token)
|
||||||
|
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
assert_equal "This invitation has already been used or is no longer valid.", flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should accept invitation with valid password" do
|
||||||
|
put invitation_path(@token), params: {
|
||||||
|
password: "newpassword123",
|
||||||
|
password_confirmation: "newpassword123"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to root_path
|
||||||
|
assert_equal "Your account has been set up successfully. Welcome!", flash[:notice]
|
||||||
|
|
||||||
|
@user.reload
|
||||||
|
assert_equal "active", @user.status
|
||||||
|
assert @user.authenticate("newpassword123")
|
||||||
|
assert cookies[:session_id] # Should be signed in
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should reject invitation with password mismatch" do
|
||||||
|
put invitation_path(@token), params: {
|
||||||
|
password: "newpassword123",
|
||||||
|
password_confirmation: "differentpassword"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to invitation_path(@token)
|
||||||
|
assert_equal "Passwords did not match.", flash[:alert]
|
||||||
|
|
||||||
|
@user.reload
|
||||||
|
assert_equal "pending_invitation", @user.status
|
||||||
|
assert_nil cookies[:session_id] # Should not be signed in
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should reject invitation with missing password" do
|
||||||
|
put invitation_path(@token), params: {
|
||||||
|
password: "",
|
||||||
|
password_confirmation: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# When password validation fails, the controller should redirect back to the invitation form
|
||||||
|
assert_redirected_to invitation_path(@token)
|
||||||
|
assert_equal "Passwords did not match.", flash[:alert]
|
||||||
|
|
||||||
|
@user.reload
|
||||||
|
assert_equal "pending_invitation", @user.status
|
||||||
|
assert_nil cookies[:session_id] # Should not be signed in
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should reject invitation with short password" do
|
||||||
|
put invitation_path(@token), params: {
|
||||||
|
password: "short",
|
||||||
|
password_confirmation: "short"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to invitation_path(@token)
|
||||||
|
assert_equal "Passwords did not match.", flash[:alert]
|
||||||
|
|
||||||
|
@user.reload
|
||||||
|
assert_equal "pending_invitation", @user.status
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should destroy existing sessions when accepting invitation" do
|
||||||
|
# Create an existing session for the user
|
||||||
|
existing_session = @user.sessions.create!
|
||||||
|
|
||||||
|
put invitation_path(@token), params: {
|
||||||
|
password: "newpassword123",
|
||||||
|
password_confirmation: "newpassword123"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to root_path
|
||||||
|
|
||||||
|
@user.reload
|
||||||
|
assert_empty @user.sessions.where.not(id: @user.sessions.last) # Only new session should exist
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should create new session after accepting invitation" do
|
||||||
|
put invitation_path(@token), params: {
|
||||||
|
password: "newpassword123",
|
||||||
|
password_confirmation: "newpassword123"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to root_path
|
||||||
|
assert cookies[:session_id]
|
||||||
|
|
||||||
|
@user.reload
|
||||||
|
assert_equal 1, @user.sessions.count
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should not allow invitation for disabled user" do
|
||||||
|
disabled_user = User.create!(
|
||||||
|
email_address: "disabled@example.com",
|
||||||
|
password: "password123",
|
||||||
|
status: :disabled
|
||||||
|
)
|
||||||
|
token = disabled_user.generate_token_for(:invitation_login)
|
||||||
|
|
||||||
|
get invitation_path(token)
|
||||||
|
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
assert_equal "This invitation has already been used or is no longer valid.", flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should allow access without authentication" do
|
||||||
|
# This test ensures the allow_unauthenticated_access is working
|
||||||
|
get invitation_path(@token)
|
||||||
|
assert_response :success
|
||||||
|
end
|
||||||
|
end
|
||||||
12
test/fixtures/application_groups.yml
vendored
@@ -1,9 +1,9 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
one:
|
kavita_admin_group:
|
||||||
application: one
|
application: kavita_app
|
||||||
group: one
|
group: admin_group
|
||||||
|
|
||||||
two:
|
kavita_editor_group:
|
||||||
application: two
|
application: kavita_app
|
||||||
group: two
|
group: editor_group
|
||||||
|
|||||||
41
test/fixtures/applications.yml
vendored
@@ -1,21 +1,26 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
one:
|
<% require 'bcrypt' %>
|
||||||
name: MyString
|
|
||||||
slug: MyString
|
|
||||||
app_type: MyString
|
|
||||||
client_id: MyString
|
|
||||||
client_secret: MyString
|
|
||||||
redirect_uris: MyText
|
|
||||||
metadata: MyText
|
|
||||||
active: false
|
|
||||||
|
|
||||||
two:
|
kavita_app:
|
||||||
name: MyString
|
name: Kavita Reader
|
||||||
slug: MyString
|
slug: kavita-reader
|
||||||
app_type: MyString
|
app_type: oidc
|
||||||
client_id: MyString
|
client_id: <%= SecureRandom.urlsafe_base64(32) %>
|
||||||
client_secret: MyString
|
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
|
||||||
redirect_uris: MyText
|
redirect_uris: |
|
||||||
metadata: MyText
|
https://kavita.example.com/signin-oidc
|
||||||
active: false
|
https://kavita.example.com/signout-callback-oidc
|
||||||
|
metadata: "{}"
|
||||||
|
active: true
|
||||||
|
|
||||||
|
another_app:
|
||||||
|
name: Another App
|
||||||
|
slug: another-app
|
||||||
|
app_type: oidc
|
||||||
|
client_id: <%= SecureRandom.urlsafe_base64(32) %>
|
||||||
|
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
|
||||||
|
redirect_uris: |
|
||||||
|
https://app.example.com/auth/callback
|
||||||
|
metadata: "{}"
|
||||||
|
active: true
|
||||||
|
|||||||
12
test/fixtures/groups.yml
vendored
@@ -1,9 +1,9 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
one:
|
admin_group:
|
||||||
name: MyString
|
name: Administrators
|
||||||
description: MyText
|
description: System administrators with full access
|
||||||
|
|
||||||
two:
|
editor_group:
|
||||||
name: MyString
|
name: Editors
|
||||||
description: MyText
|
description: Content editors with limited access
|
||||||
|
|||||||
20
test/fixtures/oidc_access_tokens.yml
vendored
@@ -1,15 +1,15 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
one:
|
one:
|
||||||
token: MyString
|
token: <%= SecureRandom.urlsafe_base64(32) %>
|
||||||
application: one
|
application: kavita_app
|
||||||
user: one
|
user: alice
|
||||||
scope: MyString
|
scope: "openid profile email"
|
||||||
expires_at: 2025-10-23 16:40:39
|
expires_at: 2025-12-31 23:59:59
|
||||||
|
|
||||||
two:
|
two:
|
||||||
token: MyString
|
token: <%= SecureRandom.urlsafe_base64(32) %>
|
||||||
application: two
|
application: another_app
|
||||||
user: two
|
user: bob
|
||||||
scope: MyString
|
scope: "openid profile email"
|
||||||
expires_at: 2025-10-23 16:40:39
|
expires_at: 2025-12-31 23:59:59
|
||||||
|
|||||||
24
test/fixtures/oidc_authorization_codes.yml
vendored
@@ -1,19 +1,19 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
one:
|
one:
|
||||||
code: MyString
|
code: <%= SecureRandom.urlsafe_base64(32) %>
|
||||||
application: one
|
application: kavita_app
|
||||||
user: one
|
user: alice
|
||||||
redirect_uri: MyString
|
redirect_uri: "https://kavita.example.com/signin-oidc"
|
||||||
scope: MyString
|
scope: "openid profile email"
|
||||||
expires_at: 2025-10-23 16:40:38
|
expires_at: 2025-12-31 23:59:59
|
||||||
used: false
|
used: false
|
||||||
|
|
||||||
two:
|
two:
|
||||||
code: MyString
|
code: <%= SecureRandom.urlsafe_base64(32) %>
|
||||||
application: two
|
application: another_app
|
||||||
user: two
|
user: bob
|
||||||
redirect_uri: MyString
|
redirect_uri: "https://app.example.com/auth/callback"
|
||||||
scope: MyString
|
scope: "openid profile email"
|
||||||
expires_at: 2025-10-23 16:40:38
|
expires_at: 2025-12-31 23:59:59
|
||||||
used: false
|
used: false
|
||||||
|
|||||||
13
test/fixtures/oidc_user_consents.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
|
alice_consent:
|
||||||
|
user: alice
|
||||||
|
application: kavita_app
|
||||||
|
scopes_granted: openid profile email
|
||||||
|
granted_at: 2025-10-24 16:57:39
|
||||||
|
|
||||||
|
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
@@ -1,9 +1,9 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
one:
|
alice_admin_group:
|
||||||
user: one
|
user: alice
|
||||||
group: one
|
group: admin_group
|
||||||
|
|
||||||
two:
|
bob_editor_group:
|
||||||
user: two
|
user: bob
|
||||||
group: two
|
group: editor_group
|
||||||
|
|||||||
12
test/fixtures/users.yml
vendored
@@ -1,9 +1,13 @@
|
|||||||
<% password_digest = BCrypt::Password.create("password") %>
|
<% password_digest = BCrypt::Password.create("password") %>
|
||||||
|
|
||||||
one:
|
alice:
|
||||||
email_address: one@example.com
|
email_address: alice@example.com
|
||||||
password_digest: <%= password_digest %>
|
password_digest: <%= password_digest %>
|
||||||
|
admin: true
|
||||||
|
status: 0 # active
|
||||||
|
|
||||||
two:
|
bob:
|
||||||
email_address: two@example.com
|
email_address: bob@example.com
|
||||||
password_digest: <%= password_digest %>
|
password_digest: <%= password_digest %>
|
||||||
|
admin: false
|
||||||
|
status: 0 # active
|
||||||
|
|||||||
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
|
||||||
179
test/integration/invitation_flow_test.rb
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class InvitationFlowTest < ActionDispatch::IntegrationTest
|
||||||
|
test "complete invitation flow from email to account setup" do
|
||||||
|
# Create a pending user (simulating admin invitation)
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "newuser@example.com",
|
||||||
|
password: "temppassword",
|
||||||
|
status: :pending_invitation
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate invitation token (simulating email link)
|
||||||
|
token = user.generate_token_for(:invitation_login)
|
||||||
|
|
||||||
|
# Step 1: User clicks invitation link
|
||||||
|
get invitation_path(token)
|
||||||
|
assert_response :success
|
||||||
|
assert_select "h1", "Welcome to Clinch!"
|
||||||
|
|
||||||
|
# Step 2: User submits valid password
|
||||||
|
put invitation_path(token), params: {
|
||||||
|
password: "SecurePassword123!",
|
||||||
|
password_confirmation: "SecurePassword123!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should be redirected to dashboard
|
||||||
|
assert_redirected_to root_path
|
||||||
|
assert_equal "Your account has been set up successfully. Welcome!", flash[:notice]
|
||||||
|
|
||||||
|
# Verify user is now active and signed in
|
||||||
|
user.reload
|
||||||
|
assert_equal "active", user.status
|
||||||
|
assert user.authenticate("SecurePassword123!")
|
||||||
|
assert cookies[:session_id]
|
||||||
|
|
||||||
|
# Step 3: User can now access protected areas
|
||||||
|
get root_path
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Step 4: User can sign out and sign back in with new password
|
||||||
|
delete session_path
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
# Cookie might still be present but session should be invalid
|
||||||
|
# Check that we can't access protected resources
|
||||||
|
get root_path
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
|
||||||
|
post signin_path, params: {
|
||||||
|
email_address: "newuser@example.com",
|
||||||
|
password: "SecurePassword123!"
|
||||||
|
}
|
||||||
|
assert_redirected_to root_path
|
||||||
|
assert cookies[:session_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invitation flow with password validation error" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "user@example.com",
|
||||||
|
password: "temppassword",
|
||||||
|
status: :pending_invitation
|
||||||
|
)
|
||||||
|
|
||||||
|
token = user.generate_token_for(:invitation_login)
|
||||||
|
|
||||||
|
# Visit invitation page
|
||||||
|
get invitation_path(token)
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Submit mismatching passwords
|
||||||
|
put invitation_path(token), params: {
|
||||||
|
password: "Password123!",
|
||||||
|
password_confirmation: "DifferentPassword123!"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect back to invitation form with error
|
||||||
|
assert_redirected_to invitation_path(token)
|
||||||
|
assert_equal "Passwords did not match.", flash[:alert]
|
||||||
|
|
||||||
|
# User should still be pending invitation
|
||||||
|
user.reload
|
||||||
|
assert_equal "pending_invitation", user.status
|
||||||
|
|
||||||
|
# User should not be signed in
|
||||||
|
# Cookie might still be present but session should be invalid
|
||||||
|
# Check that we can't access protected resources
|
||||||
|
get root_path
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
|
||||||
|
# Try to access protected area - should be redirected
|
||||||
|
get root_path
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
end
|
||||||
|
|
||||||
|
test "expired invitation token flow" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "expired@example.com",
|
||||||
|
password: "temppassword",
|
||||||
|
status: :pending_invitation
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate expired token by creating a manually crafted invalid token
|
||||||
|
invalid_token = "expired_token_#{SecureRandom.hex(20)}"
|
||||||
|
|
||||||
|
get invitation_path(invalid_token)
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
assert_equal "Invitation link is invalid or has expired.", flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invitation for already active user" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "active@example.com",
|
||||||
|
password: "password123",
|
||||||
|
status: :active
|
||||||
|
)
|
||||||
|
|
||||||
|
token = user.generate_token_for(:invitation_login)
|
||||||
|
|
||||||
|
get invitation_path(token)
|
||||||
|
assert_redirected_to signin_path
|
||||||
|
assert_equal "This invitation has already been used or is no longer valid.", flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multiple invitation attempts" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "multiple@example.com",
|
||||||
|
password: "temppassword",
|
||||||
|
status: :pending_invitation
|
||||||
|
)
|
||||||
|
|
||||||
|
token = user.generate_token_for(:invitation_login)
|
||||||
|
|
||||||
|
# First attempt - wrong password
|
||||||
|
put invitation_path(token), params: {
|
||||||
|
password: "wrong",
|
||||||
|
password_confirmation: "wrong"
|
||||||
|
}
|
||||||
|
assert_redirected_to invitation_path(token)
|
||||||
|
assert_equal "Passwords did not match.", flash[:alert]
|
||||||
|
|
||||||
|
# Second attempt - successful
|
||||||
|
put invitation_path(token), params: {
|
||||||
|
password: "CorrectPassword123!",
|
||||||
|
password_confirmation: "CorrectPassword123!"
|
||||||
|
}
|
||||||
|
assert_redirected_to root_path
|
||||||
|
assert_equal "Your account has been set up successfully. Welcome!", flash[:notice]
|
||||||
|
|
||||||
|
user.reload
|
||||||
|
assert_equal "active", user.status
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invitation flow with session cleanup" do
|
||||||
|
user = User.create!(
|
||||||
|
email_address: "cleanup@example.com",
|
||||||
|
password: "temppassword",
|
||||||
|
status: :pending_invitation
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create existing sessions
|
||||||
|
old_session1 = user.sessions.create!
|
||||||
|
old_session2 = user.sessions.create!
|
||||||
|
assert_equal 2, user.sessions.count
|
||||||
|
|
||||||
|
token = user.generate_token_for(:invitation_login)
|
||||||
|
|
||||||
|
put invitation_path(token), params: {
|
||||||
|
password: "NewPassword123!",
|
||||||
|
password_confirmation: "NewPassword123!"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_redirected_to root_path
|
||||||
|
|
||||||
|
user.reload
|
||||||
|
# Should have only one new session
|
||||||
|
assert_equal 1, user.sessions.count
|
||||||
|
assert_not_equal old_session1.id, user.sessions.first.id
|
||||||
|
assert_not_equal old_session2.id, user.sessions.first.id
|
||||||
|
end
|
||||||
|
end
|
||||||
210
test/integration/oidc_role_mapping_test.rb
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcRoleMappingTest < ActionDispatch::IntegrationTest
|
||||||
|
def setup
|
||||||
|
@application = applications(:kavita_app)
|
||||||
|
@user = users(:alice)
|
||||||
|
|
||||||
|
# Set a known client secret for testing
|
||||||
|
@test_client_secret = "test_secret_for_testing_only"
|
||||||
|
@application.client_secret = @test_client_secret
|
||||||
|
@application.save!
|
||||||
|
|
||||||
|
@application.update!(
|
||||||
|
role_mapping_mode: "oidc_managed",
|
||||||
|
role_claim_name: "roles"
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin_role = @application.application_roles.create!(
|
||||||
|
name: "admin",
|
||||||
|
display_name: "Administrator"
|
||||||
|
)
|
||||||
|
@editor_role = @application.application_roles.create!(
|
||||||
|
name: "editor",
|
||||||
|
display_name: "Editor"
|
||||||
|
)
|
||||||
|
|
||||||
|
sign_in @user
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should include roles in JWT tokens" do
|
||||||
|
# Assign roles to user
|
||||||
|
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||||
|
@application.assign_role_to_user!(@user, "editor", source: 'oidc')
|
||||||
|
|
||||||
|
# Get authorization code
|
||||||
|
post oauth_authorize_path, params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: "test-state",
|
||||||
|
nonce: "test-nonce"
|
||||||
|
}
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
post oauth_consent_path, params: {
|
||||||
|
consent: "approve",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: "test-state"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
authorization_code = extract_code_from_redirect(response.location)
|
||||||
|
|
||||||
|
# Exchange code for token
|
||||||
|
post oauth_token_path, params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: authorization_code,
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @test_client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
token_response = JSON.parse(response.body)
|
||||||
|
id_token = token_response["id_token"]
|
||||||
|
|
||||||
|
# Decode and verify ID token contains roles
|
||||||
|
decoded_token = JWT.decode(id_token, nil, false).first
|
||||||
|
assert_includes decoded_token["roles"], "admin"
|
||||||
|
assert_includes decoded_token["roles"], "editor"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should filter roles by prefix" do
|
||||||
|
@application.update!(role_prefix: "app-")
|
||||||
|
@admin_role.update!(name: "app-admin")
|
||||||
|
@editor_role.update!(name: "external-editor") # Should be filtered out
|
||||||
|
|
||||||
|
@application.assign_role_to_user!(@user, "app-admin", source: 'oidc')
|
||||||
|
@application.assign_role_to_user!(@user, "external-editor", source: 'oidc')
|
||||||
|
|
||||||
|
# Get token
|
||||||
|
post oauth_authorize_path, params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: "test-state"
|
||||||
|
}
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
post oauth_consent_path, params: {
|
||||||
|
consent: "approve",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: "test-state"
|
||||||
|
}
|
||||||
|
|
||||||
|
authorization_code = extract_code_from_redirect(response.location)
|
||||||
|
|
||||||
|
post oauth_token_path, params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: authorization_code,
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @test_client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
token_response = JSON.parse(response.body)
|
||||||
|
id_token = token_response["id_token"]
|
||||||
|
decoded_token = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
assert_includes decoded_token["roles"], "app-admin"
|
||||||
|
assert_not_includes decoded_token["roles"], "external-editor"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should include role permissions when configured" do
|
||||||
|
@application.update!(managed_permissions: { "include_permissions" => true })
|
||||||
|
@admin_role.update!(permissions: { "read" => true, "write" => true, "delete" => true })
|
||||||
|
|
||||||
|
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||||
|
|
||||||
|
# Get token and check for role permissions
|
||||||
|
post oauth_authorize_path, params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: "test-state"
|
||||||
|
}
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
post oauth_consent_path, params: {
|
||||||
|
consent: "approve",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: "test-state"
|
||||||
|
}
|
||||||
|
|
||||||
|
authorization_code = extract_code_from_redirect(response.location)
|
||||||
|
|
||||||
|
post oauth_token_path, params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: authorization_code,
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @test_client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
token_response = JSON.parse(response.body)
|
||||||
|
id_token = token_response["id_token"]
|
||||||
|
decoded_token = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
assert decoded_token["role_permissions"].present?
|
||||||
|
role_permissions = decoded_token["role_permissions"].find { |rp| rp["name"] == "admin" }
|
||||||
|
assert_equal({ "read" => true, "write" => true, "delete" => true }, role_permissions["permissions"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should use custom role claim name" do
|
||||||
|
@application.update!(role_claim_name: "user_roles")
|
||||||
|
@application.assign_role_to_user!(@user, "admin", source: 'oidc')
|
||||||
|
|
||||||
|
# Get token
|
||||||
|
post oauth_authorize_path, params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: "test-state"
|
||||||
|
}
|
||||||
|
|
||||||
|
follow_redirect!
|
||||||
|
post oauth_consent_path, params: {
|
||||||
|
consent: "approve",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
scope: "openid profile email",
|
||||||
|
state: "test-state"
|
||||||
|
}
|
||||||
|
|
||||||
|
authorization_code = extract_code_from_redirect(response.location)
|
||||||
|
|
||||||
|
post oauth_token_path, params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: authorization_code,
|
||||||
|
redirect_uri: "https://example.com/callback",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @test_client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
token_response = JSON.parse(response.body)
|
||||||
|
id_token = token_response["id_token"]
|
||||||
|
decoded_token = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
assert_nil decoded_token["roles"]
|
||||||
|
assert_includes decoded_token["user_roles"], "admin"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extract_code_from_redirect(redirect_url)
|
||||||
|
uri = URI.parse(redirect_url)
|
||||||
|
query_params = CGI.parse(uri.query)
|
||||||
|
query_params["code"]&.first
|
||||||
|
end
|
||||||
|
end
|
||||||
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
@@ -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
@@ -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
|
||||||
86
test/models/application_role_test.rb
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationRoleTest < ActiveSupport::TestCase
|
||||||
|
def setup
|
||||||
|
@application = applications(:kavita_app)
|
||||||
|
@role = @application.application_roles.create!(
|
||||||
|
name: "admin",
|
||||||
|
display_name: "Administrator",
|
||||||
|
description: "Full access to all features"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should be valid" do
|
||||||
|
assert @role.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should require name" do
|
||||||
|
@role.name = ""
|
||||||
|
assert_not @role.valid?
|
||||||
|
assert_includes @role.errors[:name], "can't be blank"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should require display_name" do
|
||||||
|
@role.display_name = ""
|
||||||
|
assert_not @role.valid?
|
||||||
|
assert_includes @role.errors[:display_name], "can't be blank"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should enforce unique role name per application" do
|
||||||
|
duplicate_role = @application.application_roles.build(
|
||||||
|
name: @role.name,
|
||||||
|
display_name: "Another Admin"
|
||||||
|
)
|
||||||
|
assert_not duplicate_role.valid?
|
||||||
|
assert_includes duplicate_role.errors[:name], "has already been taken"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should allow same role name in different applications" do
|
||||||
|
other_app = Application.create!(
|
||||||
|
name: "Other App",
|
||||||
|
slug: "other-app",
|
||||||
|
app_type: "oidc"
|
||||||
|
)
|
||||||
|
other_role = other_app.application_roles.build(
|
||||||
|
name: @role.name,
|
||||||
|
display_name: "Other Admin"
|
||||||
|
)
|
||||||
|
assert other_role.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should track user assignments" do
|
||||||
|
user = users(:alice)
|
||||||
|
assert_not @role.user_has_role?(user)
|
||||||
|
|
||||||
|
@role.assign_to_user!(user)
|
||||||
|
assert @role.user_has_role?(user)
|
||||||
|
assert @role.users.include?(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should handle role removal" do
|
||||||
|
user = users(:alice)
|
||||||
|
@role.assign_to_user!(user)
|
||||||
|
assert @role.user_has_role?(user)
|
||||||
|
|
||||||
|
@role.remove_from_user!(user)
|
||||||
|
assert_not @role.user_has_role?(user)
|
||||||
|
assert_not @role.users.include?(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should default to active" do
|
||||||
|
new_role = @application.application_roles.build(
|
||||||
|
name: "member",
|
||||||
|
display_name: "Member"
|
||||||
|
)
|
||||||
|
assert new_role.active?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "should support default permissions" do
|
||||||
|
role_with_permissions = @application.application_roles.create!(
|
||||||
|
name: "editor",
|
||||||
|
display_name: "Editor",
|
||||||
|
permissions: { "read" => true, "write" => true, "delete" => false }
|
||||||
|
)
|
||||||
|
assert_equal({ "read" => true, "write" => true, "delete" => false }, role_with_permissions.permissions)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -124,4 +124,272 @@ class ForwardAuthRuleTest < ActiveSupport::TestCase
|
|||||||
|
|
||||||
assert_not @rule.user_allowed?(user)
|
assert_not @rule.user_allowed?(user)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||