Compare commits
47 Commits
2025.01
...
ef15db77f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef15db77f9 | ||
|
|
4d1bc1ab66 | ||
|
|
517029247d | ||
|
|
bfcc5cdc84 | ||
|
|
81871426e9 | ||
|
|
ddcb297c74 | ||
|
|
6f7de94623 | ||
|
|
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 |
12
.env.example
@@ -16,9 +16,19 @@ SMTP_AUTHENTICATION=plain
|
||||
SMTP_ENABLE_STARTTLS=true
|
||||
|
||||
# Application Configuration
|
||||
CLINCH_HOST=http://localhost:9000
|
||||
CLINCH_HOST=http://localhost:3000
|
||||
CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# DNS Rebinding Protection Configuration
|
||||
# Set to service name (e.g., 'clinch') if running in same Docker compose as Caddy
|
||||
CLINCH_DOCKER_SERVICE_NAME=
|
||||
|
||||
# Allow internal IP access for cross-compose deployments (true/false)
|
||||
CLINCH_ALLOW_INTERNAL_IPS=true
|
||||
|
||||
# Allow localhost access for development (true/false)
|
||||
CLINCH_ALLOW_LOCALHOST=true
|
||||
|
||||
# OIDC Configuration
|
||||
# RSA private key for signing ID tokens (JWT)
|
||||
# Generate with: openssl genrsa 2048
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
@@ -116,7 +116,7 @@ jobs:
|
||||
run: bin/rails db:test:prepare test:system
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v5
|
||||
if: failure()
|
||||
with:
|
||||
name: screenshots
|
||||
|
||||
13
Gemfile
@@ -26,17 +26,19 @@ gem "bcrypt", "~> 3.1.7"
|
||||
gem "rotp", "~> 6.3"
|
||||
|
||||
# QR code generation for TOTP setup
|
||||
gem "rqrcode", "~> 2.0"
|
||||
gem "rqrcode", "~> 3.1"
|
||||
|
||||
# JWT for OIDC ID tokens
|
||||
gem "jwt", "~> 2.9"
|
||||
gem "jwt", "~> 3.1"
|
||||
|
||||
# Public Suffix List for domain parsing
|
||||
gem "public_suffix", "~> 6.0"
|
||||
|
||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
|
||||
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
|
||||
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||
gem "solid_cache"
|
||||
gem "solid_queue"
|
||||
gem "solid_cable"
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
@@ -68,6 +70,9 @@ end
|
||||
group :development do
|
||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||
gem "web-console"
|
||||
|
||||
# Preview emails in browser instead of sending them
|
||||
gem "letter_opener"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
40
Gemfile.lock
@@ -100,6 +100,8 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
@@ -113,8 +115,6 @@ GEM
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
@@ -122,9 +122,6 @@ GEM
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.14.7)
|
||||
@@ -145,7 +142,7 @@ GEM
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.1)
|
||||
jwt (2.10.2)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.8.1)
|
||||
activesupport (>= 7.0)
|
||||
@@ -159,6 +156,12 @@ GEM
|
||||
thor (~> 1.3)
|
||||
zeitwerk (>= 2.6.18, < 3.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.24.1)
|
||||
@@ -225,7 +228,6 @@ GEM
|
||||
public_suffix (6.0.2)
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
rack-session (2.1.1)
|
||||
@@ -276,10 +278,10 @@ GEM
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rubocop (1.81.6)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
@@ -312,9 +314,9 @@ GEM
|
||||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.0)
|
||||
rubyzip (3.2.1)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.37.0)
|
||||
selenium-webdriver (4.38.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
@@ -329,13 +331,6 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.2.2)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (~> 1.11)
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||
sqlite3 (2.7.4-arm-linux-gnu)
|
||||
@@ -414,18 +409,19 @@ DEPENDENCIES
|
||||
image_processing (~> 1.2)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
jwt (~> 2.9)
|
||||
jwt (~> 3.1)
|
||||
kamal
|
||||
letter_opener
|
||||
propshaft
|
||||
public_suffix (~> 6.0)
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.0)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.0)
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
sqlite3 (>= 2.1)
|
||||
stimulus-rails
|
||||
tailwindcss-rails
|
||||
|
||||
21
LICENSE.txt
Normal file
@@ -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.
|
||||
54
README.md
@@ -1,5 +1,8 @@
|
||||
# Clinch
|
||||
|
||||
> [!NOTE]
|
||||
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
|
||||
**A lightweight, self-hosted identity & SSO portal**
|
||||
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
||||
@@ -18,6 +21,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
|
||||
|
||||
### User Management
|
||||
@@ -69,6 +101,7 @@ Send emails for:
|
||||
- **Group-based allowlists** - Restrict applications to specific user groups
|
||||
- **Per-application access** - Each app defines which groups can access it
|
||||
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
|
||||
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
|
||||
|
||||
---
|
||||
|
||||
@@ -83,11 +116,13 @@ Send emails for:
|
||||
- TOTP secret and backup codes (encrypted)
|
||||
- TOTP enforcement flag
|
||||
- Status (active, disabled, pending_invitation)
|
||||
- Custom claims (JSON) - arbitrary key-value pairs added to OIDC tokens
|
||||
- Token generation for invitations, password resets, and magic logins
|
||||
|
||||
**Group**
|
||||
- Name (unique, normalized to lowercase)
|
||||
- Description
|
||||
- Custom claims (JSON) - shared claims for all members (merged with user claims)
|
||||
- Many-to-many with Users and Applications
|
||||
|
||||
**Session**
|
||||
@@ -100,9 +135,11 @@ Send emails for:
|
||||
|
||||
**Application**
|
||||
- Name and slug (URL-safe identifier)
|
||||
- Type (oidc, trusted_header, saml)
|
||||
- Client ID and secret (for OIDC)
|
||||
- Redirect URIs (JSON array)
|
||||
- Type (oidc or forward_auth)
|
||||
- Client ID and secret (for OIDC apps)
|
||||
- Redirect URIs (for OIDC apps)
|
||||
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
||||
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
|
||||
- Metadata (flexible JSON storage)
|
||||
- Active flag
|
||||
- Many-to-many with Groups (allowlist)
|
||||
@@ -167,7 +204,7 @@ bin/dev
|
||||
docker build -t clinch .
|
||||
|
||||
# Run container
|
||||
docker run -p 9000:9000 \
|
||||
docker run -p 3000:3000 \
|
||||
-v clinch-storage:/rails/storage \
|
||||
-e SECRET_KEY_BASE=your-secret-key \
|
||||
-e SMTP_ADDRESS=smtp.example.com \
|
||||
@@ -208,7 +245,7 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
```
|
||||
|
||||
### First Run
|
||||
1. Visit Clinch at `http://localhost:9000` (or your configured domain)
|
||||
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
||||
2. First-run wizard creates initial admin user
|
||||
3. Admin can then:
|
||||
- Create groups
|
||||
@@ -227,12 +264,14 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
- First-run wizard
|
||||
|
||||
### Planned Features
|
||||
- **Audit logging** - Track all authentication events
|
||||
- **WebAuthn/Passkeys** - Hardware key support
|
||||
|
||||
#### Maybe
|
||||
- **SAML support** - SAML 2.0 identity provider
|
||||
- **Policy engine** - Rule-based access control
|
||||
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
||||
- Stored as JSON, evaluated after auth but before consent
|
||||
- **Audit logging** - Track all authentication events
|
||||
- **WebAuthn/Passkeys** - Hardware key support
|
||||
- **LDAP sync** - Import users from LDAP/Active Directory
|
||||
|
||||
---
|
||||
@@ -251,4 +290,3 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ module Admin
|
||||
|
||||
def create
|
||||
@application = Application.new(application_params)
|
||||
@available_groups = Group.order(:name)
|
||||
|
||||
if @application.save
|
||||
# Handle group assignments
|
||||
@@ -25,9 +26,22 @@ module Admin
|
||||
@application.allowed_groups = Group.where(id: group_ids)
|
||||
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
|
||||
@available_groups = Group.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -60,11 +74,17 @@ module Admin
|
||||
|
||||
def regenerate_credentials
|
||||
if @application.oidc?
|
||||
@application.update!(
|
||||
client_id: SecureRandom.urlsafe_base64(32),
|
||||
client_secret: SecureRandom.urlsafe_base64(48)
|
||||
)
|
||||
redirect_to admin_application_path(@application), notice: "Credentials regenerated successfully. Make sure to update your application configuration."
|
||||
# Generate new client ID and secret
|
||||
new_client_id = SecureRandom.urlsafe_base64(32)
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
|
||||
@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
|
||||
redirect_to admin_application_path(@application), alert: "Only OIDC applications have credentials."
|
||||
end
|
||||
@@ -77,7 +97,10 @@ module Admin
|
||||
end
|
||||
|
||||
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,
|
||||
:domain_pattern, headers_config: {}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
module Admin
|
||||
class ForwardAuthRulesController < BaseController
|
||||
before_action :set_forward_auth_rule, only: [:show, :edit, :update, :destroy]
|
||||
|
||||
def index
|
||||
@forward_auth_rules = ForwardAuthRule.ordered
|
||||
end
|
||||
|
||||
def show
|
||||
@allowed_groups = @forward_auth_rule.allowed_groups
|
||||
end
|
||||
|
||||
def new
|
||||
@forward_auth_rule = ForwardAuthRule.new
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
|
||||
|
||||
if @forward_auth_rule.save
|
||||
# Handle group assignments
|
||||
if params[:forward_auth_rule][:group_ids].present?
|
||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
||||
end
|
||||
|
||||
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule created successfully."
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
if @forward_auth_rule.update(forward_auth_rule_params)
|
||||
# Handle group assignments
|
||||
if params[:forward_auth_rule][:group_ids].present?
|
||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
||||
else
|
||||
@forward_auth_rule.allowed_groups = []
|
||||
end
|
||||
|
||||
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule updated successfully."
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@forward_auth_rule.destroy
|
||||
redirect_to admin_forward_auth_rules_path, notice: "Forward auth rule deleted successfully."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_forward_auth_rule
|
||||
@forward_auth_rule = ForwardAuthRule.find(params[:id])
|
||||
end
|
||||
|
||||
def forward_auth_rule_params
|
||||
params.require(:forward_auth_rule).permit(:domain_pattern, :active)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -67,7 +67,7 @@ module Admin
|
||||
end
|
||||
|
||||
def group_params
|
||||
params.require(:group).permit(:name, :description)
|
||||
params.require(:group).permit(:name, :description, custom_claims: {})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module Admin
|
||||
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
|
||||
@users = User.order(created_at: :desc)
|
||||
@@ -16,9 +16,11 @@ module Admin
|
||||
def create
|
||||
@user = User.new(user_params)
|
||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||
@user.status = :pending_invitation
|
||||
|
||||
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
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
@@ -46,6 +48,16 @@ module Admin
|
||||
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
|
||||
# Prevent admin from deleting themselves
|
||||
if @user == Current.session.user
|
||||
@@ -64,7 +76,7 @@ module Admin
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:email_address, :password, :admin, :status)
|
||||
params.require(:user).permit(:email_address, :password, :admin, :status, custom_claims: {})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
31
app/controllers/api/csp_controller.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module Api
|
||||
class CspController < ApplicationController
|
||||
# CSP violation reports don't need authentication
|
||||
skip_before_action :verify_authenticity_token
|
||||
allow_unauthenticated_access
|
||||
|
||||
# POST /api/csp-violation-report
|
||||
def violation_report
|
||||
# Parse CSP violation report
|
||||
report_data = JSON.parse(request.body.read)
|
||||
|
||||
# Log the violation for security monitoring
|
||||
Rails.logger.warn "CSP Violation Report:"
|
||||
Rails.logger.warn " Blocked URI: #{report_data.dig('csp-report', 'blocked-uri')}"
|
||||
Rails.logger.warn " Document URI: #{report_data.dig('csp-report', 'document-uri')}"
|
||||
Rails.logger.warn " Referrer: #{report_data.dig('csp-report', 'referrer')}"
|
||||
Rails.logger.warn " Violated Directive: #{report_data.dig('csp-report', 'violated-directive')}"
|
||||
Rails.logger.warn " Original Policy: #{report_data.dig('csp-report', 'original-policy')}"
|
||||
Rails.logger.warn " User Agent: #{request.user_agent}"
|
||||
Rails.logger.warn " IP Address: #{request.remote_ip}"
|
||||
|
||||
# In production, you might want to send this to a security monitoring service
|
||||
# For now, we'll just log it and return a success response
|
||||
|
||||
head :no_content
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Invalid CSP violation report: #{e.message}"
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,22 +3,27 @@ module Api
|
||||
# ForwardAuth endpoints need session storage for return URL
|
||||
allow_unauthenticated_access
|
||||
skip_before_action :verify_authenticity_token
|
||||
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests }
|
||||
|
||||
# GET /api/verify
|
||||
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
||||
# to verify if a user is authenticated and authorized to access a domain
|
||||
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 Application (forward_auth type)
|
||||
|
||||
# Check for one-time forward auth token first (to handle race condition)
|
||||
session_id = check_forward_auth_token
|
||||
|
||||
# If no token found, try to get session from cookie
|
||||
session_id ||= extract_session_id
|
||||
|
||||
# Get the session from cookie
|
||||
session_id = extract_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")
|
||||
end
|
||||
|
||||
# Find the session
|
||||
session = Session.find_by(id: session_id)
|
||||
# Find the session with user association (eager loading for performance)
|
||||
session = Session.includes(:user).find_by(id: session_id)
|
||||
unless session
|
||||
# Invalid session
|
||||
return render_unauthorized("Invalid session")
|
||||
@@ -30,67 +35,99 @@ module Api
|
||||
return render_unauthorized("Session expired")
|
||||
end
|
||||
|
||||
# Update last activity
|
||||
# Update last activity (skip validations for performance)
|
||||
session.update_column(:last_activity_at, Time.current)
|
||||
|
||||
# Get the user
|
||||
# Get the user (already loaded via includes(:user))
|
||||
user = session.user
|
||||
unless user.active?
|
||||
return render_unauthorized("User account is not active")
|
||||
end
|
||||
|
||||
# Check for forward auth rule authorization
|
||||
# Check for forward auth application authorization
|
||||
# Get the forwarded host for domain matching
|
||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
|
||||
if forwarded_host.present?
|
||||
# Find matching forward auth rule for this domain
|
||||
rule = ForwardAuthRule.active.find { |r| r.matches_domain?(forwarded_host) }
|
||||
# Load active forward auth applications with their associations for better performance
|
||||
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||
apps = Application.forward_auth.includes(:allowed_groups).active
|
||||
|
||||
unless rule
|
||||
Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
|
||||
return render_forbidden("No authentication rule configured for this domain")
|
||||
# Find matching forward auth application for this domain
|
||||
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
||||
|
||||
if app
|
||||
# Check if user is allowed by this application
|
||||
unless app.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.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 app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
|
||||
else
|
||||
# No application found - allow access with default headers (original behavior)
|
||||
Rails.logger.info "ForwardAuth: No application found for domain: #{forwarded_host}, allowing with default headers"
|
||||
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
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||
end
|
||||
|
||||
# User is authenticated and authorized
|
||||
# Return 200 with user information headers
|
||||
response.headers["Remote-User"] = user.email_address
|
||||
response.headers["Remote-Email"] = user.email_address
|
||||
response.headers["Remote-Name"] = user.email_address
|
||||
# Return 200 with user information headers using app-specific configuration
|
||||
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||
case key
|
||||
when :user, :email, :name
|
||||
[header_name, user.email_address]
|
||||
when :groups
|
||||
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||
when :admin
|
||||
[header_name, user.admin? ? "true" : "false"]
|
||||
end
|
||||
}.compact.to_h
|
||||
|
||||
# Add groups if user has any
|
||||
if user.groups.any?
|
||||
response.headers["Remote-Groups"] = user.groups.pluck(:name).join(",")
|
||||
headers.each { |key, value| response.headers[key] = value }
|
||||
|
||||
# Log what headers we're sending (helpful for debugging)
|
||||
if headers.any?
|
||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
||||
else
|
||||
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||
end
|
||||
|
||||
# Add admin flag
|
||||
response.headers["Remote-Admin"] = user.admin? ? "true" : "false"
|
||||
|
||||
# Return 200 OK with no body
|
||||
head :ok
|
||||
end
|
||||
|
||||
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
|
||||
# Extract session ID from cookie
|
||||
# Rails uses signed cookies by default
|
||||
cookies.signed[:session_id]
|
||||
session_id = cookies.signed[:session_id]
|
||||
session_id
|
||||
end
|
||||
|
||||
def extract_app_from_headers
|
||||
# This method is deprecated since we now use ForwardAuthRule domain matching
|
||||
# This method is deprecated since we now use Application (forward_auth type) domain matching
|
||||
# Keeping it for backward compatibility but it's no longer used
|
||||
nil
|
||||
end
|
||||
@@ -98,11 +135,9 @@ module Api
|
||||
def render_unauthorized(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||
|
||||
# Set header to help with debugging
|
||||
response.headers["X-Auth-Reason"] = reason if reason
|
||||
|
||||
# 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
|
||||
# This will be used after authentication
|
||||
@@ -113,11 +148,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']}"
|
||||
|
||||
original_url = if original_host
|
||||
# Use the forwarded host and URI
|
||||
# Use the forwarded host and URI (original behavior)
|
||||
"https://#{original_host}#{original_uri}"
|
||||
else
|
||||
# Fallback: just redirect to the root of the original host
|
||||
"https://#{request.headers['Host']}"
|
||||
# Fallback: use the validated redirect URL or default
|
||||
redirect_url || "https://clinch.aapamilne.com"
|
||||
end
|
||||
|
||||
# Debug: log what we're redirecting to after login
|
||||
@@ -141,11 +176,43 @@ module Api
|
||||
def render_forbidden(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||
|
||||
# Set header to help with debugging
|
||||
response.headers["X-Auth-Reason"] = reason if reason
|
||||
|
||||
# Return 403 Forbidden
|
||||
head :forbidden
|
||||
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 ForwardAuth applications
|
||||
matching_app = Application.forward_auth.active.find do |app|
|
||||
app.matches_domain?(redirect_domain)
|
||||
end
|
||||
|
||||
matching_app ? url : nil
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def domain_has_forward_auth_rule?(domain)
|
||||
return false if domain.blank?
|
||||
|
||||
Application.forward_auth.active.any? do |app|
|
||||
app.matches_domain?(domain.downcase)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
require 'uri'
|
||||
require 'public_suffix'
|
||||
require 'ipaddr'
|
||||
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
@@ -31,14 +35,17 @@ module Authentication
|
||||
|
||||
def request_authentication
|
||||
session[:return_to_after_authenticating] = request.url
|
||||
redirect_to new_session_path
|
||||
redirect_to signin_path
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def start_new_session_for(user)
|
||||
user.update!(last_sign_in_at: Time.current)
|
||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||
Current.session = session
|
||||
|
||||
@@ -56,6 +63,10 @@ module Authentication
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
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
|
||||
|
||||
@@ -64,36 +75,72 @@ module Authentication
|
||||
cookies.delete(:session_id)
|
||||
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:
|
||||
# - clinch.aapamilne.com -> .aapamilne.com
|
||||
# - app.example.co.uk -> .example.co.uk
|
||||
# - localhost -> nil (no domain setting for local development)
|
||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||
# - 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)
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Split hostname into parts
|
||||
parts = host.split('.')
|
||||
# Strip port number for domain parsing
|
||||
host_without_port = host.split(':').first
|
||||
|
||||
# For normal domains like example.com, we need at least 2 parts
|
||||
# For complex domains like co.uk, we need at least 3 parts
|
||||
return nil if parts.length < 2
|
||||
# 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
|
||||
|
||||
# Extract root domain with leading dot for cross-subdomain cookies
|
||||
if parts.length >= 3
|
||||
# Check if it's a known complex TLD
|
||||
complex_tlds = %w[co.uk com.au co.nz co.za co.jp]
|
||||
second_level = "#{parts[-2]}.#{parts[-1]}"
|
||||
# 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
|
||||
|
||||
if complex_tlds.include?(second_level)
|
||||
# For complex TLDs, include more parts: app.example.co.uk -> .example.co.uk
|
||||
root_parts = parts[-3..-1]
|
||||
return ".#{root_parts.join('.')}"
|
||||
end
|
||||
# Create a one-time token for forward auth to handle the race condition
|
||||
# where the browser hasn't processed the session cookie yet
|
||||
def create_forward_auth_token(session_obj)
|
||||
# Generate a secure random token
|
||||
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
|
||||
|
||||
# For regular domains: app.example.com -> .example.com
|
||||
root_parts = parts[-2..-1]
|
||||
".#{root_parts.join('.')}"
|
||||
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
|
||||
# Discovery and JWKS endpoints are public
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo]
|
||||
skip_before_action :verify_authenticity_token, only: [:token]
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :logout]
|
||||
|
||||
# GET /.well-known/openid-configuration
|
||||
def discovery
|
||||
@@ -13,6 +13,7 @@ class OidcController < ApplicationController
|
||||
token_endpoint: "#{base_url}/oauth/token",
|
||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||
end_session_endpoint: "#{base_url}/logout",
|
||||
response_types_supported: ["code"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
@@ -81,6 +82,30 @@ class OidcController < ApplicationController
|
||||
return
|
||||
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
|
||||
session[:oauth_params] = {
|
||||
client_id: client_id,
|
||||
@@ -92,7 +117,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# Render consent page
|
||||
@redirect_uri = redirect_uri
|
||||
@scopes = scope.split(" ")
|
||||
@scopes = requested_scopes
|
||||
render :consent
|
||||
end
|
||||
|
||||
@@ -108,36 +133,47 @@ class OidcController < ApplicationController
|
||||
# User denied consent
|
||||
if params[:deny].present?
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params[:redirect_uri]}?error=access_denied"
|
||||
error_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
|
||||
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
||||
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
code = SecureRandom.urlsafe_base64(32)
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
code: code,
|
||||
redirect_uri: oauth_params[:redirect_uri],
|
||||
scope: oauth_params[:scope],
|
||||
redirect_uri: oauth_params['redirect_uri'],
|
||||
scope: oauth_params['scope'],
|
||||
nonce: oauth_params['nonce'],
|
||||
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
|
||||
session.delete(:oauth_params)
|
||||
|
||||
# Redirect back to client with authorization code
|
||||
redirect_uri = "#{oauth_params[:redirect_uri]}?code=#{code}"
|
||||
redirect_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
|
||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
|
||||
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
end
|
||||
@@ -161,7 +197,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# Find and validate the application
|
||||
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
|
||||
return
|
||||
end
|
||||
@@ -210,7 +246,7 @@ class OidcController < ApplicationController
|
||||
)
|
||||
|
||||
# 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
|
||||
render json: {
|
||||
@@ -266,9 +302,44 @@ class OidcController < ApplicationController
|
||||
# Add admin claim if user is admin
|
||||
claims[:admin] = true if user.admin?
|
||||
|
||||
# Merge custom claims from groups
|
||||
user.groups.each do |group|
|
||||
claims.merge!(group.parsed_custom_claims)
|
||||
end
|
||||
|
||||
# Merge custom claims from user (overrides group claims)
|
||||
claims.merge!(user.parsed_custom_claims)
|
||||
|
||||
render json: claims
|
||||
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
|
||||
|
||||
def extract_client_credentials
|
||||
|
||||
@@ -28,7 +28,7 @@ class PasswordsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_user_by_token
|
||||
@user = User.find_by_password_reset_token!(params[:token])
|
||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ class ProfilesController < ApplicationController
|
||||
def show
|
||||
@user = Current.session.user
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
@connected_applications = @user.oidc_user_consents.includes(:application).order(granted_at: :desc)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -33,6 +34,34 @@ class ProfilesController < ApplicationController
|
||||
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
|
||||
|
||||
def email_params
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class SessionsController < ApplicationController
|
||||
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: 5, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_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: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||
|
||||
def new
|
||||
# Redirect to signup if this is first run
|
||||
@@ -16,14 +16,19 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
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?
|
||||
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
|
||||
|
||||
# Check if user is 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
|
||||
end
|
||||
|
||||
@@ -31,9 +36,10 @@ class SessionsController < ApplicationController
|
||||
if user.totp_enabled?
|
||||
# Store user ID in session temporarily for TOTP verification
|
||||
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?
|
||||
session[:totp_redirect_url] = params[:rd]
|
||||
validated_url = validate_redirect_url(params[:rd])
|
||||
session[:totp_redirect_url] = validated_url if validated_url
|
||||
end
|
||||
redirect_to totp_verification_path(rd: params[:rd])
|
||||
return
|
||||
@@ -63,6 +69,12 @@ class SessionsController < ApplicationController
|
||||
if request.post?
|
||||
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
|
||||
if user.verify_totp(code)
|
||||
session.delete(:pending_totp_user_id)
|
||||
@@ -105,4 +117,33 @@ class SessionsController < ApplicationController
|
||||
session.destroy
|
||||
redirect_to profile_path, notice: "Session revoked successfully."
|
||||
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
|
||||
|
||||
@@ -1,2 +1,22 @@
|
||||
module ApplicationHelper
|
||||
def smtp_configured?
|
||||
return true if Rails.env.test?
|
||||
|
||||
smtp_address = ENV["SMTP_ADDRESS"]
|
||||
smtp_port = ENV["SMTP_PORT"]
|
||||
|
||||
smtp_address.present? &&
|
||||
smtp_port.present? &&
|
||||
smtp_address != "localhost" &&
|
||||
!smtp_address.start_with?("127.0.0.1") &&
|
||||
!smtp_address.start_with?("localhost")
|
||||
end
|
||||
|
||||
def email_delivery_method
|
||||
if Rails.env.development?
|
||||
ActionMailer::Base.delivery_method
|
||||
else
|
||||
:smtp
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
default from: "from@example.com"
|
||||
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
|
||||
layout "mailer"
|
||||
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,32 +1,48 @@
|
||||
class Application < ApplicationRecord
|
||||
has_secure_password :client_secret, validations: false
|
||||
|
||||
has_many :application_groups, dependent: :destroy
|
||||
has_many :allowed_groups, through: :application_groups, source: :group
|
||||
has_many :oidc_authorization_codes, dependent: :destroy
|
||||
has_many :oidc_access_tokens, dependent: :destroy
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc saml] }
|
||||
inclusion: { in: %w[oidc forward_auth] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
validates :client_secret, presence: true, if: :oidc?
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
|
||||
|
||||
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
||||
|
||||
# Default header configuration for ForwardAuth
|
||||
DEFAULT_HEADERS = {
|
||||
user: 'X-Remote-User',
|
||||
email: 'X-Remote-Email',
|
||||
name: 'X-Remote-Name',
|
||||
groups: 'X-Remote-Groups',
|
||||
admin: 'X-Remote-Admin'
|
||||
}.freeze
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :oidc, -> { where(app_type: "oidc") }
|
||||
scope :saml, -> { where(app_type: "saml") }
|
||||
scope :forward_auth, -> { where(app_type: "forward_auth") }
|
||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||
|
||||
# Type checks
|
||||
def oidc?
|
||||
app_type == "oidc"
|
||||
end
|
||||
|
||||
def saml?
|
||||
app_type == "saml"
|
||||
def forward_auth?
|
||||
app_type == "forward_auth"
|
||||
end
|
||||
|
||||
# Access control
|
||||
@@ -56,10 +72,90 @@ class Application < ApplicationRecord
|
||||
{}
|
||||
end
|
||||
|
||||
# ForwardAuth helpers
|
||||
def parsed_headers_config
|
||||
return {} unless headers_config.present?
|
||||
headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
# Check if a domain matches this application's pattern (for ForwardAuth)
|
||||
def matches_domain?(domain)
|
||||
return false if domain.blank? || !forward_auth?
|
||||
|
||||
pattern = domain_pattern.gsub('.', '\.')
|
||||
pattern = pattern.gsub('*', '[^.]*')
|
||||
|
||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||
regex.match?(domain.downcase)
|
||||
end
|
||||
|
||||
# Policy determination based on user status (for ForwardAuth)
|
||||
def policy_for_user(user)
|
||||
return 'deny' unless active?
|
||||
return 'deny' unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return 'bypass' if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||
else
|
||||
'deny'
|
||||
end
|
||||
end
|
||||
|
||||
# Get effective header configuration (for ForwardAuth)
|
||||
def effective_headers
|
||||
DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
|
||||
end
|
||||
|
||||
# Generate headers for a specific user (for ForwardAuth)
|
||||
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 (for ForwardAuth)
|
||||
def headers_disabled?
|
||||
headers_config.present? && effective_headers.values.all?(&:blank?)
|
||||
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
|
||||
|
||||
def generate_client_credentials
|
||||
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
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
class ForwardAuthRule < ApplicationRecord
|
||||
has_many :forward_auth_rule_groups, dependent: :destroy
|
||||
has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group
|
||||
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :active, inclusion: { in: [true, false] }
|
||||
|
||||
normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||
|
||||
# Check if a domain matches this rule
|
||||
def matches_domain?(domain)
|
||||
return false if domain.blank?
|
||||
|
||||
pattern = domain_pattern.gsub('.', '\.')
|
||||
pattern = pattern.gsub('*', '[^.]*')
|
||||
|
||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||
regex.match?(domain.downcase)
|
||||
end
|
||||
|
||||
# Access control for forward auth
|
||||
def user_allowed?(user)
|
||||
return false unless active?
|
||||
return false unless user.active?
|
||||
|
||||
# If no groups are specified, allow all active users (bypass)
|
||||
return true if allowed_groups.empty?
|
||||
|
||||
# Otherwise, user must be in at least one of the allowed groups
|
||||
(user.groups & allowed_groups).any?
|
||||
end
|
||||
|
||||
# Policy determination based on user status and rule configuration
|
||||
def policy_for_user(user)
|
||||
return 'deny' unless active?
|
||||
return 'deny' unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return 'bypass' if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||
else
|
||||
'deny'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
class ForwardAuthRuleGroup < ApplicationRecord
|
||||
belongs_to :forward_auth_rule
|
||||
belongs_to :group
|
||||
|
||||
validates :forward_auth_rule_id, uniqueness: { scope: :group_id }
|
||||
end
|
||||
@@ -6,4 +6,9 @@ class Group < ApplicationRecord
|
||||
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
custom_claims || {}
|
||||
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,20 @@ class User < ApplicationRecord
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :user_groups, dependent: :destroy
|
||||
has_many :groups, through: :user_groups
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
|
||||
# Token generation for passwordless flows
|
||||
generates_token_for :invitation, expires_in: 7.days
|
||||
generates_token_for :password_reset, expires_in: 1.hour
|
||||
generates_token_for :magic_login, expires_in: 15.minutes
|
||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||
updated_at
|
||||
end
|
||||
|
||||
generates_token_for :password_reset, expires_in: 1.hour do
|
||||
updated_at
|
||||
end
|
||||
|
||||
generates_token_for :magic_login, expires_in: 15.minutes do
|
||||
last_sign_in_at
|
||||
end
|
||||
|
||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||
|
||||
@@ -71,6 +80,26 @@ class User < ApplicationRecord
|
||||
JSON.parse(backup_codes)
|
||||
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
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
custom_claims || {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_backup_codes
|
||||
|
||||
@@ -27,6 +27,14 @@ class OidcJwtService
|
||||
# Add admin claim if user is admin
|
||||
payload[:admin] = true if user.admin?
|
||||
|
||||
# Merge custom claims from groups
|
||||
user.groups.each do |group|
|
||||
payload.merge!(group.parsed_custom_claims)
|
||||
end
|
||||
|
||||
# Merge custom claims from user (overrides group claims)
|
||||
payload.merge!(user.parsed_custom_claims)
|
||||
|
||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||
end
|
||||
|
||||
@@ -55,7 +63,7 @@ class OidcJwtService
|
||||
def issuer_url
|
||||
# In production, this should come from ENV or config
|
||||
# For now, we'll use a placeholder that can be overridden
|
||||
ENV.fetch("CLINCH_HOST", "http://localhost:3000")
|
||||
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<div>
|
||||
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
||||
<% if application.persisted? %>
|
||||
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||
<% end %>
|
||||
@@ -53,6 +53,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forward Auth-specific fields -->
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.forward_auth? %>">
|
||||
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||
|
||||
<div>
|
||||
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :domain_pattern, 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: "*.example.com or app.example.com" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :headers_config, rows: 8, 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: '{"user": "X-Remote-User", "email": "X-Remote-Email"}' %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Override default headers. Leave empty to use defaults: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||
@@ -83,17 +100,29 @@
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Show/hide OIDC fields based on app type selection
|
||||
// Show/hide type-specific fields based on app type selection
|
||||
const appTypeSelect = document.querySelector('#application_app_type');
|
||||
const oidcFields = document.querySelector('#oidc-fields');
|
||||
const forwardAuthFields = document.querySelector('#forward-auth-fields');
|
||||
|
||||
if (appTypeSelect && oidcFields) {
|
||||
appTypeSelect.addEventListener('change', function() {
|
||||
if (this.value === 'oidc') {
|
||||
oidcFields.style.display = 'block';
|
||||
} else {
|
||||
oidcFields.style.display = 'none';
|
||||
}
|
||||
});
|
||||
function updateFieldVisibility() {
|
||||
if (!appTypeSelect) return;
|
||||
|
||||
const appType = appTypeSelect.value;
|
||||
|
||||
if (oidcFields) {
|
||||
oidcFields.style.display = appType === 'oidc' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (forwardAuthFields) {
|
||||
forwardAuthFields.style.display = appType === 'forward_auth' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (appTypeSelect) {
|
||||
appTypeSelect.addEventListener('change', updateFieldVisibility);
|
||||
}
|
||||
|
||||
// Initialize visibility on page load
|
||||
updateFieldVisibility();
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<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 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" %>
|
||||
@@ -56,9 +56,11 @@
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900" %>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
<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>
|
||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
||||
@@ -27,8 +44,8 @@
|
||||
<% case @application.app_type %>
|
||||
<% when "oidc" %>
|
||||
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
|
||||
<% when "saml" %>
|
||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
||||
<% when "forward_auth" %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -64,7 +81,12 @@
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</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"><%= @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>
|
||||
</div>
|
||||
<div>
|
||||
@@ -84,6 +106,35 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
||||
<% if @application.forward_auth? %>
|
||||
<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">Forward Auth Configuration</h3>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</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"><%= @application.domain_pattern %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.headers_config.present? && @application.headers_config.any? %>
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
||||
<% else %>
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
||||
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
|
||||
</div>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Group Access Control -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<% content_for :title, "Edit Forward Auth Rule" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
Edit Forward Auth Rule
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||
<div class="px-4 py-6 sm:p-8">
|
||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
Groups
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
||||
{ selected: @forward_auth_rule.allowed_groups.map(&:id), prompt: "Select groups (leave empty for bypass)" },
|
||||
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||
<%= link_to "Cancel", admin_forward_auth_rule_path(@forward_auth_rule), class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
||||
<%= form.submit "Update Rule", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,89 +0,0 @@
|
||||
<% content_for :title, "Forward Auth Rules" %>
|
||||
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-base font-semibold leading-6 text-gray-900">Forward Auth Rules</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">A list of all forward authentication rules for domain-based access control.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
||||
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<% if @forward_auth_rules.any? %>
|
||||
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Domain Pattern</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
<% @forward_auth_rules.each do |rule| %>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
||||
<%= rule.domain_pattern %>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.allowed_groups.any? %>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<% rule.allowed_groups.each do |group| %>
|
||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
||||
<%= group.name %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Bypass (All Users)
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.active? %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Active
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= link_to "Delete", admin_forward_auth_rule_path(rule),
|
||||
data: {
|
||||
turbo_method: :delete,
|
||||
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
||||
},
|
||||
class: "text-red-600 hover:text-red-900" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900">No forward auth rules</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a new forward authentication rule.</p>
|
||||
<div class="mt-6">
|
||||
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,57 +0,0 @@
|
||||
<% content_for :title, "New Forward Auth Rule" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
New Forward Auth Rule
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||
<div class="px-4 py-6 sm:p-8">
|
||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
Groups
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
||||
{ prompt: "Select groups (leave empty for bypass)" },
|
||||
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||
<%= link_to "Cancel", admin_forward_auth_rules_path, class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
||||
<%= form.submit "Create Rule", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,111 +0,0 @@
|
||||
<% content_for :title, "Forward Auth Rule: #{@forward_auth_rule.domain_pattern}" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
<%= @forward_auth_rule.domain_pattern %>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="mt-4 flex md:ml-4 md:mt-0">
|
||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
<%= link_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule),
|
||||
data: {
|
||||
turbo_method: :delete,
|
||||
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
||||
},
|
||||
class: "ml-3 inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Rule Details</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Forward authentication rule configuration.</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200">
|
||||
<dl>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @forward_auth_rule.domain_pattern %></code>
|
||||
</dd>
|
||||
</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">Status</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<% if @forward_auth_rule.active? %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Active
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Access Policy</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<% if @allowed_groups.any? %>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm">Only users in these groups are allowed access:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% @allowed_groups.each do |group| %>
|
||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
||||
<%= group.name %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Bypass - All authenticated users allowed
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<%= @forward_auth_rule.created_at.strftime("%B %d, %Y at %I:%M %p") %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<%= @forward_auth_rule.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">How this rule works</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>This rule matches domains that fit the pattern: <code class="bg-blue-100 px-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></li>
|
||||
<% if @allowed_groups.any? %>
|
||||
<li>Only users belonging to the specified groups will be granted access</li>
|
||||
<li>Users will be required to authenticate with password (and 2FA if enabled)</li>
|
||||
<% else %>
|
||||
<li>All authenticated users will be granted access (bypass mode)</li>
|
||||
<% end %>
|
||||
<li>Inactive rules are ignored during authentication</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,6 +49,12 @@
|
||||
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, 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: '{"roles": ["admin", "editor"]}' %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= form.submit group.persisted? ? "Update Group" : "Create Group", 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" %>
|
||||
<%= link_to "Cancel", admin_groups_path, 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" %>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8, 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: '{"department": "engineering", "level": "senior"}' %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= form.submit user.persisted? ? "Update User" : "Create User", 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" %>
|
||||
<%= link_to "Cancel", admin_users_path, 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" %>
|
||||
|
||||
@@ -8,6 +8,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% unless smtp_configured? %>
|
||||
<div class="mt-6 rounded-md bg-yellow-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
Email delivery not configured
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
<% if Rails.env.development? %>
|
||||
Emails are being delivered using <span class="font-mono"><%= email_delivery_method %></span> and will open in your browser.
|
||||
<% else %>
|
||||
SMTP settings are not configured. Invitation emails and other notifications will not be sent.
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
<% if Rails.env.development? %>
|
||||
To configure SMTP for production, set environment variables like <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, etc.
|
||||
<% else %>
|
||||
Configure SMTP settings by setting environment variables: <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, <span class="font-mono">SMTP_PASSWORD</span>, etc.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
@@ -66,8 +99,17 @@
|
||||
<%= user.groups.count %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
||||
<%= button_to "Delete", admin_user_path(user), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this user?" }, class: "text-red-600 hover:text-red-900" %>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<% if user.pending_invitation? %>
|
||||
<%= link_to "Resend", resend_invitation_admin_user_path(user),
|
||||
data: { turbo_method: :post },
|
||||
class: "text-yellow-600 hover:text-yellow-900" %>
|
||||
<% end %>
|
||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<%= link_to "Delete", admin_user_path(user),
|
||||
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },
|
||||
class: "text-red-600 hover:text-red-900" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
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>
|
||||
<% else %>
|
||||
<!-- Public layout (signup/signin) -->
|
||||
<main class="container mx-auto mt-28 px-5 flex">
|
||||
<main class="container mx-auto mt-28 px-5">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</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",
|
||||
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>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Profile & Settings</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your account settings and security preferences.</p>
|
||||
<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, active sessions, and connected applications.</p>
|
||||
</div>
|
||||
|
||||
<!-- Account Information -->
|
||||
@@ -199,6 +199,44 @@
|
||||
}
|
||||
</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 -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
@@ -243,4 +281,27 @@
|
||||
</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>
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
</p>
|
||||
</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? %>
|
||||
<div>
|
||||
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -26,6 +29,7 @@
|
||||
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -57,16 +57,6 @@
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Admin: Forward Auth Rules -->
|
||||
<li>
|
||||
<%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/forward_auth_rules') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Admin: Groups -->
|
||||
<li>
|
||||
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }" do %>
|
||||
@@ -170,14 +160,6 @@
|
||||
Groups
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<li>
|
||||
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
||||
|
||||
@@ -23,5 +23,18 @@ module Clinch
|
||||
#
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
|
||||
# Configure SMTP settings using environment variables
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = {
|
||||
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
||||
port: ENV.fetch('SMTP_PORT', 587),
|
||||
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
||||
user_name: ENV.fetch('SMTP_USERNAME', nil),
|
||||
password: ENV.fetch('SMTP_PASSWORD', nil),
|
||||
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
||||
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
||||
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,8 +31,9 @@ Rails.application.configure do
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
config.active_storage.service = :local
|
||||
|
||||
# Don't care if the mailer can't send.
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
# Preview emails in browser using letter_opener
|
||||
config.action_mailer.delivery_method = :letter_opener
|
||||
config.action_mailer.perform_deliveries = true
|
||||
|
||||
# Make template changes take effect immediately.
|
||||
config.action_mailer.perform_caching = false
|
||||
@@ -58,9 +59,8 @@ Rails.application.configure do
|
||||
# Highlight code that enqueued background job in logs.
|
||||
config.active_job.verbose_enqueue_logs = true
|
||||
|
||||
# Use Solid Queue for background jobs (same as production).
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
# Use async processor for background jobs in development
|
||||
config.active_job.queue_adapter = :async
|
||||
|
||||
|
||||
# Highlight code that triggered redirect in logs.
|
||||
|
||||
@@ -49,16 +49,17 @@ Rails.application.configure do
|
||||
# Replace the default in-process memory cache store with a durable alternative.
|
||||
config.cache_store = :solid_cache_store
|
||||
|
||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
# Use async processor for background jobs (modify as needed for production)
|
||||
config.active_job.queue_adapter = :async
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||
# config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
config.action_mailer.default_url_options = {
|
||||
host: ENV.fetch('CLINCH_HOST', 'example.com')
|
||||
}
|
||||
|
||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||
# config.action_mailer.smtp_settings = {
|
||||
@@ -80,11 +81,56 @@ Rails.application.configure do
|
||||
config.active_record.attributes_for_inspect = [ :id ]
|
||||
|
||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||
# config.hosts = [
|
||||
# "example.com", # Allow requests from example.com
|
||||
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
|
||||
# ]
|
||||
#
|
||||
# Configure allowed hosts based on deployment scenario
|
||||
allowed_hosts = [
|
||||
ENV.fetch('CLINCH_HOST', 'auth.example.com'), # External domain (auth service itself)
|
||||
]
|
||||
|
||||
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
||||
host_domain = ENV.fetch('CLINCH_HOST', 'auth.example.com')
|
||||
if host_domain.present?
|
||||
begin
|
||||
# Use PublicSuffix to properly extract the domain
|
||||
domain = PublicSuffix.parse(host_domain)
|
||||
registrable_domain = domain.domain # Gets "example.com" from "auth.example.com"
|
||||
|
||||
if registrable_domain.present?
|
||||
# Create regex to allow any subdomain of the registrable domain
|
||||
allowed_hosts << /.*#{Regexp.escape(registrable_domain)}/
|
||||
end
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback to simple domain extraction if PublicSuffix fails
|
||||
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
||||
base_domain = host_domain.split('.').last(2).join('.')
|
||||
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
||||
end
|
||||
end
|
||||
|
||||
# Allow Docker service names if running in same compose
|
||||
if ENV['CLINCH_DOCKER_SERVICE_NAME']
|
||||
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
|
||||
end
|
||||
|
||||
# Allow internal IP access for cross-compose or host networking
|
||||
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true'
|
||||
# Specific host IP
|
||||
allowed_hosts << '192.168.2.246'
|
||||
|
||||
# Private IP ranges for internal network access
|
||||
allowed_hosts += [
|
||||
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network
|
||||
/10\.\d+\.\d+\.\d+/, # 10.0.0.0/8 private network
|
||||
/172\.(1[6-9]|2[0-9]|3[0-1])\.\d+\.\d+/ # 172.16.0.0/12 private network
|
||||
]
|
||||
end
|
||||
|
||||
# Local development fallbacks
|
||||
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
|
||||
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
|
||||
end
|
||||
|
||||
config.hosts = allowed_hosts
|
||||
|
||||
# Skip DNS rebinding protection for the default health check endpoint.
|
||||
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||
end
|
||||
|
||||
@@ -4,26 +4,74 @@
|
||||
# See the Securing Rails Applications Guide for more information:
|
||||
# https://guides.rubyonrails.org/security.html#content-security-policy-header
|
||||
|
||||
# Rails.application.configure do
|
||||
# config.content_security_policy do |policy|
|
||||
# policy.default_src :self, :https
|
||||
# policy.font_src :self, :https, :data
|
||||
# policy.img_src :self, :https, :data
|
||||
# policy.object_src :none
|
||||
# policy.script_src :self, :https
|
||||
# policy.style_src :self, :https
|
||||
# # Specify URI for violation reports
|
||||
# # policy.report_uri "/csp-violation-report-endpoint"
|
||||
# end
|
||||
#
|
||||
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
|
||||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
||||
# config.content_security_policy_nonce_directives = %w(script-src style-src)
|
||||
#
|
||||
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
|
||||
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
|
||||
# # config.content_security_policy_nonce_auto = true
|
||||
#
|
||||
# # Report violations without enforcing the policy.
|
||||
# # config.content_security_policy_report_only = true
|
||||
# end
|
||||
Rails.application.configure do
|
||||
config.content_security_policy do |policy|
|
||||
# Default policy: only allow resources from same origin and HTTPS
|
||||
policy.default_src :self, :https
|
||||
|
||||
# Scripts: strict security with nonce support for dynamic content
|
||||
policy.script_src :self, :https, :strict_dynamic
|
||||
|
||||
# Styles: allow inline styles for CSS frameworks, but require HTTPS
|
||||
policy.style_src :self, :https, :unsafe_inline
|
||||
|
||||
# Images: allow data URIs for inline images and HTTPS sources
|
||||
policy.img_src :self, :https, :data
|
||||
|
||||
# Fonts: allow self-hosted and HTTPS fonts, plus data URIs
|
||||
policy.font_src :self, :https, :data
|
||||
|
||||
# Media: allow self and HTTPS media sources
|
||||
policy.media_src :self, :https
|
||||
|
||||
# Objects: block potentially dangerous plugins
|
||||
policy.object_src :none
|
||||
|
||||
# Base URI: restrict base tag to same origin
|
||||
policy.base_uri :self
|
||||
|
||||
# Form actions: only allow forms to submit to same origin
|
||||
policy.form_action :self
|
||||
|
||||
# Frame ancestors: prevent clickjacking by disallowing framing
|
||||
policy.frame_ancestors :none
|
||||
|
||||
# Frame sources: block iframes unless explicitly needed
|
||||
policy.frame_src :none
|
||||
|
||||
# Connect sources: control where XHR/Fetch can connect
|
||||
policy.connect_src :self, :https
|
||||
|
||||
# Manifest: only allow same-origin manifest files
|
||||
policy.manifest_src :self
|
||||
|
||||
# Worker sources: control web worker origins
|
||||
policy.worker_src :self, :https
|
||||
|
||||
# Report URI: send violation reports to our monitoring endpoint
|
||||
if Rails.env.production?
|
||||
policy.report_uri "/api/csp-violation-report"
|
||||
end
|
||||
end
|
||||
|
||||
# Generate session nonces for permitted inline scripts and styles
|
||||
config.content_security_policy_nonce_generator = ->(request) {
|
||||
# Use a secure random nonce instead of session ID for better security
|
||||
SecureRandom.base64(16)
|
||||
}
|
||||
|
||||
# Apply nonces to script and style directives
|
||||
config.content_security_policy_nonce_directives = %w(script-src style-src)
|
||||
|
||||
# Automatically add `nonce` attributes to script/style tags
|
||||
config.content_security_policy_nonce_auto = true
|
||||
|
||||
# Enforce CSP in production, but use report-only in development for debugging
|
||||
if Rails.env.production?
|
||||
# Enforce the policy in production
|
||||
config.content_security_policy_report_only = false
|
||||
else
|
||||
# Report violations only in development (helps with debugging)
|
||||
config.content_security_policy_report_only = true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,11 +31,11 @@ threads threads_count, threads_count
|
||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||
port ENV.fetch("PORT", 3000)
|
||||
|
||||
|
||||
# Allow puma to be restarted by `bin/rails restart` command.
|
||||
plugin :tmp_restart
|
||||
|
||||
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
||||
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
||||
# Solid Queue plugin removed - now using async processor
|
||||
|
||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||
# In other environments, only set the PID file if requested.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# examples:
|
||||
# periodic_cleanup:
|
||||
# class: CleanSoftDeletedRecordsJob
|
||||
# queue: background
|
||||
# args: [ 1000, { batch_size: 500 } ]
|
||||
# schedule: every hour
|
||||
# periodic_cleanup_with_command:
|
||||
# command: "SoftDeletedRecord.due.delete_all"
|
||||
# priority: 2
|
||||
# schedule: at 5am every day
|
||||
|
||||
production:
|
||||
clear_solid_queue_finished_jobs:
|
||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||
schedule: every hour at minute 12
|
||||
@@ -1,6 +1,7 @@
|
||||
Rails.application.routes.draw do
|
||||
resource :session
|
||||
resources :passwords, param: :token
|
||||
resources :invitations, param: :token, only: [:show, :update]
|
||||
mount ActionCable.server => "/cable"
|
||||
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
@@ -25,15 +26,22 @@ Rails.application.routes.draw do
|
||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||
post "/oauth/token", to: "oidc#token"
|
||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||
get "/logout", to: "oidc#logout"
|
||||
|
||||
# ForwardAuth / Trusted Header SSO
|
||||
namespace :api do
|
||||
get "/verify", to: "forward_auth#verify"
|
||||
post "/csp-violation-report", to: "csp#violation_report"
|
||||
end
|
||||
|
||||
# Authenticated routes
|
||||
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
|
||||
member do
|
||||
delete :destroy, action: :destroy_other
|
||||
@@ -50,14 +58,17 @@ Rails.application.routes.draw do
|
||||
# Admin routes
|
||||
namespace :admin do
|
||||
root "dashboard#index"
|
||||
resources :users
|
||||
resources :users do
|
||||
member do
|
||||
post :resend_invitation
|
||||
end
|
||||
end
|
||||
resources :applications do
|
||||
member do
|
||||
post :regenerate_credentials
|
||||
end
|
||||
end
|
||||
resources :groups
|
||||
resources :forward_auth_rules
|
||||
end
|
||||
|
||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddCustomClaimsToGroupsAndUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :groups, :custom_claims, :json, default: {}, null: false
|
||||
add_column :users, :custom_claims, :json, default: {}, null: false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
class AddForwardAuthFieldsToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Add ForwardAuth-specific fields
|
||||
add_column :applications, :domain_pattern, :string
|
||||
add_column :applications, :headers_config, :json, default: {}, null: false
|
||||
|
||||
# Add index on domain_pattern for lookup performance
|
||||
add_index :applications, :domain_pattern, unique: true, where: "domain_pattern IS NOT NULL"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
# Temporarily define models for migration
|
||||
forward_auth_rule_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "forward_auth_rules"
|
||||
has_many :forward_auth_rule_groups, foreign_key: :forward_auth_rule_id, dependent: :destroy
|
||||
has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
forward_auth_rule_group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "forward_auth_rule_groups"
|
||||
belongs_to :forward_auth_rule, class_name: "MigrateForwardAuthRulesToApplications::ForwardAuthRule"
|
||||
belongs_to :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "groups"
|
||||
end
|
||||
|
||||
application_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "applications"
|
||||
has_many :application_groups, foreign_key: :application_id, dependent: :destroy
|
||||
end
|
||||
|
||||
application_group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "application_groups"
|
||||
belongs_to :application, class_name: "MigrateForwardAuthRulesToApplications::Application"
|
||||
belongs_to :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
# Assign to constants so we can reference them
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ForwardAuthRule", forward_auth_rule_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ForwardAuthRuleGroup", forward_auth_rule_group_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::Group", group_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::Application", application_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ApplicationGroup", application_group_class)
|
||||
|
||||
# Migrate each ForwardAuthRule to an Application
|
||||
forward_auth_rule_class.find_each do |rule|
|
||||
# Create Application from ForwardAuthRule
|
||||
app = application_class.create!(
|
||||
name: rule.domain_pattern.titleize,
|
||||
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
||||
app_type: 'forward_auth',
|
||||
domain_pattern: rule.domain_pattern,
|
||||
headers_config: rule.headers_config || {},
|
||||
active: rule.active
|
||||
)
|
||||
|
||||
# Migrate group associations
|
||||
forward_auth_rule_group_class.where(forward_auth_rule_id: rule.id).find_each do |far_group|
|
||||
application_group_class.create!(
|
||||
application_id: app.id,
|
||||
group_id: far_group.group_id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
# Remove all forward_auth applications created by this migration
|
||||
Application.where(app_type: 'forward_auth').destroy_all
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_const(name, value)
|
||||
parts = name.split("::")
|
||||
parts[0..-2].inject(Object) { |mod, part| mod.const_get(part) }.const_set(parts.last, value)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class RemoveRoleRelatedTablesAndColumns < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Remove join table first (due to foreign keys)
|
||||
drop_table :user_role_assignments if table_exists?(:user_role_assignments)
|
||||
|
||||
# Remove application_roles table
|
||||
drop_table :application_roles if table_exists?(:application_roles)
|
||||
|
||||
# Remove role-related columns from applications
|
||||
remove_column :applications, :role_mapping_mode, :string if column_exists?(:applications, :role_mapping_mode)
|
||||
remove_column :applications, :role_prefix, :string if column_exists?(:applications, :role_prefix)
|
||||
remove_column :applications, :role_claim_name, :string if column_exists?(:applications, :role_claim_name)
|
||||
remove_column :applications, :managed_permissions, :json if column_exists?(:applications, :managed_permissions)
|
||||
end
|
||||
end
|
||||
9
db/migrate/20251104015104_remove_forward_auth_tables.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
class RemoveForwardAuthTables < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Remove join table first (due to foreign keys)
|
||||
drop_table :forward_auth_rule_groups if table_exists?(:forward_auth_rule_groups)
|
||||
|
||||
# Remove forward_auth_rules table
|
||||
drop_table :forward_auth_rules if table_exists?(:forward_auth_rules)
|
||||
end
|
||||
end
|
||||
46
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_04_015104) do
|
||||
create_table "application_groups", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -25,8 +25,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||
t.boolean "active", default: true, null: false
|
||||
t.string "app_type", null: false
|
||||
t.string "client_id"
|
||||
t.string "client_secret"
|
||||
t.string "client_secret_digest"
|
||||
t.datetime "created_at", null: false
|
||||
t.text "description"
|
||||
t.string "domain_pattern"
|
||||
t.json "headers_config", default: {}, null: false
|
||||
t.text "metadata"
|
||||
t.string "name", null: false
|
||||
t.text "redirect_uris"
|
||||
@@ -34,28 +37,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["active"], name: "index_applications_on_active"
|
||||
t.index ["client_id"], name: "index_applications_on_client_id", unique: true
|
||||
t.index ["domain_pattern"], name: "index_applications_on_domain_pattern", unique: true, where: "domain_pattern IS NOT NULL"
|
||||
t.index ["slug"], name: "index_applications_on_slug", unique: true
|
||||
end
|
||||
|
||||
create_table "forward_auth_rule_groups", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "forward_auth_rule_id", null: false
|
||||
t.integer "group_id", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["forward_auth_rule_id"], name: "index_forward_auth_rule_groups_on_forward_auth_rule_id"
|
||||
t.index ["group_id"], name: "index_forward_auth_rule_groups_on_group_id"
|
||||
end
|
||||
|
||||
create_table "forward_auth_rules", force: :cascade do |t|
|
||||
t.boolean "active"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "domain_pattern"
|
||||
t.integer "policy"
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "groups", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.json "custom_claims", default: {}, null: false
|
||||
t.text "description"
|
||||
t.string "name", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -82,6 +70,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||
t.string "code", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.string "nonce"
|
||||
t.string "redirect_uri", null: false
|
||||
t.string "scope"
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -94,6 +83,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
|
||||
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|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "device_name"
|
||||
@@ -123,7 +125,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||
t.boolean "admin", default: false, null: false
|
||||
t.text "backup_codes"
|
||||
t.datetime "created_at", null: false
|
||||
t.json "custom_claims", default: {}, null: false
|
||||
t.string "email_address", null: false
|
||||
t.datetime "last_sign_in_at"
|
||||
t.string "password_digest", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.boolean "totp_required", default: false, null: false
|
||||
@@ -135,12 +139,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_10_23_234744) do
|
||||
|
||||
add_foreign_key "application_groups", "applications"
|
||||
add_foreign_key "application_groups", "groups"
|
||||
add_foreign_key "forward_auth_rule_groups", "forward_auth_rules"
|
||||
add_foreign_key "forward_auth_rule_groups", "groups"
|
||||
add_foreign_key "oidc_access_tokens", "applications"
|
||||
add_foreign_key "oidc_access_tokens", "users"
|
||||
add_foreign_key "oidc_authorization_codes", "applications"
|
||||
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 "user_groups", "groups"
|
||||
add_foreign_key "user_groups", "users"
|
||||
|
||||
393
docs/forward-auth.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# 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
|
||||
|
||||
## Implementation Overview
|
||||
|
||||
### Forward Auth Pattern
|
||||
|
||||
**Standard Forward Auth Approach:**
|
||||
- Returns `302 Found` or `303 See Other` with `Location` header
|
||||
- Direct browser redirects to authentication service
|
||||
- Uses HTTP status codes to communicate authentication state
|
||||
|
||||
**Clinch Current Implementation:**
|
||||
- Returns `302 Found` directly to login URL
|
||||
- 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:3000/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:3000
|
||||
}
|
||||
|
||||
# MEtube (protected by Clinch)
|
||||
metube.example.com {
|
||||
forward_auth clinch:3000 {
|
||||
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`
|
||||
- **Implementation Details**: See technical documentation below
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test forward auth endpoint directly
|
||||
curl -v http://localhost:3000/api/verify?rd=https://clinch.example.com
|
||||
|
||||
# Should return 302 redirect to login page
|
||||
# Or 200 OK if you have a valid session cookie
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Content Security Policy (CSP)
|
||||
|
||||
Clinch includes a comprehensive Content Security Policy to prevent Cross-Site Scripting (XSS) attacks by controlling which resources can be loaded by the browser.
|
||||
|
||||
**What CSP Prevents:**
|
||||
- Malicious script injection attacks
|
||||
- Unauthorized resource loading
|
||||
- Clickjacking through iframe protection
|
||||
- Data exfiltration through unauthorized connections
|
||||
|
||||
**CSP Features:**
|
||||
- **Strict script control**: Only allows scripts from same origin or HTTPS
|
||||
- **Nonce support**: Allows specific inline scripts with cryptographic nonces
|
||||
- **Frame protection**: Prevents clickjacking attacks
|
||||
- **Resource restrictions**: Controls images, fonts, styles, and media sources
|
||||
- **Violation reporting**: Monitors and logs attempted XSS attacks
|
||||
|
||||
**Development vs Production:**
|
||||
- **Development**: Report-only mode for debugging CSP violations
|
||||
- **Production**: Full enforcement with violation logging
|
||||
|
||||
### DNS Rebinding Protection
|
||||
|
||||
Clinch includes built-in DNS rebinding protection for enhanced security in all deployment scenarios.
|
||||
|
||||
**What is DNS Rebinding?**
|
||||
DNS rebinding attacks trick a victim's browser into accessing internal network resources by manipulating DNS responses, potentially allowing attackers to probe your authentication system.
|
||||
|
||||
**Clinch's Protection Layers:**
|
||||
1. **Rails Host Validation**: Blocks unauthorized domains at the application level
|
||||
2. **Infrastructure Security**: Caddy/Reverse proxy provides additional protection
|
||||
3. **Environment-Specific Configuration**: Adapts to your deployment scenario
|
||||
|
||||
### Deployment Scenarios
|
||||
|
||||
#### Scenario 1: Same Docker Compose (Recommended)
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
caddy:
|
||||
# ... caddy configuration
|
||||
|
||||
clinch:
|
||||
image: reg.tbdb.info/clinch:latest
|
||||
environment:
|
||||
- CLINCH_HOST=auth.aapamilne.com
|
||||
- CLINCH_DOCKER_SERVICE_NAME=clinch # Enable service name access
|
||||
- CLINCH_ALLOW_INTERNAL_IPS=true # Allow backup IP access
|
||||
- CLINCH_ALLOW_LOCALHOST=false
|
||||
```
|
||||
|
||||
**Caddy Configuration:**
|
||||
```caddyfile
|
||||
metube.aapamilne.com {
|
||||
forward_auth clinch:3000 { # Docker service name (preferred)
|
||||
uri /api/verify
|
||||
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy * {
|
||||
to http://192.168.2.223:8081
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Benefits:**
|
||||
- ✅ Docker network isolation prevents external access
|
||||
- ✅ Service names resolve to unpredictable internal IPs
|
||||
- ✅ Natural DNS rebinding protection
|
||||
- ✅ Application-level host validation as backup
|
||||
|
||||
#### Scenario 2: Separate Docker Composes (Current Setup)
|
||||
```yaml
|
||||
# clinch-compose/.env
|
||||
CLINCH_HOST=auth.aapamilne.com
|
||||
CLINCH_ALLOW_INTERNAL_IPS=true
|
||||
CLINCH_ALLOW_LOCALHOST=false
|
||||
CLINCH_DOCKER_SERVICE_NAME=
|
||||
```
|
||||
|
||||
**Caddy Configuration:**
|
||||
```caddyfile
|
||||
metube.aapamilne.com {
|
||||
forward_auth 192.168.2.246:3000 { # IP access across composes
|
||||
uri /api/verify
|
||||
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Benefits:**
|
||||
- ✅ Rails host validation blocks unauthorized domains
|
||||
- ✅ Only allows private IP ranges and your domain
|
||||
- ✅ Defense in depth (application + infrastructure security)
|
||||
|
||||
#### Scenario 3: External Deployment
|
||||
```yaml
|
||||
# Production environment
|
||||
environment:
|
||||
- CLINCH_HOST=auth.example.com
|
||||
- CLINCH_ALLOW_INTERNAL_IPS=false # Stricter for external
|
||||
- CLINCH_ALLOW_LOCALHOST=false
|
||||
```
|
||||
|
||||
**Caddy Configuration:**
|
||||
```caddyfile
|
||||
app.example.com {
|
||||
forward_auth auth.example.com:3000 { # External domain only
|
||||
uri /api/verify
|
||||
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Security Benefits:**
|
||||
- ✅ Only allows your external domain
|
||||
- ✅ Blocks internal IP access
|
||||
- ✅ Maximum security for public deployments
|
||||
|
||||
### Host Validation Environment Variables
|
||||
|
||||
| Variable | Default | Purpose | Recommended Setting |
|
||||
|----------|---------|---------|-------------------|
|
||||
| `CLINCH_HOST` | `auth.aapamilne.com` | Primary domain | Always set to your auth domain |
|
||||
| `CLINCH_DOCKER_SERVICE_NAME` | `nil` | Docker service name | Set to service name in same compose |
|
||||
| `CLINCH_ALLOW_INTERNAL_IPS` | `true` | Allow private IPs | `true` for internal, `false` for external |
|
||||
| `CLINCH_ALLOW_LOCALHOST` | `false` | Allow localhost access | `true` for development only |
|
||||
|
||||
### Security Architecture
|
||||
|
||||
Clinch provides **defense in depth** security with multiple protection layers:
|
||||
|
||||
**Application-Level Security:**
|
||||
- Host validation prevents unauthorized domain access
|
||||
- Session-based authentication with secure cookies
|
||||
- Rate limiting on sensitive endpoints
|
||||
- Input validation and sanitization
|
||||
- Content Security Policy (CSP) prevents XSS attacks
|
||||
|
||||
**Infrastructure Security:**
|
||||
- Docker network isolation
|
||||
- Reverse proxy access control
|
||||
- SSL/TLS encryption
|
||||
- Private network restrictions
|
||||
|
||||
**Benefits of Multi-Layer Security:**
|
||||
- If infrastructure security fails, application security still protects
|
||||
- Flexible deployment options without compromising security
|
||||
- Environment-specific configuration for different threat models
|
||||
|
||||
## 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 service name/IP 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
|
||||
5. **Host Validation Errors**:
|
||||
- **Problem**: "Blocked host: [host]" errors in logs
|
||||
- **Solution**: Check `CLINCH_HOST` and other environment variables
|
||||
- **Debug**: Verify your Caddy configuration matches allowed hosts
|
||||
6. **DNS Rebinding Protection**:
|
||||
- **Problem**: Legitimate requests blocked as "unauthorized host"
|
||||
- **Solution**: Ensure your deployment scenario matches environment variables
|
||||
- **Debug**: Check Rails logs for host validation messages
|
||||
|
||||
### 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
|
||||
|
||||
one:
|
||||
application: one
|
||||
group: one
|
||||
kavita_admin_group:
|
||||
application: kavita_app
|
||||
group: admin_group
|
||||
|
||||
two:
|
||||
application: two
|
||||
group: two
|
||||
kavita_editor_group:
|
||||
application: kavita_app
|
||||
group: editor_group
|
||||
|
||||
41
test/fixtures/applications.yml
vendored
@@ -1,21 +1,26 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
name: MyString
|
||||
slug: MyString
|
||||
app_type: MyString
|
||||
client_id: MyString
|
||||
client_secret: MyString
|
||||
redirect_uris: MyText
|
||||
metadata: MyText
|
||||
active: false
|
||||
<% require 'bcrypt' %>
|
||||
|
||||
two:
|
||||
name: MyString
|
||||
slug: MyString
|
||||
app_type: MyString
|
||||
client_id: MyString
|
||||
client_secret: MyString
|
||||
redirect_uris: MyText
|
||||
metadata: MyText
|
||||
active: false
|
||||
kavita_app:
|
||||
name: Kavita Reader
|
||||
slug: kavita-reader
|
||||
app_type: oidc
|
||||
client_id: <%= SecureRandom.urlsafe_base64(32) %>
|
||||
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
|
||||
redirect_uris: |
|
||||
https://kavita.example.com/signin-oidc
|
||||
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
|
||||
|
||||
11
test/fixtures/forward_auth_rules.yml
vendored
@@ -1,11 +0,0 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
domain_pattern: MyString
|
||||
policy: 1
|
||||
active: false
|
||||
|
||||
two:
|
||||
domain_pattern: MyString
|
||||
policy: 1
|
||||
active: false
|
||||
12
test/fixtures/groups.yml
vendored
@@ -1,9 +1,9 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
name: MyString
|
||||
description: MyText
|
||||
admin_group:
|
||||
name: Administrators
|
||||
description: System administrators with full access
|
||||
|
||||
two:
|
||||
name: MyString
|
||||
description: MyText
|
||||
editor_group:
|
||||
name: Editors
|
||||
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
|
||||
|
||||
one:
|
||||
token: MyString
|
||||
application: one
|
||||
user: one
|
||||
scope: MyString
|
||||
expires_at: 2025-10-23 16:40:39
|
||||
token: <%= SecureRandom.urlsafe_base64(32) %>
|
||||
application: kavita_app
|
||||
user: alice
|
||||
scope: "openid profile email"
|
||||
expires_at: 2025-12-31 23:59:59
|
||||
|
||||
two:
|
||||
token: MyString
|
||||
application: two
|
||||
user: two
|
||||
scope: MyString
|
||||
expires_at: 2025-10-23 16:40:39
|
||||
token: <%= SecureRandom.urlsafe_base64(32) %>
|
||||
application: another_app
|
||||
user: bob
|
||||
scope: "openid profile email"
|
||||
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
|
||||
|
||||
one:
|
||||
code: MyString
|
||||
application: one
|
||||
user: one
|
||||
redirect_uri: MyString
|
||||
scope: MyString
|
||||
expires_at: 2025-10-23 16:40:38
|
||||
code: <%= SecureRandom.urlsafe_base64(32) %>
|
||||
application: kavita_app
|
||||
user: alice
|
||||
redirect_uri: "https://kavita.example.com/signin-oidc"
|
||||
scope: "openid profile email"
|
||||
expires_at: 2025-12-31 23:59:59
|
||||
used: false
|
||||
|
||||
two:
|
||||
code: MyString
|
||||
application: two
|
||||
user: two
|
||||
redirect_uri: MyString
|
||||
scope: MyString
|
||||
expires_at: 2025-10-23 16:40:38
|
||||
code: <%= SecureRandom.urlsafe_base64(32) %>
|
||||
application: another_app
|
||||
user: bob
|
||||
redirect_uri: "https://app.example.com/auth/callback"
|
||||
scope: "openid profile email"
|
||||
expires_at: 2025-12-31 23:59:59
|
||||
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
|
||||
|
||||
one:
|
||||
user: one
|
||||
group: one
|
||||
alice_admin_group:
|
||||
user: alice
|
||||
group: admin_group
|
||||
|
||||
two:
|
||||
user: two
|
||||
group: two
|
||||
bob_editor_group:
|
||||
user: bob
|
||||
group: editor_group
|
||||
|
||||
12
test/fixtures/users.yml
vendored
@@ -1,9 +1,13 @@
|
||||
<% password_digest = BCrypt::Password.create("password") %>
|
||||
|
||||
one:
|
||||
email_address: one@example.com
|
||||
alice:
|
||||
email_address: alice@example.com
|
||||
password_digest: <%= password_digest %>
|
||||
admin: true
|
||||
status: 0 # active
|
||||
|
||||
two:
|
||||
email_address: two@example.com
|
||||
bob:
|
||||
email_address: bob@example.com
|
||||
password_digest: <%= password_digest %>
|
||||
admin: false
|
||||
status: 0 # active
|
||||
|
||||