3 Commits

Author SHA1 Message Date
dependabot[bot]
02e46a7168 Bump actions/upload-artifact from 4 to 5
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 20:07:23 +11:00
Dan Milne
a2a954b4c3 More tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-10-26 23:56:02 +11:00
Dan Milne
0ce38e3202 Bug fix
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-10-26 23:20:44 +11:00
120 changed files with 3528 additions and 4789 deletions

View File

@@ -16,43 +16,9 @@ SMTP_AUTHENTICATION=plain
SMTP_ENABLE_STARTTLS=true SMTP_ENABLE_STARTTLS=true
# Application Configuration # Application Configuration
CLINCH_HOST=http://localhost:3000 CLINCH_HOST=http://localhost:9000
CLINCH_FROM_EMAIL=noreply@example.com CLINCH_FROM_EMAIL=noreply@example.com
# WebAuthn / Passkey Configuration
# Required for passkeys to work in production (HTTPS required)
#
# CLINCH_RP_ID is the Relying Party Identifier - the domain that owns the passkeys
# - If your site is auth.example.com, use either "auth.example.com" or "example.com"
# - Using parent domain (e.g., "example.com") allows passkeys to work across all subdomains
# - Using subdomain (e.g., "auth.example.com") restricts passkeys to that specific subdomain
#
# CLINCH_RP_NAME is shown to users when creating/using passkeys
#
# Examples:
# For https://auth.example.com:
# CLINCH_HOST=https://auth.example.com
# CLINCH_RP_ID=example.com
# CLINCH_RP_NAME="Example Company"
#
# For https://sso.mycompany.com:
# CLINCH_HOST=https://sso.mycompany.com
# CLINCH_RP_ID=mycompany.com
# CLINCH_RP_NAME="My Company Identity"
#
CLINCH_RP_ID=localhost
CLINCH_RP_NAME="Clinch Identity Provider"
# 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 # OIDC Configuration
# RSA private key for signing ID tokens (JWT) # RSA private key for signing ID tokens (JWT)
# Generate with: openssl genrsa 2048 # Generate with: openssl genrsa 2048
@@ -68,27 +34,3 @@ CLINCH_ALLOW_LOCALHOST=true
# Optional: Set custom port # Optional: Set custom port
# PORT=9000 # PORT=9000
# Sentry Configuration (Optional)
# Enable error tracking and performance monitoring
# Leave SENTRY_DSN empty to disable Sentry completely
#
# Production: Get your DSN from https://sentry.io/settings/projects/
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
#
# Optional: Override Sentry environment (defaults to Rails.env)
# SENTRY_ENVIRONMENT=production
#
# Optional: Override Sentry release (defaults to Git commit hash)
# SENTRY_RELEASE=v1.0.0
#
# Optional: Performance monitoring sample rate (0.0 to 1.0, default 0.2)
# Higher values provide more data but cost more
# SENTRY_TRACES_SAMPLE_RATE=0.2
#
# Optional: Continuous profiling sample rate (0.0 to 1.0, default 0.0)
# Very resource intensive, only enable for performance investigations
# SENTRY_PROFILES_SAMPLE_RATE=0.0
#
# Development: Enable Sentry in development for testing
# SENTRY_ENABLED_IN_DEVELOPMENT=true

View File

@@ -32,7 +32,7 @@ FROM base AS build
# Install packages needed to build gems # Install packages needed to build gems
RUN apt-get update -qq && \ RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config libssl-dev && \ apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems # Install application gems

12
Gemfile
View File

@@ -1,7 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.1" gem "rails", "~> 8.1.0"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft] # The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft" gem "propshaft"
# Use sqlite3 as the database for Active Record # Use sqlite3 as the database for Active Record
@@ -31,16 +31,6 @@ gem "rqrcode", "~> 3.1"
# JWT for OIDC ID tokens # JWT for OIDC ID tokens
gem "jwt", "~> 3.1" gem "jwt", "~> 3.1"
# WebAuthn for passkey support
gem "webauthn", "~> 3.0"
# Public Suffix List for domain parsing
gem "public_suffix", "~> 6.0"
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
gem "sentry-ruby", "~> 5.18"
gem "sentry-rails", "~> 5.18"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem # Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ] gem "tzinfo-data", platforms: %i[ windows jruby ]

View File

@@ -3,29 +3,29 @@ GEM
specs: specs:
action_text-trix (2.1.15) action_text-trix (2.1.15)
railties railties
actioncable (8.1.1) actioncable (8.1.0)
actionpack (= 8.1.1) actionpack (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.1) actionmailbox (8.1.0)
actionpack (= 8.1.1) actionpack (= 8.1.0)
activejob (= 8.1.1) activejob (= 8.1.0)
activerecord (= 8.1.1) activerecord (= 8.1.0)
activestorage (= 8.1.1) activestorage (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.1) actionmailer (8.1.0)
actionpack (= 8.1.1) actionpack (= 8.1.0)
actionview (= 8.1.1) actionview (= 8.1.0)
activejob (= 8.1.1) activejob (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.1) actionpack (8.1.0)
actionview (= 8.1.1) actionview (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.1) actiontext (8.1.0)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.1) actionpack (= 8.1.0)
activerecord (= 8.1.1) activerecord (= 8.1.0)
activestorage (= 8.1.1) activestorage (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.1) actionview (8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.1) activejob (8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.1) activemodel (8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
activerecord (8.1.1) activerecord (8.1.0)
activemodel (= 8.1.1) activemodel (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.1) activestorage (8.1.0)
actionpack (= 8.1.1) actionpack (= 8.1.0)
activejob (= 8.1.1) activejob (= 8.1.0)
activerecord (= 8.1.1) activerecord (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.1) activesupport (8.1.0)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -77,13 +77,11 @@ GEM
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
android_key_attestation (0.3.0)
ast (2.4.3) ast (2.4.3)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
bigdecimal (3.3.1) bigdecimal (3.3.1)
bindata (2.5.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.6) bootsnap (1.18.6)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -102,24 +100,20 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
cbor (0.5.10.1)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.5) concurrent-ruby (1.3.5)
connection_pool (2.5.4) connection_pool (2.5.4)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
crass (1.0.6) crass (1.0.6)
date (3.5.0) date (3.4.1)
debug (1.11.0) debug (1.11.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
dotenv (3.1.8) dotenv (3.1.8)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (5.1.3) erb (5.1.1)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-aarch64-linux-musl)
@@ -140,14 +134,14 @@ GEM
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.1) io-console (0.8.1)
irb (1.15.3) irb (1.15.2)
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.14.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.15.2) json (2.15.1)
jwt (3.1.2) jwt (3.1.2)
base64 base64
kamal (2.8.1) kamal (2.8.1)
@@ -200,7 +194,7 @@ GEM
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.0)
nio4r (2.7.5) nio4r (2.7.4)
nokogiri (1.18.10-aarch64-linux-gnu) nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl) nokogiri (1.18.10-aarch64-linux-musl)
@@ -215,9 +209,6 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl) nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
openssl (3.3.2)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
ostruct (0.6.3) ostruct (0.6.3)
parallel (1.27.0) parallel (1.27.0)
parser (3.3.9.0) parser (3.3.9.0)
@@ -238,7 +229,7 @@ GEM
puma (7.1.0) puma (7.1.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.4) rack (3.2.3)
rack-session (2.1.1) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
@@ -246,20 +237,20 @@ GEM
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.2.1)
rack (>= 3) rack (>= 3)
rails (8.1.1) rails (8.1.0)
actioncable (= 8.1.1) actioncable (= 8.1.0)
actionmailbox (= 8.1.1) actionmailbox (= 8.1.0)
actionmailer (= 8.1.1) actionmailer (= 8.1.0)
actionpack (= 8.1.1) actionpack (= 8.1.0)
actiontext (= 8.1.1) actiontext (= 8.1.0)
actionview (= 8.1.1) actionview (= 8.1.0)
activejob (= 8.1.1) activejob (= 8.1.0)
activemodel (= 8.1.1) activemodel (= 8.1.0)
activerecord (= 8.1.1) activerecord (= 8.1.0)
activestorage (= 8.1.1) activestorage (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.1) railties (= 8.1.0)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -267,9 +258,9 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.1) railties (8.1.0)
actionpack (= 8.1.1) actionpack (= 8.1.0)
activesupport (= 8.1.1) activesupport (= 8.1.0)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -277,8 +268,8 @@ GEM
tsort (>= 0.2) tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.3.0)
rdoc (6.15.1) rdoc (6.15.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
@@ -324,8 +315,6 @@ GEM
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.1) rubyzip (3.2.1)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.38.0) selenium-webdriver (4.38.0)
base64 (~> 0.2) base64 (~> 0.2)
@@ -333,12 +322,6 @@ GEM
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sentry-rails (5.28.0)
railties (>= 5.0)
sentry-ruby (~> 5.28.0)
sentry-ruby (5.28.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
solid_cable (3.0.12) solid_cable (3.0.12)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
@@ -379,11 +362,7 @@ GEM
thruster (0.1.16-aarch64-linux) thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin) thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-linux) thruster (0.1.16-x86_64-linux)
timeout (0.4.4) timeout (0.4.3)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.17) turbo-rails (2.0.17)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
@@ -393,21 +372,13 @@ GEM
unicode-display_width (3.2.0) unicode-display_width (3.2.0)
unicode-emoji (~> 4.1) unicode-emoji (~> 4.1)
unicode-emoji (4.1.0) unicode-emoji (4.1.0)
uri (1.1.0) uri (1.0.4)
useragent (0.16.11) useragent (0.16.11)
web-console (4.2.1) web-console (4.2.1)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webauthn (3.4.3)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
openssl (>= 2.2)
safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.8.0)
base64 base64
@@ -442,15 +413,12 @@ DEPENDENCIES
kamal kamal
letter_opener letter_opener
propshaft propshaft
public_suffix (~> 6.0)
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.1.1) rails (~> 8.1.0)
rotp (~> 6.3) rotp (~> 6.3)
rqrcode (~> 3.1) rqrcode (~> 3.1)
rubocop-rails-omakase rubocop-rails-omakase
selenium-webdriver selenium-webdriver
sentry-rails (~> 5.18)
sentry-ruby (~> 5.18)
solid_cable solid_cable
solid_cache solid_cache
sqlite3 (>= 2.1) sqlite3 (>= 2.1)
@@ -460,7 +428,6 @@ DEPENDENCIES
turbo-rails turbo-rails
tzinfo-data tzinfo-data
web-console web-console
webauthn (~> 3.0)
BUNDLED WITH BUNDLED WITH
2.7.2 2.7.2

View File

@@ -1,27 +1,10 @@
# Clinch # Clinch
> [!NOTE] This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
> 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 / IpD portal** **A lightweight, self-hosted identity & SSO portal**
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table. Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
I've completed all planned features:
* Create Admin user on first login
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
* Passkey generation and login, with detection of Passkey during login
* Forward Auth configured and working
* OIDC provider with auto discovery working
* Invite users by email, assign to groups
* Self managed password reset by email
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
* Configurable Group and User custom claims for OIDC token
* Display all Applications available to the user on their Dashboard
* Display all logged in sessions and OIDC logged in sessions
What remains now is ensure test coverage,
## Why Clinch? ## Why Clinch?
@@ -37,35 +20,6 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
--- ---
## Screenshots
### User Dashboard
[![User Dashboard](docs/screenshots/thumbs/0-dashboard.png)](docs/screenshots/0-dashboard.png)
### Sign In
[![Sign In](docs/screenshots/thumbs/1-signin.png)](docs/screenshots/1-signin.png)
### Sign In with 2FA
[![Sign In with 2FA](docs/screenshots/thumbs/2-signin.png)](docs/screenshots/2-signin.png)
### Users Management
[![Users Management](docs/screenshots/thumbs/3-users.png)](docs/screenshots/3-users.png)
### Welcome Screen
[![Welcome Screen](docs/screenshots/thumbs/4-welcome.png)](docs/screenshots/4-welcome.png)
### Welcome Setup
[![Welcome Setup](docs/screenshots/thumbs/5-welcome-2.png)](docs/screenshots/5-welcome-2.png)
### Setup 2FA
[![Setup 2FA](docs/screenshots/thumbs/6-setup-2fa.png)](docs/screenshots/6-setup-2fa.png)
### Forward Auth Example 1
[![Forward Auth Example 1](docs/screenshots/thumbs/7-forward-auth-1.png)](docs/screenshots/7-forward-auth-1.png)
### Forward Auth Example 2
[![Forward Auth Example 2](docs/screenshots/thumbs/8-forward-auth-2.png)](docs/screenshots/8-forward-auth-2.png)
## Features ## Features
### User Management ### User Management
@@ -117,7 +71,6 @@ Send emails for:
- **Group-based allowlists** - Restrict applications to specific user groups - **Group-based allowlists** - Restrict applications to specific user groups
- **Per-application access** - Each app defines which groups can access it - **Per-application access** - Each app defines which groups can access it
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth - **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)
--- ---
@@ -132,13 +85,11 @@ Send emails for:
- TOTP secret and backup codes (encrypted) - TOTP secret and backup codes (encrypted)
- TOTP enforcement flag - TOTP enforcement flag
- Status (active, disabled, pending_invitation) - 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 - Token generation for invitations, password resets, and magic logins
**Group** **Group**
- Name (unique, normalized to lowercase) - Name (unique, normalized to lowercase)
- Description - Description
- Custom claims (JSON) - shared claims for all members (merged with user claims)
- Many-to-many with Users and Applications - Many-to-many with Users and Applications
**Session** **Session**
@@ -151,11 +102,9 @@ Send emails for:
**Application** **Application**
- Name and slug (URL-safe identifier) - Name and slug (URL-safe identifier)
- Type (oidc or forward_auth) - Type (oidc, trusted_header, saml)
- Client ID and secret (for OIDC apps) - Client ID and secret (for OIDC)
- Redirect URIs (for OIDC apps) - Redirect URIs (JSON array)
- 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) - Metadata (flexible JSON storage)
- Active flag - Active flag
- Many-to-many with Groups (allowlist) - Many-to-many with Groups (allowlist)

View File

@@ -1,35 +0,0 @@
class ActiveSessionsController < 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 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 active_sessions_path, alert: "No consent found for this application."
return
end
# Revoke the consent
consent.destroy
redirect_to active_sessions_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 active_sessions_path, notice: "Successfully revoked access to #{count} applications."
else
redirect_to active_sessions_path, alert: "No applications to revoke."
end
end
end

View File

@@ -1,6 +1,6 @@
module Admin module Admin
class ApplicationsController < BaseController class ApplicationsController < BaseController
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials] before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials, :roles, :create_role, :update_role, :assign_role, :remove_role]
def index def index
@applications = Application.order(created_at: :desc) @applications = Application.order(created_at: :desc)
@@ -90,6 +90,53 @@ module Admin
end end
end end
def roles
@application_roles = @application.application_roles.includes(:user_role_assignments)
@available_users = User.active.order(:email_address)
end
def create_role
@role = @application.application_roles.build(role_params)
if @role.save
redirect_to roles_admin_application_path(@application), notice: "Role created successfully."
else
@application_roles = @application.application_roles.includes(:user_role_assignments)
@available_users = User.active.order(:email_address)
render :roles, status: :unprocessable_entity
end
end
def update_role
@role = @application.application_roles.find(params[:role_id])
if @role.update(role_params)
redirect_to roles_admin_application_path(@application), notice: "Role updated successfully."
else
@application_roles = @application.application_roles.includes(:user_role_assignments)
@available_users = User.active.order(:email_address)
render :roles, status: :unprocessable_entity
end
end
def assign_role
user = User.find(params[:user_id])
role = @application.application_roles.find(params[:role_id])
@application.assign_role_to_user!(user, role.name, source: 'manual')
redirect_to roles_admin_application_path(@application), notice: "Role assigned successfully."
end
def remove_role
user = User.find(params[:user_id])
role = @application.application_roles.find(params[:role_id])
@application.remove_role_from_user!(user, role.name)
redirect_to roles_admin_application_path(@application), notice: "Role removed successfully."
end
private private
def set_application def set_application
@@ -99,11 +146,12 @@ module Admin
def application_params def application_params
params.require(:application).permit( params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, headers_config: {} :role_mapping_mode, :role_prefix, :role_claim_name, managed_permissions: {}
).tap do |whitelisted| )
# Remove client_secret from params if present (shouldn't be updated via form) end
whitelisted.delete(:client_secret)
end def role_params
params.require(:application_role).permit(:name, :display_name, :description, :active, permissions: {})
end end
end end
end end

View File

@@ -0,0 +1,84 @@
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)
# Handle headers configuration
@forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
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 headers configuration
@forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
@forward_auth_rule.save!
# Handle group assignments
if params[:forward_auth_rule][:group_ids].present?
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
@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
def process_headers_config(headers_params)
return {} unless headers_params.is_a?(Hash)
# Clean up headers config - remove empty values, keep only filled ones
headers_params.select { |key, value| value.present? }.symbolize_keys
end
end
end

View File

@@ -67,7 +67,7 @@ module Admin
end end
def group_params def group_params
params.require(:group).permit(:name, :description, custom_claims: {}) params.require(:group).permit(:name, :description)
end end
end end
end end

View File

@@ -76,7 +76,7 @@ module Admin
end end
def user_params def user_params
params.require(:user).permit(:email_address, :name, :password, :admin, :status, custom_claims: {}) params.require(:user).permit(:email_address, :password, :admin, :status)
end end
end end
end end

View File

@@ -1,50 +0,0 @@
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)
csp_report = report_data['csp-report']
# Log the violation for security monitoring
Rails.logger.warn "CSP Violation Report:"
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
Rails.logger.warn " User Agent: #{request.user_agent}"
Rails.logger.warn " IP Address: #{request.remote_ip}"
# Emit structured event for CSP violation
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
Rails.event.notify("csp.violation", {
blocked_uri: csp_report['blocked-uri'],
document_uri: csp_report['document-uri'],
referrer: csp_report['referrer'],
violated_directive: csp_report['violated-directive'],
original_policy: csp_report['original-policy'],
disposition: csp_report['disposition'],
effective_directive: csp_report['effective-directive'],
source_file: csp_report['source-file'],
line_number: csp_report['line-number'],
column_number: csp_report['column-number'],
status_code: csp_report['status-code'],
user_agent: request.user_agent,
ip_address: request.remote_ip,
current_user_id: Current.user&.id,
timestamp: Time.current,
session_id: Current.session&.id
})
head :no_content
rescue JSON::ParserError => e
Rails.logger.error "Invalid CSP violation report: #{e.message}"
head :bad_request
end
end
end

View File

@@ -3,27 +3,22 @@ module Api
# ForwardAuth endpoints need session storage for return URL # ForwardAuth endpoints need session storage for return URL
allow_unauthenticated_access allow_unauthenticated_access
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests }
# GET /api/verify # GET /api/verify
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx) # This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
# to verify if a user is authenticated and authorized to access a domain # to verify if a user is authenticated and authorized to access a domain
def verify def verify
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type) # Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
# 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 unless session_id
# No session cookie or token - user is not authenticated # No session cookie - user is not authenticated
return render_unauthorized("No session cookie") return render_unauthorized("No session cookie")
end end
# Find the session with user association (eager loading for performance) # Find the session
session = Session.includes(:user).find_by(id: session_id) session = Session.find_by(id: session_id)
unless session unless session
# Invalid session # Invalid session
return render_unauthorized("Invalid session") return render_unauthorized("Invalid session")
@@ -35,46 +30,42 @@ module Api
return render_unauthorized("Session expired") return render_unauthorized("Session expired")
end end
# Update last activity (skip validations for performance) # Update last activity
session.update_column(:last_activity_at, Time.current) session.update_column(:last_activity_at, Time.current)
# Get the user (already loaded via includes(:user)) # Get the user
user = session.user user = session.user
unless user.active? unless user.active?
return render_unauthorized("User account is not active") return render_unauthorized("User account is not active")
end end
# Check for forward auth application authorization # Check for forward auth rule authorization
# Get the forwarded host for domain matching # Get the forwarded host for domain matching
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"] forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
if forwarded_host.present? if forwarded_host.present?
# Load active forward auth applications with their associations for better performance # Find matching forward auth rule for this domain
# Preload groups to avoid N+1 queries in user_allowed? checks rule = ForwardAuthRule.active.find { |r| r.matches_domain?(forwarded_host) }
apps = Application.forward_auth.includes(:allowed_groups).active
# Find matching forward auth application for this domain unless rule
app = apps.find { |a| a.matches_domain?(forwarded_host) } Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
return render_forbidden("No authentication rule configured for this domain")
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 end
# Check if user is allowed by this rule
unless rule.user_allowed?(user)
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by rule #{rule.domain_pattern}"
return render_forbidden("You do not have permission to access this domain")
end
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by rule #{rule.domain_pattern} (policy: #{rule.policy_for_user(user)})"
else else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)" Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
end end
# User is authenticated and authorized # User is authenticated and authorized
# Return 200 with user information headers using app-specific configuration # Return 200 with user information headers using rule-specific configuration
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name| headers = rule ? rule.headers_for_user(user) : ForwardAuthRule::DEFAULT_HEADERS.map { |key, header_name|
case key case key
when :user, :email, :name when :user, :email, :name
[header_name, user.email_address] [header_name, user.email_address]
@@ -100,34 +91,14 @@ module Api
private private
def check_forward_auth_token
# Check for one-time token in query parameters (for race condition handling)
token = params[:fa_token]
return nil unless token.present?
# Try to get session ID from cache
session_id = Rails.cache.read("forward_auth_token:#{token}")
return nil unless session_id
# Verify the session exists and is valid
session = Session.find_by(id: session_id)
return nil unless session && !session.expired?
# Delete the token immediately (one-time use)
Rails.cache.delete("forward_auth_token:#{token}")
session_id
end
def extract_session_id def extract_session_id
# Extract session ID from cookie # Extract session ID from cookie
# Rails uses signed cookies by default # Rails uses signed cookies by default
session_id = cookies.signed[:session_id] cookies.signed[:session_id]
session_id
end end
def extract_app_from_headers def extract_app_from_headers
# This method is deprecated since we now use Application (forward_auth type) domain matching # This method is deprecated since we now use ForwardAuthRule domain matching
# Keeping it for backward compatibility but it's no longer used # Keeping it for backward compatibility but it's no longer used
nil nil
end end
@@ -135,9 +106,11 @@ module Api
def render_unauthorized(reason = nil) def render_unauthorized(reason = nil)
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}" 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 # Get the redirect URL from query params or construct default
redirect_url = validate_redirect_url(params[:rd]) base_url = params[:rd] || "https://clinch.aapamilne.com"
base_url = determine_base_url(redirect_url)
# Set the original URL that user was trying to access # Set the original URL that user was trying to access
# This will be used after authentication # This will be used after authentication
@@ -148,11 +121,11 @@ module Api
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}" Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
original_url = if original_host original_url = if original_host
# Use the forwarded host and URI (original behavior) # Use the forwarded host and URI
"https://#{original_host}#{original_uri}" "https://#{original_host}#{original_uri}"
else else
# Fallback: use the validated redirect URL or default # Fallback: just redirect to the root of the original host
redirect_url || "https://clinch.aapamilne.com" "https://#{request.headers['Host']}"
end end
# Debug: log what we're redirecting to after login # Debug: log what we're redirecting to after login
@@ -176,63 +149,11 @@ module Api
def render_forbidden(reason = nil) def render_forbidden(reason = nil)
Rails.logger.info "ForwardAuth: Forbidden - #{reason}" Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
# Set header to help with debugging
response.headers["X-Auth-Reason"] = reason if reason
# Return 403 Forbidden # Return 403 Forbidden
head :forbidden head :forbidden
end end
def validate_redirect_url(url)
return nil unless url.present?
begin
uri = URI.parse(url)
# Only allow HTTP/HTTPS schemes
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https'
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
# Check against our 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
def determine_base_url(redirect_url)
# If we have a valid redirect URL, use it
return redirect_url if redirect_url.present?
# Try CLINCH_HOST environment variable first
if ENV['CLINCH_HOST'].present?
"https://#{ENV['CLINCH_HOST']}"
else
# Fallback to the request host
request_host = request.host || request.headers['X-Forwarded-Host']
if request_host.present?
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
"https://#{request_host}"
else
# No host information available - raise exception to force proper configuration
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available. Please configure CLINCH_HOST properly."
end
end
end
end end
end end

View File

@@ -1,7 +1,3 @@
require 'uri'
require 'public_suffix'
require 'ipaddr'
module Authentication module Authentication
extend ActiveSupport::Concern extend ActiveSupport::Concern
@@ -39,9 +35,7 @@ module Authentication
end end
def after_authentication_url def after_authentication_url
return_url = session[:return_to_after_authenticating] session.delete(:return_to_after_authenticating) || root_url
final_url = session.delete(:return_to_after_authenticating) || root_url
final_url
end end
def start_new_session_for(user) def start_new_session_for(user)
@@ -63,10 +57,6 @@ module Authentication
cookie_options[:domain] = domain if domain.present? cookie_options[:domain] = domain if domain.present?
cookies.signed.permanent[:session_id] = cookie_options cookies.signed.permanent[:session_id] = cookie_options
# Create a one-time token for immediate forward auth after authentication
# This solves the race condition where browser hasn't processed cookie yet
create_forward_auth_token(session)
end end
end end
@@ -75,72 +65,36 @@ module Authentication
cookies.delete(:session_id) cookies.delete(:session_id)
end end
# Extract root domain for cross-subdomain cookies in SSO forward_auth system. # Extract root domain for cross-subdomain cookies
#
# PURPOSE: Enables a single authentication session to work across multiple subdomains
# by setting cookies with the domain parameter (e.g., .example.com allows access from
# both app.example.com and api.example.com).
#
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
# When accessing services by IP, there are no subdomains to share cookies with,
# and setting a domain cookie would break authentication.
#
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
#
# Examples: # Examples:
# - app.example.com -> .example.com (enables cross-subdomain SSO) # - clinch.aapamilne.com -> .aapamilne.com
# - api.example.co.uk -> .example.co.uk (handles complex TLDs) # - app.example.co.uk -> .example.co.uk
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains) # - localhost -> nil (no domain setting for local development)
# - localhost -> nil (local development, no domain cookie)
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
#
# @param host [String] The request host (may include port)
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
def extract_root_domain(host) def extract_root_domain(host)
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/) return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
# Strip port number for domain parsing # Split hostname into parts
host_without_port = host.split(':').first parts = host.split('.')
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie # For normal domains like example.com, we need at least 2 parts
return nil if IPAddr.new(host_without_port) rescue false # For complex domains like co.uk, we need at least 3 parts
return nil if parts.length < 2
# Use Public Suffix List for accurate domain parsing # Extract root domain with leading dot for cross-subdomain cookies
domain = PublicSuffix.parse(host_without_port) if parts.length >= 3
".#{domain.domain}" # Check if it's a known complex TLD
rescue PublicSuffix::DomainInvalid complex_tlds = %w[co.uk com.au co.nz co.za co.jp]
# Fallback for invalid domains or IPs second_level = "#{parts[-2]}.#{parts[-1]}"
nil
end
# Create a one-time token for forward auth to handle the race condition if complex_tlds.include?(second_level)
# where the browser hasn't processed the session cookie yet # For complex TLDs, include more parts: app.example.co.uk -> .example.co.uk
def create_forward_auth_token(session_obj) root_parts = parts[-3..-1]
# Generate a secure random token return ".#{root_parts.join('.')}"
token = SecureRandom.urlsafe_base64(32) end
# Store it with an expiry of 30 seconds
Rails.cache.write(
"forward_auth_token:#{token}",
session_obj.id,
expires_in: 30.seconds
)
# Set the token as a query parameter on the redirect URL
# We need to store this in the controller's session
controller_session = session
if controller_session[:return_to_after_authenticating].present?
original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url)
# Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token
uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL
controller_session[:return_to_after_authenticating] = uri.to_s
end end
# For regular domains: app.example.com -> .example.com
root_parts = parts[-2..-1]
".#{root_parts.join('.')}"
end end
end end

View File

@@ -8,10 +8,5 @@ class DashboardController < ApplicationController
# User must be authenticated # User must be authenticated
@user = Current.session.user @user = Current.session.user
# Load user's accessible applications
@applications = Application.active.select do |app|
app.user_allowed?(@user)
end
end end
end end

View File

@@ -291,7 +291,7 @@ class OidcController < ApplicationController
email: user.email_address, email: user.email_address,
email_verified: true, email_verified: true,
preferred_username: user.email_address, preferred_username: user.email_address,
name: user.name.presence || user.email_address name: user.email_address
} }
# Add groups if user has any # Add groups if user has any
@@ -302,14 +302,6 @@ class OidcController < ApplicationController
# Add admin claim if user is admin # Add admin claim if user is admin
claims[:admin] = true if user.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 render json: claims
end end

View File

@@ -1,6 +1,8 @@
class ProfilesController < ApplicationController class ProfilesController < ApplicationController
def show def show
@user = Current.session.user @user = Current.session.user
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
@connected_applications = @user.oidc_user_consents.includes(:application).order(granted_at: :desc)
end end
def update def update
@@ -10,6 +12,7 @@ class ProfilesController < ApplicationController
# Updating password - requires current password # Updating password - requires current password
unless @user.authenticate(params[:user][:current_password]) unless @user.authenticate(params[:user][:current_password])
@user.errors.add(:current_password, "is incorrect") @user.errors.add(:current_password, "is incorrect")
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
return return
end end
@@ -17,6 +20,7 @@ class ProfilesController < ApplicationController
if @user.update(password_params) if @user.update(password_params)
redirect_to profile_path, notice: "Password updated successfully." redirect_to profile_path, notice: "Password updated successfully."
else else
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
end end
else else
@@ -24,11 +28,40 @@ class ProfilesController < ApplicationController
if @user.update(email_params) if @user.update(email_params)
redirect_to profile_path, notice: "Email updated successfully." redirect_to profile_path, notice: "Email updated successfully."
else else
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
end end
end end
end end
def revoke_consent
@user = Current.session.user
application = Application.find(params[:application_id])
# Check if user has consent for this application
consent = @user.oidc_user_consents.find_by(application: application)
unless consent
redirect_to profile_path, alert: "No consent found for this application."
return
end
# Revoke the consent
consent.destroy
redirect_to profile_path, notice: "Successfully revoked access to #{application.name}."
end
def revoke_all_consents
@user = Current.session.user
count = @user.oidc_user_consents.count
if count > 0
@user.oidc_user_consents.destroy_all
redirect_to profile_path, notice: "Successfully revoked access to #{count} applications."
else
redirect_to profile_path, alert: "No applications to revoke."
end
end
private private
def email_params def email_params

View File

@@ -1,8 +1,7 @@
class SessionsController < ApplicationController class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ] allow_unauthenticated_access only: %i[ new create verify_totp ]
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." } rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." } rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests }
def new def new
# Redirect to signup if this is first run # Redirect to signup if this is first run
@@ -17,10 +16,9 @@ class SessionsController < ApplicationController
return return
end end
# Store the redirect URL from forward auth if present (after validation) # Store the redirect URL from forward auth if present
if params[:rd].present? if params[:rd].present?
validated_url = validate_redirect_url(params[:rd]) session[:return_to_after_authenticating] = params[:rd]
session[:return_to_after_authenticating] = validated_url if validated_url
end end
# Check if user is active # Check if user is active
@@ -37,10 +35,9 @@ class SessionsController < ApplicationController
if user.totp_enabled? if user.totp_enabled?
# Store user ID in session temporarily for TOTP verification # Store user ID in session temporarily for TOTP verification
session[:pending_totp_user_id] = user.id session[:pending_totp_user_id] = user.id
# Preserve the redirect URL through TOTP verification (after validation) # Preserve the redirect URL through TOTP verification
if params[:rd].present? if params[:rd].present?
validated_url = validate_redirect_url(params[:rd]) session[:totp_redirect_url] = params[:rd]
session[:totp_redirect_url] = validated_url if validated_url
end end
redirect_to totp_verification_path(rd: params[:rd]) redirect_to totp_verification_path(rd: params[:rd])
return return
@@ -70,12 +67,6 @@ class SessionsController < ApplicationController
if request.post? if request.post?
code = params[:code]&.strip code = params[:code]&.strip
# Check if user is already authenticated (prevent duplicate submissions)
if authenticated?
redirect_to root_path, notice: "Already signed in."
return
end
# Try TOTP verification first # Try TOTP verification first
if user.verify_totp(code) if user.verify_totp(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
@@ -116,174 +107,6 @@ class SessionsController < ApplicationController
def destroy_other def destroy_other
session = Current.session.user.sessions.find(params[:id]) session = Current.session.user.sessions.find(params[:id])
session.destroy session.destroy
redirect_to active_sessions_path, notice: "Session revoked successfully." redirect_to profile_path, notice: "Session revoked successfully."
end
# WebAuthn authentication methods
def webauthn_challenge
email = params[:email]&.strip&.downcase
if email.blank?
render json: { error: "Email is required" }, status: :unprocessable_entity
return
end
user = User.find_by(email_address: email)
if user.nil? || !user.can_authenticate_with_webauthn?
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity
return
end
# Store user ID in session for verification
session[:pending_webauthn_user_id] = user.id
# Store redirect URL if present
if params[:rd].present?
validated_url = validate_redirect_url(params[:rd])
session[:webauthn_redirect_url] = validated_url if validated_url
end
begin
# Generate authentication options
# Decode the stored base64url credential IDs before passing to the gem
credential_ids = user.webauthn_credentials.pluck(:external_id).map do |encoded_id|
Base64.urlsafe_decode64(encoded_id)
end
options = WebAuthn::Credential.options_for_get(
allow: credential_ids,
user_verification: "preferred"
)
# Store challenge in session
session[:webauthn_challenge] = options.challenge
render json: options
rescue => e
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error
end
end
def webauthn_verify
# Get pending user from session
user_id = session[:pending_webauthn_user_id]
unless user_id
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
return
end
user = User.find_by(id: user_id)
unless user
session.delete(:pending_webauthn_user_id)
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
return
end
# Get the credential and assertion from params
credential_data = params[:credential]
if credential_data.blank?
render json: { error: "Credential data is required" }, status: :unprocessable_entity
return
end
# Get the challenge from session
challenge = session.delete(:webauthn_challenge)
if challenge.blank?
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
return
end
begin
# Decode the credential response
webauthn_credential = WebAuthn::Credential.from_get(credential_data)
# Find the stored credential
external_id = Base64.urlsafe_encode64(webauthn_credential.id)
stored_credential = user.webauthn_credential_for(external_id)
if stored_credential.nil?
render json: { error: "Credential not found" }, status: :unprocessable_entity
return
end
# Verify the assertion
stored_public_key = Base64.urlsafe_decode64(stored_credential.public_key)
webauthn_credential.verify(
challenge,
public_key: stored_public_key,
sign_count: stored_credential.sign_count
)
# Check for suspicious sign count (possible clone)
if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count)
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id}"
# You might want to notify admins or temporarily disable the credential
end
# Update credential usage
stored_credential.update_usage!(
sign_count: webauthn_credential.sign_count,
ip_address: request.remote_ip,
user_agent: request.user_agent
)
# Clean up session
session.delete(:pending_webauthn_user_id)
if session[:webauthn_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
end
# Create session
start_new_session_for user
render json: {
success: true,
redirect_to: after_authentication_url,
message: "Signed in successfully with passkey"
}
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn verification error: #{e.message}"
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity
rescue JSON::ParserError => e
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
render json: { error: "Invalid credential format" }, status: :unprocessable_entity
rescue => e
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
end
end
private
def validate_redirect_url(url)
return nil unless url.present?
begin
uri = URI.parse(url)
# Only allow HTTP/HTTPS schemes
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil unless Rails.env.development? || uri.scheme == 'https'
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
# Check against our ForwardAuthRules
matching_rule = ForwardAuthRule.active.find do |rule|
rule.matches_domain?(redirect_domain)
end
matching_rule ? url : nil
rescue URI::InvalidURIError
nil
end
end end
end end

View File

@@ -24,12 +24,9 @@ class TotpController < ApplicationController
if totp.verify(code, drift_behind: 30, drift_ahead: 30) if totp.verify(code, drift_behind: 30, drift_ahead: 30)
# Save the secret and generate backup codes # Save the secret and generate backup codes
@user.totp_secret = totp_secret @user.totp_secret = totp_secret
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model @user.backup_codes = generate_backup_codes
@user.save! @user.save!
# Store plain codes temporarily in session for display after redirect
session[:temp_backup_codes] = plain_codes
# Redirect to backup codes page with success message # Redirect to backup codes page with success message
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now." redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
else else
@@ -39,15 +36,8 @@ class TotpController < ApplicationController
# GET /totp/backup_codes - Show backup codes (requires password) # GET /totp/backup_codes - Show backup codes (requires password)
def backup_codes def backup_codes
# Check if we have temporary codes from TOTP setup # This will be shown after password verification
if session[:temp_backup_codes].present? @backup_codes = @user.parsed_backup_codes
@backup_codes = session[:temp_backup_codes]
session.delete(:temp_backup_codes) # Clear after use
else
# This will be shown after password verification for existing users
# Since we can't display BCrypt hashes, redirect to regenerate
redirect_to regenerate_backup_codes_totp_path
end
end end
# POST /totp/verify_password - Verify password before showing backup codes # POST /totp/verify_password - Verify password before showing backup codes
@@ -59,28 +49,6 @@ class TotpController < ApplicationController
end end
end end
# GET /totp/regenerate_backup_codes - Regenerate backup codes (requires password)
def regenerate_backup_codes
# This will be shown after password verification
end
# POST /totp/regenerate_backup_codes - Actually regenerate backup codes
def create_new_backup_codes
unless @user.authenticate(params[:password])
redirect_to regenerate_backup_codes_totp_path, alert: "Incorrect password."
return
end
# Generate new backup codes and store BCrypt hashes
plain_codes = @user.send(:generate_backup_codes)
@user.save!
# Store plain codes temporarily in session for display
session[:temp_backup_codes] = plain_codes
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
end
# DELETE /totp - Disable TOTP (requires password) # DELETE /totp - Disable TOTP (requires password)
def destroy def destroy
unless @user.authenticate(params[:password]) unless @user.authenticate(params[:password])
@@ -109,4 +77,8 @@ class TotpController < ApplicationController
redirect_to profile_path, alert: "Two-factor authentication is not enabled." redirect_to profile_path, alert: "Two-factor authentication is not enabled."
end end
end end
def generate_backup_codes
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
end
end end

View File

@@ -1,198 +0,0 @@
class WebauthnController < ApplicationController
before_action :set_webauthn_credential, only: [:destroy]
skip_before_action :require_authentication, only: [:check]
# GET /webauthn/new
def new
@webauthn_credential = WebauthnCredential.new
end
# POST /webauthn/challenge
# Generate registration challenge for creating a new passkey
def challenge
user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
registration_options = WebAuthn::Credential.options_for_create(
user: {
id: user.webauthn_user_handle,
name: user.email_address,
display_name: user.name || user.email_address
},
exclude: user.webauthn_credentials.pluck(:external_id),
authenticator_selection: {
userVerification: "preferred",
residentKey: "preferred",
authenticatorAttachment: "platform" # Prefer platform authenticators first
}
)
# Store challenge in session for verification
session[:webauthn_challenge] = registration_options.challenge
render json: registration_options
end
# POST /webauthn/create
# Verify and store the new credential
def create
credential_data, nickname = extract_credential_params
if credential_data.blank? || nickname.blank?
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity
return
end
# Retrieve the challenge from session
challenge = session.delete(:webauthn_challenge)
if challenge.blank?
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
return
end
begin
# Pass the credential hash directly to WebAuthn gem
webauthn_credential = WebAuthn::Credential.from_create(credential_data.to_h)
# Verify the credential against the challenge
webauthn_credential.verify(challenge)
# Extract credential metadata from the hash
response = credential_data.to_h
client_extension_results = response["clientExtensionResults"] || {}
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
"cross-platform"
else
"platform"
end
# Determine if this is a backup/synced credential
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
backup_state = client_extension_results["credProps"]&.dig("backup") || false
# Store the credential
user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
@webauthn_credential = user.webauthn_credentials.create!(
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
public_key: Base64.urlsafe_encode64(webauthn_credential.public_key),
sign_count: webauthn_credential.sign_count,
nickname: nickname,
authenticator_type: authenticator_type,
backup_eligible: backup_eligible,
backup_state: backup_state
)
render json: {
success: true,
message: "Passkey '#{nickname}' registered successfully",
credential_id: @webauthn_credential.id
}
rescue WebAuthn::Error => e
Rails.logger.error "WebAuthn registration error: #{e.message}"
render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity
rescue => e
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
end
end
# DELETE /webauthn/:id
# Remove a passkey
def destroy
user = Current.session&.user
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
if @webauthn_credential.user != user
render json: { error: "Unauthorized" }, status: :forbidden
return
end
nickname = @webauthn_credential.nickname
@webauthn_credential.destroy
respond_to do |format|
format.html {
redirect_to profile_path,
notice: "Passkey '#{nickname}' has been removed"
}
format.json {
render json: {
success: true,
message: "Passkey '#{nickname}' has been removed"
}
}
end
end
# GET /webauthn/check
# Check if user has WebAuthn credentials (for login page detection)
def check
email = params[:email]&.strip&.downcase
if email.blank?
render json: { has_webauthn: false, error: "Email is required" }
return
end
user = User.find_by(email_address: email)
if user.nil?
render json: { has_webauthn: false, message: "User not found" }
return
end
render json: {
has_webauthn: user.can_authenticate_with_webauthn?,
user_id: user.id,
preferred_method: user.preferred_authentication_method,
requires_webauthn: user.require_webauthn?
}
end
private
def extract_credential_params
# Use require.permit which is working and reliable
# The JavaScript sends params both directly and wrapped in webauthn key
begin
# Try direct parameters first
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
nickname = params.require(:nickname)
[credential_params, nickname]
rescue ActionController::ParameterMissing
Rails.logger.error("Using the fallback parameters")
# Fallback to webauthn-wrapped parameters
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
[webauthn_params[:credential], webauthn_params[:nickname]]
end
end
def set_webauthn_credential
@webauthn_credential = WebauthnCredential.find(params[:id])
rescue ActiveRecord::RecordNotFound
respond_to do |format|
format.html {
redirect_to profile_path,
alert: "Passkey not found"
}
format.json {
render json: { error: "Passkey not found" }, status: :not_found
}
end
end
# Helper method to convert Base64 to Base64URL if needed
def base64_to_base64url(str)
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
end
# Helper method to convert Base64URL to Base64 if needed
def base64url_to_base64(str)
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
end
end

View File

@@ -1,24 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"]
connect() {
this.updateFieldVisibility()
}
updateFieldVisibility() {
const appType = this.appTypeSelectTarget.value
if (appType === 'oidc') {
this.oidcFieldsTarget.classList.remove('hidden')
this.forwardAuthFieldsTarget.classList.add('hidden')
} else if (appType === 'forward_auth') {
this.oidcFieldsTarget.classList.add('hidden')
this.forwardAuthFieldsTarget.classList.remove('hidden')
} else {
this.oidcFieldsTarget.classList.add('hidden')
this.forwardAuthFieldsTarget.classList.add('hidden')
}
}
}

View File

@@ -1,28 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
codes: Array
}
download() {
const content = "Clinch Backup Codes\n" +
"===================\n\n" +
this.codesValue.join("\n") +
"\n\nSave these codes in a secure location."
const blob = new Blob([content], { type: 'text/plain' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'clinch-backup-codes.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}
print() {
window.print()
}
}

View File

@@ -1,85 +0,0 @@
import { Controller } from "@hotwired/stimulus"
/**
* Manages flash message display, auto-dismissal, and user interactions
* Supports different flash types with appropriate styling and behavior
*/
export default class extends Controller {
static values = {
autoDismiss: String, // "false" or delay in milliseconds
type: String
}
connect() {
// Auto-dismiss if enabled
if (this.autoDismissValue && this.autoDismissValue !== "false") {
this.scheduleAutoDismiss()
}
// Smooth entrance animation
this.element.classList.add('transition-all', 'duration-300', 'ease-out')
this.element.style.opacity = '0'
this.element.style.transform = 'translateY(-10px)'
// Animate in
requestAnimationFrame(() => {
this.element.style.opacity = '1'
this.element.style.transform = 'translateY(0)'
})
}
/**
* Dismisses the flash message with smooth animation
*/
dismiss() {
// Add dismiss animation
this.element.classList.add('transition-all', 'duration-300', 'ease-in')
this.element.style.opacity = '0'
this.element.style.transform = 'translateY(-10px)'
// Remove from DOM after animation
setTimeout(() => {
this.element.remove()
}, 300)
}
/**
* Schedules auto-dismissal based on the configured delay
*/
scheduleAutoDismiss() {
const delay = parseInt(this.autoDismissValue)
if (delay > 0) {
setTimeout(() => {
this.dismiss()
}, delay)
}
}
/**
* Pause auto-dismissal on hover (for user reading)
*/
mouseEnter() {
if (this.autoDismissTimer) {
clearTimeout(this.autoDismissTimer)
this.autoDismissTimer = null
}
}
/**
* Resume auto-dismissal when hover ends
*/
mouseLeave() {
if (this.autoDismissValue && this.autoDismissValue !== "false") {
this.scheduleAutoDismiss()
}
}
/**
* Handle keyboard interactions
*/
keydown(event) {
if (event.key === 'Escape' || event.key === 'Enter') {
this.dismiss()
}
}
}

View File

@@ -1,89 +0,0 @@
import { Controller } from "@hotwired/stimulus"
/**
* Manages form error display and dismissal
* Provides consistent error handling across all forms
*/
export default class extends Controller {
static targets = ["container"]
/**
* Dismisses the error container with a smooth fade-out animation
*/
dismiss() {
if (!this.hasContainerTarget) return
// Add transition classes
this.containerTarget.classList.add('transition-all', 'duration-300', 'opacity-0', 'transform', 'scale-95')
// Remove from DOM after animation completes
setTimeout(() => {
this.containerTarget.remove()
}, 300)
}
/**
* Shows server-side validation errors after form submission
* Auto-focuses the first error field for better accessibility
*/
connect() {
// Auto-focus first error field if errors exist
this.focusFirstErrorField()
// Scroll to errors if needed
this.scrollToErrors()
}
/**
* Focuses the first field with validation errors
*/
focusFirstErrorField() {
if (!this.hasContainerTarget) return
// Find first form field with errors (look for error classes or aria-invalid)
const form = this.element.closest('form')
if (!form) return
const errorField = form.querySelector('[aria-invalid="true"], .border-red-500, .ring-red-500')
if (errorField) {
setTimeout(() => {
errorField.focus()
errorField.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
}
/**
* Scrolls error container into view if it's not visible
*/
scrollToErrors() {
if (!this.hasContainerTarget) return
const rect = this.containerTarget.getBoundingClientRect()
const isInViewport = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
if (!isInViewport) {
setTimeout(() => {
this.containerTarget.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
}, 100)
}
}
/**
* Auto-dismisses success messages after a delay
* Can be called from other controllers
*/
autoDismiss(delay = 5000) {
if (!this.hasContainerTarget) return
setTimeout(() => {
this.dismiss()
}, delay)
}
}

View File

@@ -1,68 +0,0 @@
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
}
})
}
}
}

View File

@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

View File

@@ -1,81 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["textarea", "status"]
static classes = ["valid", "invalid", "validStatus", "invalidStatus"]
connect() {
this.validate()
}
validate() {
const value = this.textareaTarget.value.trim()
if (!value) {
this.clearStatus()
return true
}
try {
JSON.parse(value)
this.showValid()
return true
} catch (error) {
this.showInvalid(error.message)
return false
}
}
format() {
const value = this.textareaTarget.value.trim()
if (!value) return
try {
const parsed = JSON.parse(value)
const formatted = JSON.stringify(parsed, null, 2)
this.textareaTarget.value = formatted
this.showValid()
} catch (error) {
this.showInvalid(error.message)
}
}
clearStatus() {
this.textareaTarget.classList.remove(...this.invalidClasses)
this.textareaTarget.classList.remove(...this.validClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = ""
this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses)
}
}
showValid() {
this.textareaTarget.classList.remove(...this.invalidClasses)
this.textareaTarget.classList.add(...this.validClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = "✓ Valid JSON"
this.statusTarget.classList.remove(...this.invalidStatusClasses)
this.statusTarget.classList.add(...this.validStatusClasses)
}
}
showInvalid(errorMessage) {
this.textareaTarget.classList.remove(...this.validClasses)
this.textareaTarget.classList.add(...this.invalidClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}`
this.statusTarget.classList.remove(...this.validStatusClasses)
this.statusTarget.classList.add(...this.invalidStatusClasses)
}
}
insertSample(event) {
event.preventDefault()
const sample = event.params.json || event.target.dataset.jsonSample
if (sample) {
this.textareaTarget.value = sample
this.format()
}
}
}

View File

@@ -1,92 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Handles login form UI changes based on WebAuthn availability
export default class extends Controller {
static targets = ["webauthnSection", "passwordSection", "statusMessage", "loadingOverlay"]
connect() {
// Listen for WebAuthn availability events from the webauthn controller
this.element.addEventListener('webauthn:webauthn-available', this.handleWebAuthnAvailable.bind(this));
// Listen for WebAuthn registration events (from profile page)
this.element.addEventListener('webauthn:passkey-registered', this.handlePasskeyRegistered.bind(this));
// Listen for authentication start/end to show/hide loading
document.addEventListener('webauthn:authenticate-start', this.showLoading.bind(this));
document.addEventListener('webauthn:authenticate-end', this.hideLoading.bind(this));
}
disconnect() {
// Clean up event listeners
document.removeEventListener('webauthn:authenticate-start', this.showLoading.bind(this));
document.removeEventListener('webauthn:authenticate-end', this.hideLoading.bind(this));
}
handleWebAuthnAvailable(event) {
const detail = event.detail;
if (!this.hasWebauthnSectionTarget || !this.hasPasswordSectionTarget) {
return;
}
if (detail.hasWebauthn) {
this.webauthnSectionTarget.classList.remove('hidden');
// If WebAuthn is required, hide password section
if (detail.requiresWebauthn) {
this.passwordSectionTarget.classList.add('hidden');
} else {
// Show both options with a divider
this.passwordSectionTarget.classList.add('border-t', 'pt-4', 'mt-4');
this.addOrDivider();
}
}
}
handlePasskeyRegistered(event) {
if (!this.hasStatusMessageTarget) {
return;
}
// Show success message
this.statusMessageTarget.className = 'mt-4 p-3 rounded-md bg-green-50 text-green-800 border border-green-200';
this.statusMessageTarget.textContent = 'Passkey registered successfully!';
this.statusMessageTarget.classList.remove('hidden');
// Hide after 3 seconds
setTimeout(() => {
this.statusMessageTarget.classList.add('hidden');
}, 3000);
}
showLoading() {
if (this.hasLoadingOverlayTarget) {
this.loadingOverlayTarget.classList.remove('hidden');
}
}
hideLoading() {
if (this.hasLoadingOverlayTarget) {
this.loadingOverlayTarget.classList.add('hidden');
}
}
addOrDivider() {
// Check if divider already exists
if (this.element.querySelector('.login-divider')) {
return;
}
const orDiv = document.createElement('div');
orDiv.className = 'relative my-4 login-divider';
orDiv.innerHTML = `
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or</span>
</div>
`;
this.webauthnSectionTarget.parentNode.insertBefore(orDiv, this.passwordSectionTarget);
}
}

View File

@@ -1,48 +0,0 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["sidebarOverlay"];
connect() {
// Initialize mobile sidebar functionality
// Add escape key listener to close sidebar
this.boundHandleEscape = this.handleEscape.bind(this);
document.addEventListener('keydown', this.boundHandleEscape);
}
disconnect() {
// Clean up event listeners
document.removeEventListener('keydown', this.boundHandleEscape);
}
openSidebar() {
if (this.hasSidebarOverlayTarget) {
this.sidebarOverlayTarget.classList.remove('hidden');
// Prevent body scroll when sidebar is open
document.body.style.overflow = 'hidden';
}
}
closeSidebar() {
if (this.hasSidebarOverlayTarget) {
this.sidebarOverlayTarget.classList.add('hidden');
// Restore body scroll
document.body.style.overflow = '';
}
}
// Close sidebar when clicking on the overlay background
closeOnBackgroundClick(event) {
// Check if the click is on the overlay background (the semi-transparent layer)
if (event.target === this.sidebarOverlayTarget || event.target.classList.contains('bg-gray-900/80')) {
this.closeSidebar();
}
}
// Handle escape key to close sidebar
handleEscape(event) {
if (event.key === 'Escape' && this.hasSidebarOverlayTarget && !this.sidebarOverlayTarget.classList.contains('hidden')) {
this.closeSidebar();
}
}
}

View File

@@ -1,50 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Generic modal controller for showing/hiding modal dialogs
export default class extends Controller {
static targets = ["dialog"]
show(event) {
// If called from a button with data-modal-id, find and show that modal
const modalId = event.currentTarget?.dataset?.modalId;
if (modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove("hidden");
}
} else if (this.hasDialogTarget) {
// Otherwise show the dialog target
this.dialogTarget.classList.remove("hidden");
} else {
// Or show this element itself
this.element.classList.remove("hidden");
}
}
hide() {
// Find the currently visible modal to hide it
const visibleModal = document.querySelector('[data-controller="modal"] .fixed.inset-0:not(.hidden)');
if (visibleModal) {
visibleModal.classList.add("hidden");
} else if (this.hasDialogTarget) {
this.dialogTarget.classList.add("hidden");
} else {
this.element.classList.add("hidden");
}
}
// Close modal when clicking backdrop
closeOnBackdrop(event) {
// Only close if clicking directly on the backdrop (not child elements)
if (event.target === this.element || event.target.classList.contains('modal-backdrop')) {
this.hide();
}
}
// Close modal on Escape key
closeOnEscape(event) {
if (event.key === "Escape") {
this.hide();
}
}
}

View 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")
}
}
}

View File

@@ -1,317 +0,0 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["nickname", "submitButton", "status", "error"];
static values = {
challengeUrl: String,
createUrl: String,
checkUrl: String
};
connect() {
// Check if WebAuthn is supported
if (!this.isWebAuthnSupported()) {
console.warn("WebAuthn is not supported in this browser");
return;
}
}
// Check if browser supports WebAuthn
isWebAuthnSupported() {
return (
window.PublicKeyCredential !== undefined &&
typeof window.PublicKeyCredential === "function"
);
}
// Check if user has passkeys (for login page)
async checkWebAuthnSupport(event) {
const email = event.target.value.trim();
if (!email || !this.isValidEmail(email)) {
return;
}
try {
const response = await fetch(`${this.checkUrlValue}?email=${encodeURIComponent(email)}`);
const data = await response.json();
console.debug("WebAuthn check response:", data);
if (data.has_webauthn) {
console.debug("Dispatching webauthn-available event");
// Trigger custom event for login form to show passkey option
this.dispatch("webauthn-available", {
detail: {
hasWebauthn: data.has_webauthn,
requiresWebauthn: data.requires_webauthn,
preferredMethod: data.preferred_method
}
});
// Auto-trigger passkey authentication if required
if (data.requires_webauthn) {
setTimeout(() => this.authenticate(), 100);
}
} else {
console.debug("No WebAuthn credentials found for this email");
}
} catch (error) {
console.error("Error checking WebAuthn support:", error);
}
}
// Start registration ceremony
async register(event) {
event.preventDefault();
if (!this.isWebAuthnSupported()) {
this.showError("WebAuthn is not supported in your browser");
return;
}
const nickname = this.nicknameTarget.value.trim();
if (!nickname) {
this.showError("Please enter a nickname for this passkey");
return;
}
this.setLoading(true);
this.clearMessages();
try {
// Get registration challenge from server
const challengeResponse = await fetch(this.challengeUrlValue, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": this.getCSRFToken()
}
});
if (!challengeResponse.ok) {
throw new Error("Failed to get registration challenge");
}
const credentialCreationOptions = await challengeResponse.json();
// Use modern Web Authentication API Level 3 to parse options
// This automatically handles all base64url encoding/decoding
const publicKeyOptions = PublicKeyCredential.parseCreationOptionsFromJSON(
credentialCreationOptions
);
// Create credential via WebAuthn API
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
});
if (!credential) {
throw new Error("Failed to create credential");
}
// Send credential to server for verification
// Use toJSON() to properly serialize the credential
const credentialResponse = await fetch(this.createUrlValue, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": this.getCSRFToken()
},
body: JSON.stringify({
credential: credential.toJSON(),
nickname: nickname
})
});
const result = await credentialResponse.json();
if (result.success) {
this.showSuccess(result.message);
// Clear the form
this.nicknameTarget.value = "";
// Dispatch event to refresh the passkey list
this.dispatch("passkey-registered", {
detail: {
nickname: nickname,
credentialId: result.credential_id
}
});
// Optionally close modal or redirect
setTimeout(() => {
if (window.location.pathname === "/webauthn/new") {
window.location.href = "/profile";
}
}, 1500);
} else {
this.showError(result.error || "Failed to register passkey");
}
} catch (error) {
console.error("WebAuthn registration error:", error);
this.showError(this.getErrorMessage(error));
} finally {
this.setLoading(false);
}
}
// Start authentication ceremony
async authenticate(event) {
if (event) {
event.preventDefault();
}
if (!this.isWebAuthnSupported()) {
this.showError("WebAuthn is not supported in your browser");
return;
}
this.setLoading(true);
this.clearMessages();
try {
// Get authentication challenge from server
const response = await fetch("/sessions/webauthn/challenge", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": this.getCSRFToken()
},
body: JSON.stringify({
email: this.getUserEmail()
})
});
if (!response.ok) {
throw new Error("Failed to get authentication challenge");
}
const credentialRequestOptions = await response.json();
// Use modern Web Authentication API Level 3 to parse options
// This automatically handles all base64url encoding/decoding
const publicKeyOptions = PublicKeyCredential.parseRequestOptionsFromJSON(
credentialRequestOptions
);
// Get credential via WebAuthn API
const credential = await navigator.credentials.get({
publicKey: publicKeyOptions
});
if (!credential) {
throw new Error("Failed to get credential");
}
// Send assertion to server for verification
// Use toJSON() to properly serialize the credential
const authResponse = await fetch("/sessions/webauthn/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": this.getCSRFToken()
},
body: JSON.stringify({
credential: credential.toJSON(),
email: this.getUserEmail()
})
});
const result = await authResponse.json();
if (result.success) {
// Redirect to dashboard or intended URL
window.location.href = result.redirect_to || "/";
} else {
this.showError(result.error || "Authentication failed");
}
} catch (error) {
console.error("WebAuthn authentication error:", error);
this.showError(this.getErrorMessage(error));
} finally {
this.setLoading(false);
}
}
// UI helper methods
setLoading(isLoading) {
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = isLoading;
this.submitButtonTarget.textContent = isLoading ? "Registering..." : "Register Passkey";
}
}
showSuccess(message) {
if (this.hasStatusTarget) {
this.statusTarget.textContent = message;
this.statusTarget.className = "mt-2 text-sm text-green-600";
this.statusTarget.style.display = "block";
}
}
showError(message) {
if (this.hasErrorTarget) {
this.errorTarget.textContent = message;
this.errorTarget.className = "mt-2 text-sm text-red-600";
this.errorTarget.style.display = "block";
}
}
clearMessages() {
if (this.hasStatusTarget) {
this.statusTarget.style.display = "none";
this.statusTarget.textContent = "";
}
if (this.hasErrorTarget) {
this.errorTarget.style.display = "none";
this.errorTarget.textContent = "";
}
}
getCSRFToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute("content") : "";
}
getUserEmail() {
// Try multiple ways to get the user email from login form
let emailInput = document.querySelector('input[type="email"]');
if (!emailInput) {
emailInput = document.querySelector('input[name="email"]');
}
if (!emailInput) {
emailInput = document.querySelector('input[name="session[email_address]"]');
}
if (!emailInput) {
emailInput = document.querySelector('input[name="user[email_address]"]');
}
return emailInput ? emailInput.value.trim() : "";
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
getErrorMessage(error) {
// Common WebAuthn errors
if (error.name === "NotAllowedError") {
return "Authentication was cancelled or timed out. Please try again.";
}
if (error.name === "SecurityError") {
return "Security requirements not met. Make sure you're using HTTPS.";
}
if (error.name === "NotSupportedError") {
return "This device doesn't support the requested authentication method.";
}
if (error.name === "InvalidStateError") {
return "This authenticator has already been registered.";
}
// Fallback to error message
return error.message || "An unexpected error occurred";
}
}

View File

@@ -1,49 +1,53 @@
class Application < ApplicationRecord class Application < ApplicationRecord
has_secure_password :client_secret, validations: false has_secure_password :client_secret
has_many :application_groups, dependent: :destroy has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group has_many :allowed_groups, through: :application_groups, source: :group
has_many :oidc_authorization_codes, dependent: :destroy has_many :oidc_authorization_codes, dependent: :destroy
has_many :oidc_access_tokens, dependent: :destroy has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy
has_many :application_roles, dependent: :destroy
has_many :user_role_assignments, through: :application_roles
validates :name, presence: true validates :name, presence: true
validates :slug, presence: true, uniqueness: { case_sensitive: false }, validates :slug, presence: true, uniqueness: { case_sensitive: false },
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" } format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
validates :app_type, presence: true, validates :app_type, presence: true,
inclusion: { in: %w[oidc forward_auth] } inclusion: { in: %w[oidc saml] }
validates :client_id, uniqueness: { allow_nil: true } validates :client_id, uniqueness: { allow_nil: true }
validates :client_secret, presence: true, on: :create, if: -> { oidc? } validates :role_mapping_mode, inclusion: { in: %w[disabled oidc_managed hybrid] }, allow_blank: true
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
normalizes :slug, with: ->(slug) { slug.strip.downcase } normalizes :slug, with: ->(slug) { slug.strip.downcase }
normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
before_validation :generate_client_credentials, on: :create, if: :oidc? 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 # Scopes
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
scope :oidc, -> { where(app_type: "oidc") } scope :oidc, -> { where(app_type: "oidc") }
scope :forward_auth, -> { where(app_type: "forward_auth") } scope :saml, -> { where(app_type: "saml") }
scope :ordered, -> { order(domain_pattern: :asc) } scope :oidc_managed_roles, -> { where(role_mapping_mode: "oidc_managed") }
scope :hybrid_roles, -> { where(role_mapping_mode: "hybrid") }
# Type checks # Type checks
def oidc? def oidc?
app_type == "oidc" app_type == "oidc"
end end
def forward_auth? def saml?
app_type == "forward_auth" app_type == "saml"
end
# Role mapping checks
def role_mapping_enabled?
role_mapping_mode.in?(['oidc_managed', 'hybrid'])
end
def oidc_managed_roles?
role_mapping_mode == 'oidc_managed'
end
def hybrid_roles?
role_mapping_mode == 'hybrid'
end end
# Access control # Access control
@@ -73,74 +77,49 @@ class Application < ApplicationRecord
{} {}
end end
# ForwardAuth helpers def parsed_managed_permissions
def parsed_headers_config return {} unless managed_permissions.present?
return {} unless headers_config.present? managed_permissions.is_a?(Hash) ? managed_permissions : JSON.parse(managed_permissions)
headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
rescue JSON::ParserError rescue JSON::ParserError
{} {}
end end
# Check if a domain matches this application's pattern (for ForwardAuth) # Role management methods
def matches_domain?(domain) def user_roles(user)
return false if domain.blank? || !forward_auth? application_roles.joins(:user_role_assignments)
.where(user_role_assignments: { user: user })
pattern = domain_pattern.gsub('.', '\.') .active
pattern = pattern.gsub('*', '[^.]*')
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
regex.match?(domain.downcase)
end end
# Policy determination based on user status (for ForwardAuth) def user_has_role?(user, role_name)
def policy_for_user(user) user_roles(user).exists?(name: role_name)
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
# Get effective header configuration (for ForwardAuth) def assign_role_to_user!(user, role_name, source: 'manual', metadata: {})
def effective_headers role = application_roles.active.find_by!(name: role_name)
DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys) role.assign_to_user!(user, source: source, metadata: metadata)
end end
# Generate headers for a specific user (for ForwardAuth) def remove_role_from_user!(user, role_name)
def headers_for_user(user) role = application_roles.find_by!(name: role_name)
headers = {} role.remove_from_user!(user)
effective = effective_headers end
# Only generate headers that are configured (not set to nil/false) # Enhanced access control with roles
effective.each do |key, header_name| def user_allowed_with_roles?(user)
next unless header_name.present? # Skip disabled headers return user_allowed?(user) unless role_mapping_enabled?
case key # For OIDC managed roles, check if user has any roles assigned
when :user, :email if oidc_managed_roles?
headers[header_name] = user.email_address return user_roles(user).exists?
when :name
headers[header_name] = user.name.presence || 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 end
headers # For hybrid mode, either group-based access or role-based access works
end if hybrid_roles?
return user_allowed?(user) || user_roles(user).exists?
end
# Check if all headers are disabled (for ForwardAuth) user_allowed?(user)
def headers_disabled?
headers_config.present? && effective_headers.values.all?(&:blank?)
end end
# Generate and return a new client secret # Generate and return a new client secret

View File

@@ -0,0 +1,26 @@
class ApplicationRole < ApplicationRecord
belongs_to :application
has_many :user_role_assignments, dependent: :destroy
has_many :users, through: :user_role_assignments
validates :name, presence: true, uniqueness: { scope: :application_id }
validates :display_name, presence: true
scope :active, -> { where(active: true) }
def user_has_role?(user)
user_role_assignments.exists?(user: user)
end
def assign_to_user!(user, source: 'oidc', metadata: {})
user_role_assignments.find_or_create_by!(user: user) do |assignment|
assignment.source = source
assignment.metadata = metadata
end
end
def remove_from_user!(user)
assignment = user_role_assignments.find_by(user: user)
assignment&.destroy
end
end

View File

@@ -0,0 +1,94 @@
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 }
# Default header configuration
DEFAULT_HEADERS = {
user: 'X-Remote-User',
email: 'X-Remote-Email',
name: 'X-Remote-Name',
groups: 'X-Remote-Groups',
admin: 'X-Remote-Admin'
}.freeze
# Scopes
scope :active, -> { where(active: true) }
scope :ordered, -> { order(domain_pattern: :asc) }
# 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
# Get effective header configuration (rule-specific + defaults)
def effective_headers
DEFAULT_HEADERS.merge((headers_config || {}).symbolize_keys)
end
# Generate headers for a specific user
def headers_for_user(user)
headers = {}
effective = effective_headers
# Only generate headers that are configured (not set to nil/false)
effective.each do |key, header_name|
next unless header_name.present? # Skip disabled headers
case key
when :user, :email, :name
headers[header_name] = user.email_address
when :groups
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
when :admin
headers[header_name] = user.admin? ? "true" : "false"
end
end
headers
end
# Check if all headers are disabled
def headers_disabled?
headers_config.present? && effective_headers.values.all?(&:blank?)
end
end

View File

@@ -0,0 +1,6 @@
class ForwardAuthRuleGroup < ApplicationRecord
belongs_to :forward_auth_rule
belongs_to :group
validates :forward_auth_rule_id, uniqueness: { scope: :group_id }
end

View File

@@ -6,9 +6,4 @@ class Group < ApplicationRecord
validates :name, presence: true, uniqueness: { case_sensitive: false } validates :name, presence: true, uniqueness: { case_sensitive: false }
normalizes :name, with: ->(name) { name.strip.downcase } normalizes :name, with: ->(name) { name.strip.downcase }
# Parse custom_claims JSON field
def parsed_custom_claims
custom_claims || {}
end
end end

View File

@@ -3,8 +3,9 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy has_many :sessions, dependent: :destroy
has_many :user_groups, dependent: :destroy has_many :user_groups, dependent: :destroy
has_many :groups, through: :user_groups has_many :groups, through: :user_groups
has_many :user_role_assignments, dependent: :destroy
has_many :application_roles, through: :user_role_assignments
has_many :oidc_user_consents, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
# Token generation for passwordless flows # Token generation for passwordless flows
generates_token_for :invitation_login, expires_in: 24.hours do generates_token_for :invitation_login, expires_in: 24.hours do
@@ -66,101 +67,19 @@ class User < ApplicationRecord
def verify_backup_code(code) def verify_backup_code(code)
return false unless backup_codes.present? return false unless backup_codes.present?
# Rate limiting: prevent brute force attacks on backup codes codes = JSON.parse(backup_codes)
if rate_limit_backup_code_verification? if codes.include?(code)
Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}" codes.delete(code)
return false update(backup_codes: codes.to_json)
end
# backup_codes is now an Array (JSON column), no need to parse
# Find the matching hash by comparing with BCrypt
matching_hash = backup_codes.find do |hashed_code|
BCrypt::Password.new(hashed_code) == code
end
if matching_hash
# Remove the used hash from the array (single-use property)
backup_codes.delete(matching_hash)
save! # Save the updated array
# Log successful backup code usage for security monitoring
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}"
true true
else else
# Increment failed attempt counter and log for security monitoring
increment_backup_code_failed_attempts
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}"
false false
end end
end end
# Rate limiting for backup code verification to prevent brute force attacks def parsed_backup_codes
def rate_limit_backup_code_verification? return [] unless backup_codes.present?
# Use Rails.cache to track failed attempts JSON.parse(backup_codes)
cache_key = "backup_code_failed_attempts_#{id}"
attempts = Rails.cache.read(cache_key) || 0
if attempts >= 5 # Allow max 5 failed attempts per hour
true
else
# Don't increment here - increment only on failed attempts
false
end
end
# Increment failed attempt counter
def increment_backup_code_failed_attempts
cache_key = "backup_code_failed_attempts_#{id}"
attempts = Rails.cache.read(cache_key) || 0
Rails.cache.write(cache_key, attempts + 1, expires_in: 1.hour)
end
# WebAuthn methods
def webauthn_enabled?
webauthn_credentials.exists?
end
def can_authenticate_with_webauthn?
webauthn_enabled? && active?
end
def require_webauthn?
webauthn_required? || (webauthn_enabled? && !password_digest.present?)
end
# Generate stable WebAuthn user handle on first use
def webauthn_user_handle
return webauthn_id if webauthn_id.present?
# Generate random 64-byte opaque identifier (base64url encoded)
handle = SecureRandom.urlsafe_base64(64)
update_column(:webauthn_id, handle)
handle
end
def platform_authenticators
webauthn_credentials.platform_authenticators
end
def roaming_authenticators
webauthn_credentials.roaming_authenticators
end
def webauthn_credential_for(external_id)
webauthn_credentials.find_by(external_id: external_id)
end
# Check if user has any backed up (synced) passkeys
def has_synced_passkeys?
webauthn_credentials.exists?(backup_eligible: true, backup_state: true)
end
# Preferred authentication method for login flow
def preferred_authentication_method
return :webauthn if require_webauthn?
return :webauthn if can_authenticate_with_webauthn? && preferred_2fa_method == "webauthn"
return :password if password_digest.present?
:webauthn
end end
def has_oidc_consent?(application, requested_scopes) def has_oidc_consent?(application, requested_scopes)
@@ -178,24 +97,9 @@ class User < ApplicationRecord
oidc_user_consents.destroy_all oidc_user_consents.destroy_all
end end
# Parse custom_claims JSON field
def parsed_custom_claims
custom_claims || {}
end
private private
def generate_backup_codes def generate_backup_codes
# Generate plain codes for user to see/save Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
# Store BCrypt hashes of the codes
hashed_codes = plain_codes.map { |code| BCrypt::Password.create(code) }
# Return plain codes for display (will be shown to user once)
# Store only hashes in the database (as Array for JSON column)
self.backup_codes = hashed_codes
plain_codes
end end
end end

View File

@@ -0,0 +1,15 @@
class UserRoleAssignment < ApplicationRecord
belongs_to :user
belongs_to :application_role
validates :user, uniqueness: { scope: :application_role }
validates :source, inclusion: { in: %w[oidc manual group_sync] }
scope :oidc_managed, -> { where(source: 'oidc') }
scope :manually_assigned, -> { where(source: 'manual') }
scope :group_synced, -> { where(source: 'group_sync') }
def sync_from_oidc?
source == 'oidc'
end
end

View File

@@ -1,96 +0,0 @@
class WebauthnCredential < ApplicationRecord
belongs_to :user
# Validations
validates :external_id, presence: true, uniqueness: true
validates :public_key, presence: true
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
validates :nickname, presence: true
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] }
# Scopes for querying
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
scope :platform_authenticators, -> { where(authenticator_type: "platform") }
scope :roaming_authenticators, -> { where(authenticator_type: "cross-platform") }
scope :recently_used, -> { where.not(last_used_at: nil).order(last_used_at: :desc) }
scope :never_used, -> { where(last_used_at: nil) }
# Update last used timestamp and sign count after successful authentication
def update_usage!(sign_count:, ip_address: nil, user_agent: nil)
update!(
last_used_at: Time.current,
last_used_ip: ip_address,
sign_count: sign_count,
user_agent: user_agent
)
end
# Check if this is a platform authenticator (built-in device)
def platform_authenticator?
authenticator_type == "platform"
end
# Check if this is a roaming authenticator (USB/NFC/Bluetooth key)
def roaming_authenticator?
authenticator_type == "cross-platform"
end
# Check if this credential is backed up (synced passkeys)
def backed_up?
backup_eligible? && backup_state?
end
# Human readable description
def description
if nickname.present?
"#{nickname} (#{authenticator_type.humanize})"
else
"#{authenticator_type.humanize} Authenticator"
end
end
# Check if sign count is suspicious (clone detection)
def suspicious_sign_count?(new_sign_count)
return false if sign_count.zero? && new_sign_count > 0 # First use
return false if new_sign_count > sign_count # Normal increment
# Sign count didn't increase - possible clone
true
end
# Format for display in UI
def display_name
nickname || "#{authenticator_type&.humanize} Authenticator"
end
# When was this credential created?
def created_recently?
created_at > 1.week.ago
end
# How long ago was this last used?
def last_used_ago
return "Never" unless last_used_at
time_ago_in_words(last_used_at)
end
private
def time_ago_in_words(time)
seconds = Time.current - time
minutes = seconds / 60
hours = minutes / 60
days = hours / 24
if days > 0
"#{days.floor} day#{'s' if days > 1} ago"
elsif hours > 0
"#{hours.floor} hour#{'s' if hours > 1} ago"
elsif minutes > 0
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
else
"Just now"
end
end
end

View File

@@ -13,7 +13,7 @@ class OidcJwtService
email: user.email_address, email: user.email_address,
email_verified: true, email_verified: true,
preferred_username: user.email_address, preferred_username: user.email_address,
name: user.name.presence || user.email_address name: user.email_address
} }
# Add nonce if provided (OIDC requires this for implicit flow) # Add nonce if provided (OIDC requires this for implicit flow)
@@ -27,14 +27,11 @@ class OidcJwtService
# Add admin claim if user is admin # Add admin claim if user is admin
payload[:admin] = true if user.admin? payload[:admin] = true if user.admin?
# Merge custom claims from groups # Add role-based claims if role mapping is enabled
user.groups.each do |group| if application.role_mapping_enabled?
payload.merge!(group.parsed_custom_claims) add_role_claims!(payload, user, application)
end 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" }) JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end end
@@ -96,5 +93,50 @@ class OidcJwtService
def key_id def key_id
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15] @key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
end end
# Add role-based claims to the JWT payload
def add_role_claims!(payload, user, application)
user_roles = application.user_roles(user)
return if user_roles.empty?
role_names = user_roles.pluck(:name)
# Filter roles by prefix if configured
if application.role_prefix.present?
role_names = role_names.select { |role| role.start_with?(application.role_prefix) }
end
return if role_names.empty?
# Add roles using the configured claim name
claim_name = application.role_claim_name.presence || 'roles'
payload[claim_name] = role_names
# Add role permissions if configured
managed_permissions = application.parsed_managed_permissions
if managed_permissions['include_permissions'] == true
role_permissions = user_roles.map do |role|
{
name: role.name,
display_name: role.display_name,
permissions: role.permissions
}
end
payload['role_permissions'] = role_permissions
end
# Add role metadata if configured
if managed_permissions['include_metadata'] == true
role_metadata = user_roles.map do |role|
assignment = role.user_role_assignments.find_by(user: user)
{
name: role.name,
source: assignment&.source,
assigned_at: assignment&.created_at
}
end
payload['role_metadata'] = role_metadata
end
end
end end
end end

View File

@@ -0,0 +1,127 @@
class RoleMappingEngine
class << self
# Sync user roles from OIDC claims
def sync_user_roles!(user, application, claims)
return unless application.role_mapping_enabled?
# Extract roles from claims
external_roles = extract_roles_from_claims(application, claims)
case application.role_mapping_mode
when 'oidc_managed'
sync_oidc_managed_roles!(user, application, external_roles)
when 'hybrid'
sync_hybrid_roles!(user, application, external_roles)
end
end
# Check if user is allowed based on roles
def user_allowed_with_roles?(user, application, claims = nil)
return application.user_allowed_with_roles?(user) unless claims
if application.oidc_managed_roles?
external_roles = extract_roles_from_claims(application, claims)
return false if external_roles.empty?
# Check if any external role matches configured application roles
application.application_roles.active.exists?(name: external_roles)
elsif application.hybrid_roles?
# Allow access if either group-based or role-based access works
application.user_allowed?(user) ||
(external_roles.present? &&
application.application_roles.active.exists?(name: external_roles))
else
application.user_allowed?(user)
end
end
# Get available roles for a user in an application
def user_available_roles(user, application)
return [] unless application.role_mapping_enabled?
application.application_roles.active
end
# Map external roles to internal roles
def map_external_to_internal_roles(application, external_roles)
return [] if external_roles.empty?
configured_roles = application.application_roles.active.pluck(:name)
# Apply role prefix filtering
if application.role_prefix.present?
external_roles = external_roles.select { |role| role.start_with?(application.role_prefix) }
end
# Find matching internal roles
external_roles & configured_roles
end
private
# Extract roles from various claim sources
def extract_roles_from_claims(application, claims)
claim_name = application.role_claim_name.presence || 'roles'
# Try the configured claim name first
roles = claims[claim_name]
# Fallback to common claim names if not found
roles ||= claims['roles']
roles ||= claims['groups']
roles ||= claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']
# Ensure roles is an array
case roles
when String
[roles]
when Array
roles
else
[]
end
end
# Sync roles for OIDC managed mode (replace existing roles)
def sync_oidc_managed_roles!(user, application, external_roles)
# Map external roles to internal roles
internal_roles = map_external_to_internal_roles(application, external_roles)
# Get current OIDC-managed roles
current_assignments = user.user_role_assignments
.joins(:application_role)
.where(application_role: { application: application })
.oidc_managed
.includes(:application_role)
current_role_names = current_assignments.map { |assignment| assignment.application_role.name }
# Remove roles that are no longer in external roles
roles_to_remove = current_role_names - internal_roles
roles_to_remove.each do |role_name|
application.remove_role_from_user!(user, role_name)
end
# Add new roles
roles_to_add = internal_roles - current_role_names
roles_to_add.each do |role_name|
application.assign_role_to_user!(user, role_name, source: 'oidc',
metadata: { synced_at: Time.current })
end
end
# Sync roles for hybrid mode (merge with existing roles)
def sync_hybrid_roles!(user, application, external_roles)
# Map external roles to internal roles
internal_roles = map_external_to_internal_roles(application, external_roles)
# Only add new roles, don't remove manually assigned ones
internal_roles.each do |role_name|
next if application.user_has_role?(user, role_name)
application.assign_role_to_user!(user, role_name, source: 'oidc',
metadata: { synced_at: Time.current })
end
end
end
end

View File

@@ -1,114 +0,0 @@
<div class="space-y-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Sessions</h1>
<p class="mt-2 text-sm text-gray-600">Manage your active sessions and connected applications.</p>
</div>
<!-- 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_active_sessions_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 %>
<% if @connected_applications.any? %>
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end">
<div class="inline-block">
<%= button_to "Revoke All App Access", revoke_all_consents_active_sessions_path, 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 whitespace-nowrap",
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?" } } %>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Active Sessions -->
<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">Active Sessions</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
</div>
<div class="mt-5">
<% if @active_sessions.any? %>
<ul role="list" class="divide-y divide-gray-200">
<% @active_sessions.each do |session| %>
<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">
<%= session.device_name || "Unknown Device" %>
<% if session.id == Current.session.id %>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
This device
</span>
<% end %>
</p>
<p class="mt-1 text-sm text-gray-500">
<%= session.ip_address %>
</p>
<p class="mt-1 text-xs text-gray-400">
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
</p>
</div>
<% if session.id != Current.session.id %>
<%= button_to "Revoke", session_path(session), method: :delete,
class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
<% end %>
</div>
</li>
<% end %>
</ul>
<% else %>
<p class="text-sm text-gray-500">No other active sessions.</p>
<% end %>
<% if @active_sessions.count > 1 %>
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end">
<div class="inline-block">
<%= 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-3 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 whitespace-nowrap",
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,22 @@
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %> <%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
<%= render "shared/form_errors", form: form %> <% if application.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% application.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -17,26 +34,16 @@
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %> <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
</div> </div>
<div>
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
<p class="mt-1 text-sm text-gray-500">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
</div>
<div> <div>
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %> <%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, { <%= 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? %>
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?,
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
} %>
<% if application.persisted? %> <% if application.persisted? %>
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p> <p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
<% end %> <% end %>
</div> </div>
<!-- OIDC-specific fields --> <!-- OIDC-specific fields -->
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields"> <div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.oidc? || !application.persisted? %>">
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3> <h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
<div> <div>
@@ -44,49 +51,50 @@
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %> <%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p> <p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
</div> </div>
</div>
<!-- Forward Auth-specific fields --> <!-- Role Mapping Configuration -->
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields"> <div class="border-t border-gray-200 pt-6">
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3> <h4 class="text-base font-semibold text-gray-900 mb-4">Role Mapping Configuration</h4>
<div> <div>
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %> <%= form.label :role_mapping_mode, "Role Mapping Mode", 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" %> <%= form.select :role_mapping_mode,
<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> options_for_select([
</div> ["Disabled", "disabled"],
["OIDC Managed", "oidc_managed"],
["Hybrid (Groups + Roles)", "hybrid"]
], application.role_mapping_mode || "disabled"),
{},
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
<p class="mt-1 text-sm text-gray-500">Controls how external roles are mapped and synchronized.</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600"> <div id="role-mapping-advanced" class="mt-4 space-y-4 border-t border-gray-200 pt-4" style="<%= 'display: none;' unless application.role_mapping_enabled? %>">
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %> <div>
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10, <%= form.label :role_claim_name, "Role Claim Name", class: "block text-sm font-medium text-gray-700" %>
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", <%= form.text_field :role_claim_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "roles" %>
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}', <p class="mt-1 text-sm text-gray-500">Name of the claim that contains role information (default: 'roles').</p>
data: { </div>
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea" <div>
} %> <%= form.label :role_prefix, "Role Prefix (Optional)", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-2 text-sm text-gray-600 space-y-1"> <%= form.text_field :role_prefix, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "app-" %>
<div class="flex items-center justify-between"> <p class="mt-1 text-sm text-gray-500">Only roles starting with this prefix will be mapped. Useful for multi-tenant scenarios.</p>
<p class="font-medium">Optional: Customize header names sent to your application.</p> </div>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button> <div class="space-y-3">
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button> <label class="block text-sm font-medium text-gray-700">Managed Permissions</label>
<div class="flex items-center">
<%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_permissions", "" %>
<%= form.label :managed_permissions_include_permissions, "Include role permissions in tokens", class: "ml-2 block text-sm text-gray-900" %>
</div>
<div class="flex items-center">
<%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_metadata", "" %>
<%= form.label :managed_permissions_include_metadata, "Include role metadata in tokens", class: "ml-2 block text-sm text-gray-900" %>
</div> </div>
</div> </div>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
<details class="mt-2">
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
<div class="mt-2 ml-4 space-y-1 text-xs">
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p>
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
</div>
</details>
</div> </div>
</div> </div>
</div> </div>
@@ -120,3 +128,34 @@
</div> </div>
<% end %> <% end %>
<script>
// Show/hide OIDC fields based on app type selection
const appTypeSelect = document.querySelector('#application_app_type');
const oidcFields = document.querySelector('#oidc-fields');
const roleMappingMode = document.querySelector('#application_role_mapping_mode');
const roleMappingAdvanced = document.querySelector('#role-mapping-advanced');
function updateFieldVisibility() {
const isOidc = appTypeSelect.value === 'oidc';
const roleMappingEnabled = roleMappingMode && ['oidc_managed', 'hybrid'].includes(roleMappingMode.value);
if (oidcFields) {
oidcFields.style.display = isOidc ? 'block' : 'none';
}
if (roleMappingAdvanced) {
roleMappingAdvanced.style.display = isOidc && roleMappingEnabled ? 'block' : 'none';
}
}
if (appTypeSelect && oidcFields) {
appTypeSelect.addEventListener('change', updateFieldVisibility);
}
if (roleMappingMode) {
roleMappingMode.addEventListener('change', updateFieldVisibility);
}
// Initialize visibility on page load
updateFieldVisibility();
</script>

View File

@@ -0,0 +1,125 @@
<% content_for :title, "Role Management - #{@application.name}" %>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">
Role Management for <%= @application.name %>
</h3>
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
</div>
<% if @application.role_mapping_enabled? %>
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
<div class="mt-2 text-sm text-blue-700">
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
<% if @application.role_claim_name.present? %>
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
<% end %>
<% if @application.role_prefix.present? %>
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
<% end %>
</div>
</div>
</div>
</div>
<% else %>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
</div>
</div>
</div>
</div>
<% end %>
<!-- Create New Role -->
<div class="border-b border-gray-200 pb-6 mb-6">
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
</div>
<div>
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
</div>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
</div>
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
</div>
<div>
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
<% end %>
</div>
<!-- Existing Roles -->
<div class="space-y-6">
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
<% if @application_roles.any? %>
<div class="space-y-4">
<% @application_roles.each do |role| %>
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3">
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<%= role.display_name %>
</span>
<% unless role.active %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Inactive
</span>
<% end %>
</div>
<% if role.description.present? %>
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
<% end %>
<!-- Assigned Users -->
<div class="mt-3">
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
<div class="flex flex-wrap gap-2">
<% role.users.each do |user| %>
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
<%= user.email_address %>
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
method: :post,
data: { confirm: "Remove role from #{user.email_address}?" },
class: "ml-1 text-blue-600 hover:text-blue-800" %>
</span>
<% end %>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-12">
<div class="text-gray-500 text-sm">
No roles configured yet. Create your first role above to get started with role-based access control.
</div>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,173 @@
<% content_for :title, "Role Management - #{@application.name}" %>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">
Role Management for <%= @application.name %>
</h3>
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
</div>
<% if @application.role_mapping_enabled? %>
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
<div class="mt-2 text-sm text-blue-700">
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
<% if @application.role_claim_name.present? %>
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
<% end %>
<% if @application.role_prefix.present? %>
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
<% end %>
</div>
</div>
</div>
</div>
<% else %>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
</div>
</div>
</div>
</div>
<% end %>
<!-- Create New Role -->
<div class="border-b border-gray-200 pb-6 mb-6">
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
</div>
<div>
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
</div>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
</div>
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
</div>
<div>
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
<% end %>
</div>
<!-- Existing Roles -->
<div class="space-y-6">
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
<% if @application_roles.any? %>
<div class="space-y-4">
<% @application_roles.each do |role| %>
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3">
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<%= role.display_name %>
</span>
<% unless role.active %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Inactive
</span>
<% end %>
</div>
<% if role.description.present? %>
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
<% end %>
<!-- Assigned Users -->
<div class="mt-3">
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
<div class="flex flex-wrap gap-2">
<% role.users.each do |user| %>
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
<%= user.email_address %>
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
method: :post,
data: { confirm: "Remove role from #{user.email_address}?" },
class: "ml-1 text-blue-600 hover:text-blue-800" %>
</span>
<% end %>
</div>
</div>
</div>
<!-- Actions -->
<div class="ml-4 flex-shrink-0">
<div class="space-y-2">
<!-- Assign Role to User -->
<div class="flex items-center space-x-2">
<select id="assign-user-<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Assign to user...</option>
<% @available_users.each do |user| %>
<% unless role.user_has_role?(user) %>
<option value="<%= user.id %>"><%= user.email_address %></option>
<% end %>
<% end %>
</select>
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "REPLACE_USER_ID"),
method: :post,
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
onclick: "this.href = this.href.replace('REPLACE_USER_ID', document.getElementById('assign-user-<%= role.id %>').value); if (this.href.includes('undefined')) { alert('Please select a user'); return false; }" %>
</div>
<!-- Edit Role -->
<%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %>
</div>
</div>
</div>
<!-- Edit Role Form (Hidden by default) -->
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4">
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div class="flex items-center pt-6">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
</div>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div class="flex space-x-2">
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
<%= link_to "Cancel", "#", class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-12">
<div class="text-gray-500 text-sm">
No roles configured yet. Create your first role above to get started with role-based access control.
</div>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,179 @@
<% content_for :title, "Role Management - #{@application.name}" %>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">
Role Management for <%= @application.name %>
</h3>
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
</div>
<% if @application.role_mapping_enabled? %>
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
<div class="mt-2 text-sm text-blue-700">
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
<% if @application.role_claim_name.present? %>
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
<% end %>
<% if @application.role_prefix.present? %>
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
<% end %>
</div>
</div>
</div>
</div>
<% else %>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
</div>
</div>
</div>
</div>
<% end %>
<!-- Create New Role -->
<div class="border-b border-gray-200 pb-6 mb-6">
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
</div>
<div>
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
</div>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
</div>
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
</div>
<div>
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
<% end %>
</div>
<!-- Existing Roles -->
<div class="space-y-6" data-controller="role-management">
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
<% if @application_roles.any? %>
<div class="space-y-4">
<% @application_roles.each do |role| %>
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3">
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<%= role.display_name %>
</span>
<% unless role.active %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Inactive
</span>
<% end %>
</div>
<% if role.description.present? %>
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
<% end %>
<!-- Assigned Users -->
<div class="mt-3">
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
<div class="flex flex-wrap gap-2">
<% role.users.each do |user| %>
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
<%= user.email_address %>
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
method: :post,
data: { confirm: "Remove role from #{user.email_address}?" },
class: "ml-1 text-blue-600 hover:text-blue-800" %>
</span>
<% end %>
</div>
</div>
</div>
<!-- Actions -->
<div class="ml-4 flex-shrink-0">
<div class="space-y-2">
<!-- Assign Role to User -->
<div class="flex items-center space-x-2">
<select id="assign-user-<%= role.id %>" data-role-target="userSelect" data-role-id="<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Assign to user...</option>
<% @available_users.each do |user| %>
<% unless role.user_has_role?(user) %>
<option value="<%= user.id %>"><%= user.email_address %></option>
<% end %>
<% end %>
</select>
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"),
method: :post,
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
data: { role_target: "assignLink", action: "click->role-management#assignRole" } %>
</div>
<!-- Edit Role -->
<%= link_to "Edit", "#",
class: "text-xs text-gray-600 hover:text-gray-800",
data: { action: "click->role-management#toggleEdit" },
data: { role_id: role.id } %>
</div>
</div>
</div>
<!-- Edit Role Form (Hidden by default) -->
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4" data-role-target="editForm" data-role-id="<%= role.id %>">
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div class="flex items-center pt-6">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
</div>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div class="flex space-x-2">
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
<%= link_to "Cancel", "#",
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50",
data: { action: "click->role-management#hideEdit" },
data: { role_id: role.id } %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-12">
<div class="text-gray-500 text-sm">
No roles configured yet. Create your first role above to get started with role-based access control.
</div>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,173 @@
<% content_for :title, "Role Management - #{@application.name}" %>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">
Role Management for <%= @application.name %>
</h3>
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
</div>
<% if @application.role_mapping_enabled? %>
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
<div class="mt-2 text-sm text-blue-700">
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
<% if @application.role_claim_name.present? %>
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
<% end %>
<% if @application.role_prefix.present? %>
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
<% end %>
</div>
</div>
</div>
</div>
<% else %>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
</div>
</div>
</div>
</div>
<% end %>
<!-- Create New Role -->
<div class="border-b border-gray-200 pb-6 mb-6">
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
</div>
<div>
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
</div>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
</div>
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
</div>
<div>
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
<% end %>
</div>
<!-- Existing Roles -->
<div class="space-y-6">
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
<% if @application_roles.any? %>
<div class="space-y-4">
<% @application_roles.each do |role| %>
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-3">
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<%= role.display_name %>
</span>
<% unless role.active %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Inactive
</span>
<% end %>
</div>
<% if role.description.present? %>
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
<% end %>
<!-- Assigned Users -->
<div class="mt-3">
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
<div class="flex flex-wrap gap-2">
<% role.users.each do |user| %>
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
<%= user.email_address %>
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
method: :post,
data: { confirm: "Remove role from #{user.email_address}?" },
class: "ml-1 text-blue-600 hover:text-blue-800" %>
</span>
<% end %>
</div>
</div>
</div>
<!-- Actions -->
<div class="ml-4 flex-shrink-0">
<div class="space-y-2">
<!-- Assign Role to User -->
<div class="flex items-center space-x-2">
<select id="assign-user-<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
<option value="">Assign to user...</option>
<% @available_users.each do |user| %>
<% unless role.user_has_role?(user) %>
<option value="<%= user.id %>"><%= user.email_address %></option>
<% end %>
<% end %>
</select>
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"),
method: :post,
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
onclick: "var select = document.getElementById('assign-user-<%= role.id %>'); var userId = select.value; if (!userId) { alert('Please select a user'); return false; } this.href = this.href.replace('PLACEHOLDER', userId);" %>
</div>
<!-- Edit Role -->
<%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %>
</div>
</div>
</div>
<!-- Edit Role Form (Hidden by default) -->
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4">
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div class="flex items-center pt-6">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
</div>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div class="flex space-x-2">
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
<%= link_to "Cancel", "#", class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-12">
<div class="text-gray-500 text-sm">
No roles configured yet. Create your first role above to get started with role-based access control.
</div>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -23,6 +23,9 @@
</div> </div>
<div class="mt-4 sm:mt-0 flex gap-3"> <div class="mt-4 sm:mt-0 flex gap-3">
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %> <%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<% if @application.oidc? %>
<%= link_to "Manage Roles", roles_admin_application_path(@application), class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" %>
<% end %>
<%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %> <%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
</div> </div>
</div> </div>
@@ -44,8 +47,8 @@
<% case @application.app_type %> <% case @application.app_type %>
<% when "oidc" %> <% 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> <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 "forward_auth" %> <% when "saml" %>
<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> <span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
<% end %> <% end %>
</dd> </dd>
</div> </div>
@@ -59,16 +62,6 @@
<% end %> <% end %>
</dd> </dd>
</div> </div>
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-gray-500">Landing URL</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @application.landing_url.present? %>
<%= link_to @application.landing_url, @application.landing_url, target: "_blank", rel: "noopener noreferrer", class: "text-blue-600 hover:text-blue-800 underline" %>
<% else %>
<span class="text-gray-400 italic">Not configured</span>
<% end %>
</dd>
</div>
</dl> </dl>
</div> </div>
</div> </div>
@@ -116,35 +109,6 @@
</div> </div>
<% end %> <% 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 --> <!-- Group Access Control -->
<div class="bg-white shadow sm:rounded-lg"> <div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">

View File

@@ -0,0 +1,126 @@
<% 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 class="col-span-full">
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
HTTP Headers Configuration
</div>
<div class="mt-2 space-y-4">
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
<div>
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-User" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
</div>
<div>
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Email" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
</div>
<div>
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Name" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
</div>
<div>
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Groups" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
</div>
<div>
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Admin" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
</div>
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
<li>• <strong>Custom:</strong> Use application-specific headers</li>
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div 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>

View File

@@ -0,0 +1,68 @@
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-gray-900">Forward Auth Rules</h1>
<p class="mt-2 text-sm text-gray-700">Manage forward authentication rules for domain-based access control.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<%= link_to "New Rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Domain Pattern</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Headers</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<% @forward_auth_rules.each do |rule| %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
<%= link_to rule.domain_pattern, admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if rule.headers_config.blank? %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
<% elsif rule.headers_config.values.all?(&:blank?) %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if rule.allowed_groups.empty? %>
<span class="text-gray-400">All users</span>
<% else %>
<%= rule.allowed_groups.count %> groups
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if rule.active? %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
<% end %>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<div class="flex justify-end space-x-3">
<%= link_to "View", admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= button_to "Delete", admin_forward_auth_rule_path(rule), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this forward auth rule?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>

View File

@@ -0,0 +1,126 @@
<% 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 class="col-span-full">
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
HTTP Headers Configuration
</div>
<div class="mt-2 space-y-4">
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
<div>
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-User" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
</div>
<div>
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Email" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
</div>
<div>
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Name" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
</div>
<div>
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Groups" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
</div>
<div>
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
<div class="mt-2">
<%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
placeholder: "Remote-Admin" %>
</div>
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
</div>
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
<li>• <strong>Custom:</strong> Use application-specific headers</li>
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div 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>

View File

@@ -0,0 +1,116 @@
<div class="mb-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900"><%= @forward_auth_rule.domain_pattern %></h1>
<p class="mt-1 text-sm text-gray-500">Forward authentication rule for domain-based access control</p>
</div>
<div class="mt-4 sm:mt-0 flex gap-3">
<%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<%= button_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
</div>
</div>
</div>
<div class="space-y-6">
<!-- Basic Information -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Basic Information</h3>
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
<dd class="mt-1 text-sm text-gray-900"><code class="bg-gray-100 px-2 py-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Status</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @forward_auth_rule.active? %>
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
<% end %>
</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 @forward_auth_rule.headers_config.blank? %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
<% elsif @forward_auth_rule.headers_config.values.all?(&:blank?) %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
<% else %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
<% end %>
</dd>
</div>
</dl>
</div>
</div>
<!-- Header Configuration -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Header Configuration</h3>
<div class="space-y-4">
<% effective_headers = @forward_auth_rule.effective_headers %>
<% if effective_headers.empty? %>
<div class="rounded-md bg-gray-50 p-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-gray-700">
No headers configured - access control only.
</p>
</div>
</div>
</div>
<% else %>
<dl class="space-y-4">
<% effective_headers.each do |key, header_name| %>
<div>
<dt class="text-sm font-medium text-gray-500"><%= key.to_s.capitalize %></dt>
<dd class="mt-1 text-sm text-gray-900">
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= header_name %></code>
</dd>
</div>
<% end %>
</dl>
<% end %>
</div>
</div>
</div>
<!-- Group Access Control -->
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Access Control</h3>
<div>
<dt class="text-sm font-medium text-gray-500 mb-2">Allowed Groups</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @allowed_groups.empty? %>
<div class="rounded-md bg-blue-50 p-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-blue-700">
No groups assigned - all active users can access this domain.
</p>
</div>
</div>
</div>
<% else %>
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
<% @allowed_groups.each do |group| %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900"><%= group.name %></p>
<p class="text-xs text-gray-500"><%= pluralize(group.users.count, "member") %></p>
</div>
</li>
<% end %>
</ul>
<% end %>
</dd>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,22 @@
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %> <%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
<%= render "shared/form_errors", form: form %> <% if group.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% group.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> <%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -32,27 +49,6 @@
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p> <p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
</div> </div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= 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"]}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div>
<div class="flex gap-3"> <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" %> <%= 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" %> <%= 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" %>

View File

@@ -1,17 +1,28 @@
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %> <%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
<%= render "shared/form_errors", form: form %> <% if user.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div> <div>
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %> <%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %> <%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
</div> </div>
<div>
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
<p class="mt-1 text-sm text-gray-500">Optional: Name shown in applications. Defaults to email address if not set.</p>
</div>
<div> <div>
<%= form.label :password, class: "block text-sm font-medium text-gray-700" %> <%= form.label :password, class: "block text-sm font-medium text-gray-700" %>
<%= form.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: user.persisted? ? "Leave blank to keep current password" : "Enter password" %> <%= form.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: user.persisted? ? "Leave blank to keep current password" : "Enter password" %>
@@ -35,27 +46,6 @@
<% end %> <% end %>
</div> </div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= 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"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div>
<div class="flex gap-3"> <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" %> <%= 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" %> <%= 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" %>

View File

@@ -93,64 +93,6 @@
<% end %> <% end %>
</div> </div>
<!-- Your Applications Section -->
<div class="mt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Your Applications</h2>
<% if @applications.any? %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<% @applications.each do |app| %>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<%= app.name %>
</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<% if app.oidc? %>
bg-blue-100 text-blue-800
<% else %>
bg-green-100 text-green-800
<% end %>">
<%= app.app_type.humanize %>
</span>
</div>
<p class="text-sm text-gray-600 mb-4">
<% if app.oidc? %>
OIDC Application
<% else %>
ForwardAuth Protected Application
<% end %>
</p>
<% if app.landing_url.present? %>
<%= link_to "Open Application", app.landing_url,
target: "_blank",
rel: "noopener noreferrer",
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
<% else %>
<div class="text-sm text-gray-500 italic">
No landing URL configured
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No applications available</h3>
<p class="mt-2 text-sm text-gray-500">
You don't have access to any applications yet. Contact your administrator if you think this is an error.
</p>
</div>
<% end %>
</div>
<% if @user.admin? %> <% if @user.admin? %>
<div class="mt-8"> <div class="mt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Admin Quick Actions</h2> <h2 class="text-xl font-semibold text-gray-900 mb-4">Admin Quick Actions</h2>

View File

@@ -25,29 +25,24 @@
<body> <body>
<% if authenticated? %> <% if authenticated? %>
<div data-controller="mobile-sidebar"> <%= render "shared/sidebar" %>
<%= render "shared/sidebar" %> <div class="lg:pl-64">
<div class="lg:pl-64"> <!-- Mobile menu button -->
<!-- Mobile menu button --> <div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden"> <button type="button" class="-m-2.5 p-2.5 text-gray-700" id="mobile-menu-button">
<button type="button" <span class="sr-only">Open sidebar</span>
class="-m-2.5 p-2.5 text-gray-700" <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
id="mobile-menu-button" <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
data-action="click->mobile-sidebar#openSidebar"> </svg>
<span class="sr-only">Open sidebar</span> </button>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">
<%= render "shared/flash" %>
<%= yield %>
</div>
</main>
</div> </div>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">
<%= render "shared/flash" %>
<%= yield %>
</div>
</main>
</div> </div>
<% else %> <% else %>
<!-- Public layout (signup/signin) --> <!-- Public layout (signup/signin) -->
@@ -57,5 +52,23 @@
</main> </main>
<% end %> <% end %>
<script>
// Mobile sidebar toggle
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenuClose = document.getElementById('mobile-menu-close');
const mobileSidebarOverlay = document.getElementById('mobile-sidebar-overlay');
if (mobileMenuButton) {
mobileMenuButton.addEventListener('click', () => {
mobileSidebarOverlay?.classList.remove('hidden');
});
}
if (mobileMenuClose) {
mobileMenuClose.addEventListener('click', () => {
mobileSidebarOverlay?.classList.add('hidden');
});
}
</script>
</body> </body>
</html> </html>

View File

@@ -5,7 +5,7 @@
<h1 class="font-bold text-4xl">Forgot your password?</h1> <h1 class="font-bold text-4xl">Forgot your password?</h1>
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %> <%= form_with url: passwords_path, class: "contents" do |form| %>
<div class="my-5"> <div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], 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" %> <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], 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>

View File

@@ -1,4 +1,4 @@
<div class="space-y-8" data-controller="modal"> <div class="space-y-8">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1> <h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p> <p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
@@ -102,16 +102,10 @@
</div> </div>
</div> </div>
<div class="mt-4 flex gap-3"> <div class="mt-4 flex gap-3">
<button type="button" <button type="button" onclick="showDisable2FAModal()" 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">
data-action="click->modal#show"
data-modal-id="disable-2fa-modal"
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">
Disable 2FA Disable 2FA
</button> </button>
<button type="button" <button type="button" onclick="showViewBackupCodesModal()" class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
data-action="click->modal#show"
data-modal-id="view-backup-codes-modal"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
View Backup Codes View Backup Codes
</button> </button>
</div> </div>
@@ -125,9 +119,7 @@
</div> </div>
<!-- Disable 2FA Modal --> <!-- Disable 2FA Modal -->
<div id="disable-2fa-modal" <div id="disable-2fa-modal" class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full"> <div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"> <div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
@@ -151,9 +143,7 @@
<div class="mt-4 flex gap-3"> <div class="mt-4 flex gap-3">
<%= form.submit "Disable 2FA", <%= form.submit "Disable 2FA",
class: "inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" %> class: "inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" %>
<button type="button" <button type="button" onclick="hideDisable2FAModal()" class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
data-action="click->modal#hide"
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Cancel Cancel
</button> </button>
</div> </div>
@@ -163,27 +153,15 @@
</div> </div>
</div> </div>
<!-- Regenerate Backup Codes Modal --> <!-- View Backup Codes Modal -->
<div id="view-backup-codes-modal" <div id="view-backup-codes-modal" class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full"> <div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
<div> <div>
<h3 class="text-lg font-medium leading-6 text-gray-900">Generate New Backup Codes</h3> <h3 class="text-lg font-medium leading-6 text-gray-900">View Backup Codes</h3>
<div class="mt-2"> <div class="mt-2">
<p class="text-sm text-gray-500">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p> <p class="text-sm text-gray-500">Enter your password to view your backup codes.</p>
</div> </div>
<div class="mt-3 p-3 bg-yellow-50 rounded-md"> <%= form_with url: verify_password_totp_path, method: :post, class: "mt-4" do |form| %>
<div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<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>
<p class="text-sm text-yellow-800">
<strong>Important:</strong> Save the new codes immediately after generation. You won't be able to see them again without regenerating.
</p>
</div>
</div>
<%= form_with url: create_new_backup_codes_totp_path, method: :post, class: "mt-4" do |form| %>
<div> <div>
<%= password_field_tag :password, nil, <%= password_field_tag :password, nil,
placeholder: "Enter your password", placeholder: "Enter your password",
@@ -192,11 +170,9 @@
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div> </div>
<div class="mt-4 flex gap-3"> <div class="mt-4 flex gap-3">
<%= form.submit "Generate New Codes", <%= form.submit "View Codes",
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
<button type="button" <button type="button" onclick="hideViewBackupCodesModal()" class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
data-action="click->modal#hide"
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Cancel Cancel
</button> </button>
</div> </div>
@@ -205,123 +181,125 @@
</div> </div>
</div> </div>
<!-- Passkeys (WebAuthn) --> <script>
function showDisable2FAModal() {
document.getElementById('disable-2fa-modal').classList.remove('hidden');
}
function hideDisable2FAModal() {
document.getElementById('disable-2fa-modal').classList.add('hidden');
}
function showViewBackupCodesModal() {
document.getElementById('view-backup-codes-modal').classList.remove('hidden');
}
function hideViewBackupCodesModal() {
document.getElementById('view-backup-codes-modal').classList.add('hidden');
}
</script>
<!-- Connected Applications -->
<div class="bg-white shadow sm:rounded-lg"> <div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6" data-controller="webauthn" data-webauthn-challenge-url-value="/webauthn/challenge" data-webauthn-create-url-value="/webauthn/create"> <div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Passkeys</h3> <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"> <div class="mt-2 max-w-xl text-sm text-gray-500">
<p>Use your fingerprint, face recognition, or security key to sign in without passwords.</p> <p>These applications have access to your account. You can revoke access at any time.</p>
</div> </div>
<!-- Add Passkey Form -->
<div class="mt-5"> <div class="mt-5">
<div id="add-passkey-form" class="space-y-4"> <% if @connected_applications.any? %>
<div> <ul role="list" class="divide-y divide-gray-200">
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700">Passkey Name</label> <% @connected_applications.each do |consent| %>
<input type="text" <li class="py-4">
id="passkey-nickname" <div class="flex items-center justify-between">
data-webauthn-target="nickname" <div class="flex flex-col">
placeholder="e.g., MacBook Touch ID, iPhone Face ID" <p class="text-sm font-medium text-gray-900">
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"> <%= consent.application.name %>
<p class="mt-1 text-sm text-gray-500">Give this passkey a memorable name so you can identify it later.</p> </p>
</div> <p class="mt-1 text-sm text-gray-500">
Access to: <%= consent.formatted_scopes %>
<div> </p>
<button type="button" <p class="mt-1 text-xs text-gray-400">
data-action="click->webauthn#register" Authorized <%= time_ago_in_words(consent.granted_at) %> ago
data-webauthn-target="submitButton" </p>
class="inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add New Passkey
</button>
</div>
<!-- Status Messages -->
<div data-webauthn-target="status" class="hidden mt-2 p-3 rounded-md text-sm"></div>
<div data-webauthn-target="error" class="hidden mt-2 p-3 rounded-md text-sm"></div>
</div>
</div>
<!-- Existing Passkeys List -->
<div class="mt-8">
<h4 class="text-md font-medium text-gray-900 mb-4">Your Passkeys</h4>
<% if @user.webauthn_credentials.exists? %>
<div class="space-y-3">
<% @user.webauthn_credentials.order(created_at: :desc).each do |credential| %>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
<% if credential.platform_authenticator? %>
<!-- Platform authenticator icon -->
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<% else %>
<!-- Roaming authenticator icon -->
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
<% end %>
</div>
<div>
<div class="text-sm font-medium text-gray-900">
<%= credential.nickname %>
</div>
<div class="text-sm text-gray-500">
<%= credential.authenticator_type.humanize %> •
Last used <%= credential.last_used_ago %>
<% if credential.backed_up? %>
• <span class="text-green-600">Synced</span>
<% end %>
</div>
</div> </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> </div>
<div class="flex items-center space-x-2"> </li>
<% if credential.created_recently? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
New
</span>
<% end %>
<%= link_to webauthn_credential_path(credential),
method: :delete,
data: {
confirm: "Are you sure you want to delete '#{credential.nickname}'? You'll need to set it up again to sign in with this device.",
turbo_method: :delete
},
class: "text-red-600 hover:text-red-800 text-sm font-medium" do %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
<% end %>
</div>
</div>
<% end %> <% end %>
</div> </ul>
<div class="mt-4 p-3 bg-blue-50 rounded-lg">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<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">
<p class="text-sm text-blue-800">
<strong>Tip:</strong> Add passkeys on multiple devices for easy access. Platform authenticators (like Touch ID) are synced across your devices if you use iCloud Keychain or Google Password Manager.
</p>
</div>
</div>
</div>
<% else %> <% else %>
<div class="text-center py-8"> <p class="text-sm text-gray-500">No connected applications.</p>
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <% end %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path> </div>
</svg> </div>
<h3 class="mt-2 text-sm font-medium text-gray-900">No passkeys</h3> </div>
<p class="mt-1 text-sm text-gray-500">Get started by adding your first passkey for passwordless sign-in.</p>
</div> <!-- Active Sessions -->
<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">Active Sessions</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500">
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
</div>
<div class="mt-5">
<% if @active_sessions.any? %>
<ul role="list" class="divide-y divide-gray-200">
<% @active_sessions.each do |session| %>
<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">
<%= session.device_name || "Unknown Device" %>
<% if session.id == Current.session.id %>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
This device
</span>
<% end %>
</p>
<p class="mt-1 text-sm text-gray-500">
<%= session.ip_address %>
</p>
<p class="mt-1 text-xs text-gray-400">
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
</p>
</div>
<% if session.id != Current.session.id %>
<%= button_to "Revoke", session_path(session), method: :delete,
class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
<% end %>
</div>
</li>
<% end %>
</ul>
<% else %>
<p class="text-sm text-gray-500">No other active sessions.</p>
<% end %>
</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 %> <% end %>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,9 @@
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check"> <div class="mx-auto md:w-2/3 w-full">
<div class="mb-8"> <div class="mb-8">
<h1 class="font-bold text-4xl">Sign in to Clinch</h1> <h1 class="font-bold text-4xl">Sign in to Clinch</h1>
</div> </div>
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %> <%= form_with url: signin_path, class: "contents" do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %> <%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>
@@ -13,67 +13,26 @@
autocomplete: "username", autocomplete: "username",
placeholder: "your@email.com", placeholder: "your@email.com",
value: params[:email_address], value: params[:email_address],
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div> </div>
<!-- WebAuthn section - initially hidden --> <div class="my-5">
<div id="webauthn-section" data-login-form-target="webauthnSection" class="my-5 hidden"> <%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4"> <%= form.password_field :password,
<div class="flex items-center"> required: true,
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> autocomplete: "current-password",
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> placeholder: "Enter your password",
</svg> maxlength: 72,
<p class="text-sm text-green-800"> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
<strong>Passkey detected!</strong> You can sign in without a password.
</p>
</div>
</div>
<button type="button"
data-action="click->webauthn#authenticate"
class="w-full rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
Continue with Passkey
</button>
</div> </div>
<!-- Password section - shown by default, hidden if WebAuthn is required --> <div class="my-5">
<div id="password-section" data-login-form-target="passwordSection"> <%= form.submit "Sign in",
<div class="my-5"> class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
<%= form.password_field :password,
required: true,
autocomplete: "current-password",
placeholder: "Enter your password",
maxlength: 72,
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>
<div class="my-5">
<%= form.submit "Sign in",
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
</div>
</div> </div>
<div class="mt-4 text-sm text-gray-600 text-center"> <div class="mt-4 text-sm text-gray-600 text-center">
<%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %> <%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
</div> </div>
<% end %> <% end %>
<!-- Loading overlay -->
<div id="loading-overlay" data-login-form-target="loadingOverlay" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 flex items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Authenticating...</span>
</div>
</div>
<!-- Status messages -->
<div id="status-message" data-login-form-target="statusMessage" class="hidden mt-4 p-3 rounded-md"></div>
</div> </div>

View File

@@ -7,10 +7,7 @@
</p> </p>
</div> </div>
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6", data: { <%= form_with url: totp_verification_path, method: :post, class: "space-y-6" do |form| %>
controller: "form-submit-protection",
turbo: false
} do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %> <%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div> <div>
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %> <%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
@@ -29,7 +26,6 @@
<div> <div>
<%= form.submit "Verify", <%= form.submit "Verify",
data: { form_submit_protection_target: "submit" },
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
</div> </div>
<% end %> <% end %>

View File

@@ -1,86 +1,29 @@
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %> <% if flash[:alert] %>
<% flash.each do |type, message| %> <div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
<% next if message.blank? %>
<%
# Map flash types to styling
case type.to_s
when 'notice'
bg_class = 'bg-green-50'
text_class = 'text-green-800'
icon_class = 'text-green-400'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
auto_dismiss = true
when 'alert', 'error'
bg_class = 'bg-red-50'
text_class = 'text-red-800'
icon_class = 'text-red-400'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
auto_dismiss = false
when 'warning'
bg_class = 'bg-yellow-50'
text_class = 'text-yellow-800'
icon_class = 'text-yellow-400'
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
auto_dismiss = false
when 'info'
bg_class = 'bg-blue-50'
text_class = 'text-blue-800'
icon_class = 'text-blue-400'
icon_path = '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'
auto_dismiss = true
else
# Default styling for unknown types
bg_class = 'bg-gray-50'
text_class = 'text-gray-800'
icon_class = 'text-gray-400'
icon_path = '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'
auto_dismiss = false
end
%>
<div class="mb-4 rounded-lg <%= bg_class %> p-4 border border-opacity-20 <%= border_class_for(type) %>"
role="alert"
data-controller="flash"
data-flash-auto-dismiss-value="<%= auto_dismiss ? '5000' : 'false' %>"
data-flash-type-value="<%= type %>">
<div class="flex"> <div class="flex">
<div class="shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg> </svg>
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3">
<p class="text-sm font-medium <%= text_class %>"><%= message %></p> <p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
</div> </div>
<% if auto_dismiss || type.to_s != 'alert' %>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button"
data-action="click->flash#dismiss"
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>"
aria-label="Dismiss">
<span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
</svg>
</button>
</div>
</div>
<% end %>
</div> </div>
</div> </div>
<% end %> <% end %>
<%# Helper method for border colors %> <% if flash[:notice] %>
<% <div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
def border_class_for(type) <div class="flex">
case type.to_s <div class="flex-shrink-0">
when 'notice' then 'border-green-200' <svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
when 'alert', 'error' then 'border-red-200' <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
when 'warning' then 'border-yellow-200' </svg>
when 'info' then 'border-blue-200' </div>
else 'border-gray-200' <div class="ml-3">
end <p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
end </div>
%> </div>
</div>
<% end %>

View File

@@ -1,36 +1,23 @@
<%# Usage: <%= render "shared/form_errors", object: @user %> %> <% if form.object.errors.any? %>
<%# Usage: <%= render "shared/form_errors", form: form %> %> <div class="rounded-md bg-red-50 p-4">
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
<% if form_object&.errors&.any? %>
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
</svg> </svg>
</div> </div>
<div class="ml-3 flex-1"> <div class="ml-3">
<h3 id="form-errors-title" class="text-sm font-medium text-red-800"> <h3 class="text-sm font-medium text-red-800">
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved: There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
</h3> </h3>
<div class="mt-2"> <div class="mt-2 text-sm text-red-700">
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700"> <ul class="list-disc space-y-1 pl-5">
<% form_object.errors.full_messages.each do |message| %> <% form.object.errors.full_messages.each do |message| %>
<li><%= message %></li> <li><%= message %></li>
<% end %> <% end %>
</ul> </ul>
</div> </div>
</div> </div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -57,6 +57,16 @@
<% end %> <% end %>
</li> </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 --> <!-- Admin: Groups -->
<li> <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 %> <%= 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 %>
@@ -78,19 +88,9 @@
<% end %> <% end %>
</li> </li>
<!-- Sessions -->
<li>
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? '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 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
</svg>
Sessions
<% end %>
</li>
<!-- Sign Out --> <!-- Sign Out -->
<li> <li>
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %> <%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <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="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>
@@ -105,18 +105,12 @@
</div> </div>
<!-- Mobile sidebar overlay --> <!-- Mobile sidebar overlay -->
<div class="relative z-50 lg:hidden hidden" <div class="relative z-50 lg:hidden hidden" id="mobile-sidebar-overlay">
data-mobile-sidebar-target="sidebarOverlay"
id="mobile-sidebar-overlay"
data-action="click->mobile-sidebar#closeOnBackgroundClick">
<div class="fixed inset-0 bg-gray-900/80"></div> <div class="fixed inset-0 bg-gray-900/80"></div>
<div class="fixed inset-0 flex"> <div class="fixed inset-0 flex">
<div class="relative mr-16 flex w-full max-w-xs flex-1"> <div class="relative mr-16 flex w-full max-w-xs flex-1">
<div class="absolute left-full top-0 flex w-16 justify-center pt-5"> <div class="absolute left-full top-0 flex w-16 justify-center pt-5">
<button type="button" <button type="button" class="-m-2.5 p-2.5" id="mobile-menu-close">
class="-m-2.5 p-2.5"
id="mobile-menu-close"
data-action="click->mobile-sidebar#closeSidebar">
<span class="sr-only">Close sidebar</span> <span class="sr-only">Close sidebar</span>
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -144,7 +138,7 @@
<!-- Same nav items as desktop --> <!-- Same nav items as desktop -->
<ul role="list" class="-mx-2 space-y-1"> <ul role="list" class="-mx-2 space-y-1">
<li> <li>
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to root_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"> <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="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg> </svg>
@@ -153,7 +147,7 @@
</li> </li>
<% if user.admin? %> <% if user.admin? %>
<li> <li>
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to admin_users_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"> <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="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg> </svg>
@@ -161,7 +155,7 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to admin_applications_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"> <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="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg> </svg>
@@ -169,16 +163,24 @@
<% end %> <% end %>
</li> </li>
<li> <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' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to admin_groups_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"> <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="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg> </svg>
Groups Groups
<% end %> <% end %>
</li> </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 %> <% end %>
<li> <li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= 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 %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <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="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
@@ -186,15 +188,7 @@
<% end %> <% end %>
</li> </li>
<li> <li>
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-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 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
</svg>
Sessions
<% end %>
</li>
<li>
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <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="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg> </svg>

View File

@@ -1,4 +1,4 @@
<div class="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>"> <div class="max-w-2xl mx-auto">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1> <h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600">
@@ -29,14 +29,14 @@
</div> </div>
<div class="mt-6 flex gap-3"> <div class="mt-6 flex gap-3">
<button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> <button onclick="downloadBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg> </svg>
Download Codes Download Codes
</button> </button>
<button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> <button onclick="printBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg> </svg>
@@ -52,3 +52,27 @@
</div> </div>
</div> </div>
<script>
const backupCodes = <%= raw @backup_codes.to_json %>;
function downloadBackupCodes() {
const content = "Clinch Backup Codes\n" +
"===================\n\n" +
backupCodes.join("\n") +
"\n\nSave these codes in a secure location.";
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'clinch-backup-codes.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
function printBackupCodes() {
window.print();
}
</script>

View File

@@ -1,45 +0,0 @@
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Regenerate Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600">
This will invalidate all existing backup codes and generate new ones.
</p>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="rounded-md bg-yellow-50 p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<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 class="text-sm text-yellow-800">
<p class="font-medium">Important Security Notice</p>
<p class="mt-1">All your current backup codes will become invalid after this action. Make sure you're ready to save the new codes.</p>
</div>
</div>
</div>
<%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %>
<div>
<%= form.label :password, "Enter your password to confirm", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1">
<%= form.password_field :password, required: true,
class: "block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" %>
</div>
<p class="mt-2 text-sm text-gray-500">
This is required to verify your identity before regenerating backup codes.
</p>
</div>
<div class="flex gap-3">
<%= form.submit "Generate New Backup Codes",
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
<%= link_to "Cancel", profile_path,
class: "inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -4,8 +4,17 @@
<p class="mt-2 text-gray-600">Create your admin account to get started</p> <p class="mt-2 text-gray-600">Create your admin account to get started</p>
</div> </div>
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %> <%= form_with model: @user, url: signup_path, class: "contents" do |form| %>
<%= render "shared/form_errors", form: form %> <% if @user.errors.any? %>
<div class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:</h2>
<ul class="list-disc list-inside">
<% @user.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="my-5"> <div class="my-5">
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %> <%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>

View File

@@ -83,14 +83,4 @@ Rails.application.configure do
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`. # Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
# config.generators.apply_rubocop_autocorrect_after_generate! # config.generators.apply_rubocop_autocorrect_after_generate!
# Sentry configuration for development
# Only enabled if SENTRY_DSN environment variable is set and explicitly enabled
if ENV["SENTRY_DSN"].present? && ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
config.sentry.enabled = true
# High sample rates for development debugging
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.5).to_f
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.2).to_f
end
end end

View File

@@ -81,70 +81,11 @@ Rails.application.configure do
config.active_record.attributes_for_inspect = [ :id ] config.active_record.attributes_for_inspect = [ :id ]
# Enable DNS rebinding protection and other `Host` header attacks. # Enable DNS rebinding protection and other `Host` header attacks.
# Configure allowed hosts based on deployment scenario # config.hosts = [
allowed_hosts = [ # "example.com", # Allow requests from example.com
ENV.fetch('CLINCH_HOST', 'auth.example.com'), # External domain (auth service itself) # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
] # ]
#
# 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. # 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" } }
# Sentry configuration for production
# Only enabled if SENTRY_DSN environment variable is set
if ENV["SENTRY_DSN"].present?
config.sentry.enabled = true
# Performance monitoring: sample 20% of transactions for traces
# Adjust based on your traffic volume and Sentry plan limits
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
# Continuous profiling: disabled by default in production due to cost
# Enable temporarily for performance investigations if needed
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
end
end end

View File

@@ -50,8 +50,4 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions. # Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true
# Disable Sentry in test environment to avoid interference with tests
# Sentry can be explicitly enabled for integration testing if needed
config.sentry.enabled = false
end end

View File

@@ -4,62 +4,26 @@
# See the Securing Rails Applications Guide for more information: # See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header # https://guides.rubyonrails.org/security.html#content-security-policy-header
Rails.application.configure do # Rails.application.configure do
config.content_security_policy do |policy| # config.content_security_policy do |policy|
# Default to self for everything, plus blob: for file downloads # policy.default_src :self, :https
policy.default_src :self, "blob:" # policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads # policy.object_src :none
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation # policy.script_src :self, :https
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:" # policy.style_src :self, :https
# # Specify URI for violation reports
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes # # policy.report_uri "/csp-violation-report-endpoint"
# and Stimulus controller style manipulations # end
policy.style_src :self, :unsafe_inline #
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# Images: Allow self, data URLs, and https for external images # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
policy.img_src :self, :data, :https # config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# Fonts: Allow self and data URLs # # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
policy.font_src :self, :data # # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
# Connect: Allow self for API calls, WebAuthn, and ActionCable if needed #
# WebAuthn endpoints are on the same domain, so self is sufficient # # Report violations without enforcing the policy.
policy.connect_src :self, "wss:" # # config.content_security_policy_report_only = true
# end
# Media: Allow self
policy.media_src :self
# Object and embed sources: Disallow for security (no Flash/etc)
policy.object_src :none
policy.frame_src :none
policy.frame_ancestors :none
# Base URI: Restricted to self
policy.base_uri :self
# Form actions: Allow self for all form submissions
policy.form_action :self
# Manifest sources: Allow self for PWA manifest
policy.manifest_src :self
# Worker sources: Allow self for potential Web Workers
policy.worker_src :self
# Child sources: Allow self for any future iframes
policy.child_src :self
# Additional security headers for WebAuthn
# Required for WebAuthn to work properly
policy.require_trusted_types_for :none
policy.report_uri "/api/csp-violation-report"
end
# Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development?
# Report CSP violations (optional - uncomment to enable)
# config.content_security_policy_report_uri = "/csp-violations"
end

View File

@@ -1,122 +0,0 @@
# Local file logger for CSP violations
# Provides local logging even when Sentry is not configured
Rails.application.config.after_initialize do
# Create a dedicated logger for CSP violations
csp_log_path = Rails.root.join("log", "csp_violations.log")
# Configure log rotation
csp_logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
30 # Keep 30 old log files
)
csp_logger.level = Logger::INFO
# Format: [TIMESTAMP] LEVEL MESSAGE
csp_logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
end
module CspViolationLocalLogger
def self.emit(event)
csp_data = event[:payload] || {}
# Build a structured log message
violated_directive = csp_data[:violated_directive] || "unknown"
blocked_uri = csp_data[:blocked_uri] || "unknown"
document_uri = csp_data[:document_uri] || "unknown"
# Create a comprehensive log entry
log_message = "CSP VIOLATION DETECTED\n"
log_message += " Directive: #{violated_directive}\n"
log_message += " Blocked URI: #{blocked_uri}\n"
log_message += " Document URI: #{document_uri}\n"
log_message += " User Agent: #{csp_data[:user_agent]}\n"
log_message += " IP Address: #{csp_data[:ip_address]}\n"
log_message += " Timestamp: #{csp_data[:timestamp]}\n"
if csp_data[:current_user_id].present?
log_message += " Authenticated User ID: #{csp_data[:current_user_id]}\n"
log_message += " Session ID: #{csp_data[:session_id]}\n"
else
log_message += " User: Anonymous\n"
end
# Add additional details if available
if csp_data[:source_file].present?
log_message += " Source File: #{csp_data[:source_file]}"
log_message += ":#{csp_data[:line_number]}" if csp_data[:line_number].present?
log_message += ":#{csp_data[:column_number]}" if csp_data[:column_number].present?
log_message += "\n"
end
if csp_data[:referrer].present?
log_message += " Referrer: #{csp_data[:referrer]}\n"
end
# Determine severity for log level
level = determine_log_level(csp_data[:violated_directive])
self.csp_logger.log(level, log_message)
# Also log to main Rails logger for visibility
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
rescue => e
# Ensure logger errors don't break the CSP reporting flow
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
end
def self.csp_logger
@csp_logger ||= begin
csp_log_path = Rails.root.join("log", "csp_violations.log")
logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
30 # Keep 30 old log files
)
logger.level = Logger::INFO
logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
end
logger
end
end
private
def self.determine_log_level(violated_directive)
return Logger::INFO unless violated_directive.present?
case violated_directive.to_sym
when :script_src, :script_src_elem, :script_src_attr, :frame_src, :child_src
Logger::WARN # Higher priority violations
when :connect_src, :default_src, :style_src, :style_src_elem, :style_src_attr
Logger::INFO # Medium priority violations
else
Logger::DEBUG # Lower priority violations
end
end
end
# Register the local logger subscriber
Rails.event.subscribe(CspViolationLocalLogger)
Rails.logger.info "CSP violation local logger registered - logging to: #{csp_log_path}"
# Ensure the log file is created and writable
begin
# Create log file if it doesn't exist
FileUtils.touch(csp_log_path) unless File.exist?(csp_log_path)
# Test write to ensure permissions are correct
csp_logger.info "CSP Logger initialized at #{Time.current}"
rescue => e
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
end
end

View File

@@ -1,140 +0,0 @@
# Sentry configuration for error tracking and performance monitoring
# Only initializes if SENTRY_DSN environment variable is set
return unless ENV["SENTRY_DSN"].present?
Rails.application.configure do
config.sentry.dsn = ENV["SENTRY_DSN"]
# Set environment (defaults to Rails.env)
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
# Set release version from Git or environment variable
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil
# Sample rate for performance monitoring (0.0 to 1.0)
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
# Enable profiling in development/staging, disable in production unless explicitly enabled
config.sentry.profiles_sample_rate = if Rails.env.production?
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
end
# Include additional context
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
event
end
# Include breadcrumbs for debugging
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Send session data for user context
config.sentry.user_context = lambda do
if Current.user.present?
{
id: Current.user.id,
email: Current.user.email_address,
admin: Current.user.admin?
}
end
end
# Ignore common non-critical exceptions
config.sentry.excluded_exceptions += [
"ActionController::RoutingError",
"ActionController::InvalidAuthenticityToken",
"ActionController::UnknownFormat",
"ActionDispatch::Http::Parameters::ParseError",
"Rack::QueryParser::InvalidParameterError",
"Rack::Timeout::RequestTimeoutException",
"ActiveRecord::RecordNotFound"
]
# Add CSP-specific tags for security events
config.sentry.tags = lambda do
{
# Add application context
app_name: "clinch",
app_environment: Rails.env,
# Add CSP policy status
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
Rails.application.config.content_security_policy.present?
}
end
# Enhance before_send to handle CSP events properly
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
# Special handling for CSP violations
if event.tags&.dig(:csp_violation)
# Ensure CSP violations have proper security context
event.context[:server] = event.context[:server] || {}
event.context[:server][:name] = "clinch-auth-service"
event.context[:server][:environment] = Rails.env
# Add additional security context
event.context[:extra] ||= {}
event.context[:extra][:security_context] = {
csp_reporting: true,
user_authenticated: event.context[:user].present?,
request_origin: event.context[:request]&.dig(:headers, "Origin"),
request_referer: event.context[:request]&.dig(:headers, "Referer")
}
end
event
end
# Add CSP-specific breadcrumbs for security events
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint|
# Filter out sensitive breadcrumb data
if breadcrumb[:data]
breadcrumb[:data].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i) ||
value.to_s.match?(/password|secret/i)
}
end
# Mark CSP-related events
if breadcrumb[:message]&.include?("CSP Violation") ||
breadcrumb[:category]&.include?("csp")
breadcrumb[:data] ||= {}
breadcrumb[:data][:security_event] = true
breadcrumb[:data][:csp_violation] = true
end
breadcrumb
end
# Only send errors in production unless explicitly enabled
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
end

View File

@@ -1,120 +0,0 @@
# Sentry subscriber for CSP violations via Structured Event Reporting
# This subscriber only sends events to Sentry if Sentry is properly initialized
Rails.application.config.after_initialize do
# Only register the subscriber if Sentry is available and configured
if defined?(Sentry) && Sentry.initialized?
module CspViolationSentrySubscriber
def self.emit(event)
# Extract relevant CSP violation data
csp_data = event[:payload] || {}
# Build a descriptive message for Sentry
violated_directive = csp_data[:violated_directive]
blocked_uri = csp_data[:blocked_uri]
document_uri = csp_data[:document_uri]
message = "CSP Violation: #{violated_directive}"
message += " - Blocked: #{blocked_uri}" if blocked_uri.present?
message += " - On: #{document_uri}" if document_uri.present?
# Extract domain from blocked_uri for better classification
blocked_domain = extract_domain(blocked_uri) if blocked_uri.present?
# Determine severity based on violation type
level = determine_severity(violated_directive, blocked_uri)
# Send to Sentry with rich context
Sentry.capture_message(
message,
level: level,
tags: {
csp_violation: true,
violated_directive: violated_directive,
blocked_domain: blocked_domain,
document_domain: extract_domain(document_uri),
user_authenticated: csp_data[:current_user_id].present?
},
extra: {
# Full CSP report data
csp_violation_details: csp_data,
# Additional context for security analysis
request_context: {
user_agent: csp_data[:user_agent],
ip_address: csp_data[:ip_address],
session_id: csp_data[:session_id],
timestamp: csp_data[:timestamp]
}
},
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
)
# Log to Rails logger for redundancy
Rails.logger.info "CSP violation sent to Sentry: #{message}"
rescue => e
# Ensure subscriber errors don't break the CSP reporting flow
Rails.logger.error "Failed to send CSP violation to Sentry: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
end
private
# Extract domain from URI for better analysis
def self.extract_domain(uri)
return nil if uri.blank?
begin
parsed = URI.parse(uri)
parsed.host
rescue URI::InvalidURIError
# Handle cases where URI might be malformed or just a path
if uri.start_with?('/')
nil # It's a relative path, no domain
else
uri.split('/').first # Best effort extraction
end
end
end
# Determine severity level based on violation type
def self.determine_severity(violated_directive, blocked_uri)
return :warning unless violated_directive.present?
case violated_directive.to_sym
when :script_src, :script_src_elem, :script_src_attr
# Script violations are highest priority (XSS risk)
:error
when :style_src, :style_src_elem, :style_src_attr
# Style violations are moderate risk
:warning
when :img_src
# Image violations are typically lower priority
:info
when :connect_src
# Network violations are important
:warning
when :font_src, :media_src
# Font/media violations are lower priority
:info
when :frame_src, :child_src
# Frame violations can be security critical
:error
when :default_src
# Default src violations are important
:warning
else
# Unknown or custom directives
:warning
end
end
end
# Register the subscriber for CSP violation events
Rails.event.subscribe(CspViolationSentrySubscriber)
Rails.logger.info "CSP violation Sentry subscriber registered"
else
Rails.logger.info "Sentry not initialized - CSP violations will only be logged locally"
end
end

View File

@@ -1,54 +0,0 @@
# WebAuthn configuration for Clinch Identity Provider
WebAuthn.configure do |config|
# Relying Party name (displayed in authenticator prompts)
# For development, use http://localhost to match passkey in Passwords app
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
config.allowed_origins = [origin_host]
# Relying Party ID (must match origin domain)
# Extract domain from origin for RP ID
origin_uri = URI.parse(origin_host)
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
# For development, we also allow localhost with common ports and without port
if Rails.env.development?
config.allowed_origins += [
"http://localhost",
"http://localhost:3000",
"http://localhost:3035",
"http://127.0.0.1",
"http://127.0.0.1:3000",
"http://127.0.0.1:3035"
]
end
# Relying Party name shown in authenticator prompts
config.rp_name = ENV.fetch("CLINCH_RP_NAME", "Clinch Identity Provider")
# Credential timeout in milliseconds (60 seconds)
# Users have 60 seconds to complete the authentication ceremony
config.credential_options_timeout = 60_000
# Supported algorithms for credential creation
# ES256: ECDSA with P-256 and SHA-256 (most common, secure)
# RS256: RSASSA-PKCS1-v1_5 with SHA-256 (hardware keys often use this)
config.algorithms = ["ES256", "RS256"]
# Encoding for credential IDs and other data
config.encoding = :base64url
# Custom verifier for additional security checks if needed
# config.verifier = MyCustomVerifier.new
end
# Security note: WebAuthn requires HTTPS in production
# The WebAuthn API will not work on non-secure origins in production browsers
# Ensure CLINCH_HOST uses https:// in production environments
# Example environment variables:
# CLINCH_HOST=https://auth.example.com
# CLINCH_RP_ID=example.com
# CLINCH_RP_NAME="Example Company Identity Provider"
# CLINCH_WEBAUTHN_ATTESTATION=none
# CLINCH_WEBAUTHN_USER_VERIFICATION=preferred
# CLINCH_WEBAUTHN_RESIDENT_KEY=preferred

View File

@@ -31,7 +31,6 @@ threads threads_count, threads_count
# Specifies the `port` that Puma will listen on to receive requests; default is 3000. # Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000) port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command. # Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart plugin :tmp_restart

View File

@@ -19,10 +19,6 @@ Rails.application.routes.draw do
get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification
post "/totp-verification", to: "sessions#verify_totp" post "/totp-verification", to: "sessions#verify_totp"
# WebAuthn authentication routes
post "/sessions/webauthn/challenge", to: "sessions#webauthn_challenge"
post "/sessions/webauthn/verify", to: "sessions#webauthn_verify"
# OIDC (OpenID Connect) routes # OIDC (OpenID Connect) routes
get "/.well-known/openid-configuration", to: "oidc#discovery" get "/.well-known/openid-configuration", to: "oidc#discovery"
get "/.well-known/jwks.json", to: "oidc#jwks" get "/.well-known/jwks.json", to: "oidc#jwks"
@@ -35,7 +31,6 @@ Rails.application.routes.draw do
# ForwardAuth / Trusted Header SSO # ForwardAuth / Trusted Header SSO
namespace :api do namespace :api do
get "/verify", to: "forward_auth#verify" get "/verify", to: "forward_auth#verify"
post "/csp-violation-report", to: "csp#violation_report"
end end
# Authenticated routes # Authenticated routes
@@ -46,12 +41,6 @@ Rails.application.routes.draw do
delete :revoke_all_consents delete :revoke_all_consents
end end
end end
resource :active_sessions, only: [:show] do
member do
delete :revoke_consent
delete :revoke_all_consents
end
end
resources :sessions, only: [] do resources :sessions, only: [] do
member do member do
delete :destroy, action: :destroy_other delete :destroy, action: :destroy_other
@@ -64,15 +53,6 @@ Rails.application.routes.draw do
delete '/totp', to: 'totp#destroy' delete '/totp', to: 'totp#destroy'
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
# WebAuthn (Passkeys) routes
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
post '/webauthn/challenge', to: 'webauthn#challenge'
post '/webauthn/create', to: 'webauthn#create'
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
get '/webauthn/check', to: 'webauthn#check'
# Admin routes # Admin routes
namespace :admin do namespace :admin do
@@ -85,9 +65,15 @@ Rails.application.routes.draw do
resources :applications do resources :applications do
member do member do
post :regenerate_credentials post :regenerate_credentials
get :roles
post :create_role
patch :update_role
post :assign_role
post :remove_role
end end
end end
resources :groups resources :groups
resources :forward_auth_rules
end end
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)

View File

@@ -1,6 +0,0 @@
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

View File

@@ -1,10 +0,0 @@
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

View File

@@ -1,71 +0,0 @@
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

View File

@@ -1,15 +0,0 @@
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

View File

@@ -1,9 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
class AddNameToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :name, :string
end
end

View File

@@ -1,32 +0,0 @@
class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
def change
create_table :webauthn_credentials do |t|
# Reference to the user who owns this credential
t.references :user, null: false, foreign_key: true, index: true
# WebAuthn specification fields
t.string :external_id, null: false, index: { unique: true } # credential ID (base64)
t.string :public_key, null: false # public key (base64)
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
# Metadata
t.string :nickname # User-friendly name ("MacBook Touch ID")
t.string :authenticator_type # "platform" or "cross-platform"
t.boolean :backup_eligible, default: false # Can be backed up (passkey sync)
t.boolean :backup_state, default: false # Currently backed up
# Tracking
t.datetime :last_used_at
t.string :last_used_ip
t.string :user_agent # Browser/OS info
t.timestamps
end
# Add composite index for user-specific queries
add_index :webauthn_credentials, [:user_id, :external_id], unique: true
add_index :webauthn_credentials, [:user_id, :last_used_at]
add_index :webauthn_credentials, :authenticator_type
add_index :webauthn_credentials, :last_used_at
end
end

View File

@@ -1,16 +0,0 @@
class AddWebauthnToUsers < ActiveRecord::Migration[8.1]
def change
# WebAuthn user handle - stable, opaque identifier for the user
# Must be unique and never change once assigned
add_column :users, :webauthn_id, :string
add_index :users, :webauthn_id, unique: true
# Policy enforcement - whether this user MUST use WebAuthn
# Can be set by admins for high-security accounts
add_column :users, :webauthn_required, :boolean, default: false, null: false
# User preference for 2FA method (if both TOTP and WebAuthn are available)
# :totp, :webauthn, or nil for system default
add_column :users, :preferred_2fa_method, :string
end
end

View File

@@ -1,5 +0,0 @@
class AddLandingUrlToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :landing_url, :string
end
end

View File

@@ -1,13 +0,0 @@
class ClearExistingBackupCodes < ActiveRecord::Migration[8.1]
def up
# Clear all existing backup codes to force regeneration with BCrypt hashing
# This is a security migration to move from plain text to hashed storage
User.where.not(backup_codes: nil).update_all(backup_codes: nil)
end
def down
# This migration cannot be safely reversed
# as the original plain text codes cannot be recovered
raise ActiveRecord::IrreversibleMigration
end
end

View File

@@ -1,12 +0,0 @@
class ChangeBackupCodesToJson < ActiveRecord::Migration[8.1]
def up
# Change the column type from text to json
# This will automatically handle JSON serialization/deserialization
change_column :users, :backup_codes, :json
end
def down
# Revert back to text if needed
change_column :users, :backup_codes, :text
end
end

90
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do ActiveRecord::Schema[8.1].define(version: 2025_10_26_113035) do
create_table "application_groups", force: :cascade do |t| create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -21,6 +21,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.index ["group_id"], name: "index_application_groups_on_group_id" t.index ["group_id"], name: "index_application_groups_on_group_id"
end end
create_table "application_roles", force: :cascade do |t|
t.boolean "active", default: true
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.text "description"
t.string "display_name"
t.string "name", null: false
t.json "permissions", default: {}
t.datetime "updated_at", null: false
t.index ["application_id", "name"], name: "index_application_roles_on_application_id_and_name", unique: true
t.index ["application_id"], name: "index_application_roles_on_application_id"
end
create_table "applications", force: :cascade do |t| create_table "applications", force: :cascade do |t|
t.boolean "active", default: true, null: false t.boolean "active", default: true, null: false
t.string "app_type", null: false t.string "app_type", null: false
@@ -28,23 +41,40 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.string "client_secret_digest" t.string "client_secret_digest"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.text "description" t.text "description"
t.string "domain_pattern" t.json "managed_permissions", default: {}
t.json "headers_config", default: {}, null: false
t.string "landing_url"
t.text "metadata" t.text "metadata"
t.string "name", null: false t.string "name", null: false
t.text "redirect_uris" t.text "redirect_uris"
t.string "role_claim_name", default: "roles"
t.string "role_mapping_mode", default: "disabled", null: false
t.string "role_prefix"
t.string "slug", null: false t.string "slug", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["active"], name: "index_applications_on_active" t.index ["active"], name: "index_applications_on_active"
t.index ["client_id"], name: "index_applications_on_client_id", unique: true 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 t.index ["slug"], name: "index_applications_on_slug", unique: true
end 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.json "headers_config", default: {}, null: false
t.integer "policy"
t.datetime "updated_at", null: false
end
create_table "groups", force: :cascade do |t| create_table "groups", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false
t.text "description" t.text "description"
t.string "name", null: false t.string "name", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
@@ -122,51 +152,38 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.index ["user_id"], name: "index_user_groups_on_user_id" t.index ["user_id"], name: "index_user_groups_on_user_id"
end end
create_table "user_role_assignments", force: :cascade do |t|
t.integer "application_role_id", null: false
t.datetime "created_at", null: false
t.json "metadata", default: {}
t.string "source", default: "oidc"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_role_id"], name: "index_user_role_assignments_on_application_role_id"
t.index ["user_id", "application_role_id"], name: "index_user_role_assignments_on_user_id_and_application_role_id", unique: true
t.index ["user_id"], name: "index_user_role_assignments_on_user_id"
end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.boolean "admin", default: false, null: false t.boolean "admin", default: false, null: false
t.json "backup_codes" t.text "backup_codes"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false
t.string "email_address", null: false t.string "email_address", null: false
t.datetime "last_sign_in_at" t.datetime "last_sign_in_at"
t.string "name"
t.string "password_digest", null: false t.string "password_digest", null: false
t.string "preferred_2fa_method"
t.integer "status", default: 0, null: false t.integer "status", default: 0, null: false
t.boolean "totp_required", default: false, null: false t.boolean "totp_required", default: false, null: false
t.string "totp_secret" t.string "totp_secret"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "webauthn_id"
t.boolean "webauthn_required", default: false, null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["status"], name: "index_users_on_status" t.index ["status"], name: "index_users_on_status"
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
end
create_table "webauthn_credentials", force: :cascade do |t|
t.string "authenticator_type"
t.boolean "backup_eligible", default: false
t.boolean "backup_state", default: false
t.datetime "created_at", null: false
t.string "external_id", null: false
t.datetime "last_used_at"
t.string "last_used_ip"
t.string "nickname"
t.string "public_key", null: false
t.integer "sign_count", default: 0, null: false
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["authenticator_type"], name: "index_webauthn_credentials_on_authenticator_type"
t.index ["external_id"], name: "index_webauthn_credentials_on_external_id", unique: true
t.index ["last_used_at"], name: "index_webauthn_credentials_on_last_used_at"
t.index ["user_id", "external_id"], name: "index_webauthn_credentials_on_user_id_and_external_id", unique: true
t.index ["user_id", "last_used_at"], name: "index_webauthn_credentials_on_user_id_and_last_used_at"
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
end end
add_foreign_key "application_groups", "applications" add_foreign_key "application_groups", "applications"
add_foreign_key "application_groups", "groups" add_foreign_key "application_groups", "groups"
add_foreign_key "application_roles", "applications"
add_foreign_key "forward_auth_rule_groups", "forward_auth_rules"
add_foreign_key "forward_auth_rule_groups", "groups"
add_foreign_key "oidc_access_tokens", "applications" add_foreign_key "oidc_access_tokens", "applications"
add_foreign_key "oidc_access_tokens", "users" add_foreign_key "oidc_access_tokens", "users"
add_foreign_key "oidc_authorization_codes", "applications" add_foreign_key "oidc_authorization_codes", "applications"
@@ -176,5 +193,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
add_foreign_key "sessions", "users" add_foreign_key "sessions", "users"
add_foreign_key "user_groups", "groups" add_foreign_key "user_groups", "groups"
add_foreign_key "user_groups", "users" add_foreign_key "user_groups", "users"
add_foreign_key "webauthn_credentials", "users" add_foreign_key "user_role_assignments", "application_roles"
add_foreign_key "user_role_assignments", "users"
end end

View File

@@ -1,5 +1,9 @@
# Forward Authentication # Forward Authentication
References:
- https://www.reddit.com/r/selfhosted/comments/1hybe81/i_wanted_to_implement_my_own_forward_auth_proxy/
- https://www.kevinsimper.dk/posts/implementing-a-forward_auth-proxy-tips-and-details
## Overview ## 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. 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.
@@ -18,7 +22,7 @@ login_params = {
login_url = "#{base_url}/signin?#{login_params.to_query}" login_url = "#{base_url}/signin?#{login_params.to_query}"
``` ```
Example: `https://clinch.example.com/signin?rd=https://metube.example.com/&rm=GET` Example: `https://clinch.aapamilne.com/signin?rd=https://metube.aapamilne.com/&rm=GET`
### Tip 2: Root Domain Cookies ✅ ### Tip 2: Root Domain Cookies ✅
@@ -26,7 +30,7 @@ Clinch sets authentication cookies on the root domain to enable cross-subdomain
```ruby ```ruby
def extract_root_domain(host) def extract_root_domain(host)
# clinch.example.com -> .example.com # clinch.aapamilne.com -> .aapamilne.com
# app.example.co.uk -> .example.co.uk # app.example.co.uk -> .example.co.uk
# localhost -> nil (no domain restriction) # localhost -> nil (no domain restriction)
end end
@@ -36,85 +40,26 @@ cookies.signed.permanent[:session_id] = {
httponly: true, httponly: true,
same_site: :lax, same_site: :lax,
secure: Rails.env.production?, secure: Rails.env.production?,
domain: ".example.com" # Available to all subdomains domain: ".aapamilne.com" # Available to all subdomains
} }
``` ```
This allows the same session cookie to work across: This allows the same session cookie to work across:
- `clinch.example.com` (auth service) - `clinch.aapamilne.com` (auth service)
- `metube.example.com` (protected app) - `metube.aapamilne.com` (protected app)
- `sonarr.example.com` (protected app) - `sonarr.aapamilne.com` (protected app)
### Tip 3: Race Condition Solution with One-Time Tokens ✅ ## Authelia Analysis
**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. ### Implementation Comparison
**Solution**: Clinch uses a one-time token system to bridge this timing gap: **Authelia Approach (from analysis of `tmp/authelia/`):**
```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 - Returns `302 Found` or `303 See Other` with `Location` header
- Direct browser redirects to authentication service - Direct browser redirects (bypasses some proxy logic)
- Uses HTTP status codes to communicate authentication state - Uses StatusFound (302) or StatusSeeOther (303)
**Clinch Current Implementation:** **Clinch Current Implementation:**
- Returns `302 Found` directly to login URL - Returns `302 Found` directly to login URL (matching Authelia)
- Includes `rd` (redirect destination) and `rm` (request method) parameters - Includes `rd` (redirect destination) and `rm` (request method) parameters
- Uses root domain cookies for cross-subdomain authentication - Uses root domain cookies for cross-subdomain authentication
@@ -122,20 +67,14 @@ end
### Authentication Flow ### Authentication Flow
1. **User visits** `https://metube.example.com/` 1. **User visits** `https://metube.aapamilne.com/`
2. **Caddy forwards** to `http://clinch:3000/api/verify?rd=https://clinch.example.com` 2. **Caddy forwards** to `http://clinch:9000/api/verify?rd=https://clinch.aapamilne.com`
3. **Clinch checks session**: 3. **Clinch checks session**:
- **If authenticated**: Returns `200 OK` with user headers - **If authenticated**: Returns `200 OK` with user headers
- **If not authenticated**: Returns `302 Found` to login URL with redirect parameters - **If not authenticated**: Returns `302 Found` to login URL with redirect parameters
4. **Browser follows redirect** to Clinch login page 4. **Browser follows redirect** to Clinch login page
5. **User logs in** (with TOTP if enabled): 5. **User logs in** → gets redirected back to original MEtube URL
- Rails creates session and sets cross-domain cookie 6. **Caddy tries again** → succeeds and forwards to MEtube
- **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 ### Response Headers
@@ -149,21 +88,21 @@ Remote-Admin: false
**Redirect to Login (302 Found):** **Redirect to Login (302 Found):**
``` ```
Location: https://clinch.example.com/signin?rd=https://metube.example.com/&rm=GET Location: https://clinch.aapamilne.com/signin?rd=https://metube.aapamilne.com/&rm=GET
``` ```
## Caddy Configuration ## Caddy Configuration
```caddyfile ```caddyfile
# Clinch SSO (main authentication server) # Clinch SSO (main authentication server)
clinch.example.com { clinch.aapamilne.com {
reverse_proxy clinch:3000 reverse_proxy clinch:9000
} }
# MEtube (protected by Clinch) # MEtube (protected by Clinch)
metube.example.com { metube.aapamilne.com {
forward_auth clinch:3000 { forward_auth clinch:9000 {
uri /api/verify?rd=https://clinch.example.com uri /api/verify?rd=https://clinch.aapamilne.com
copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin copy_headers Remote-User Remote-Email Remote-Groups Remote-Admin
} }
@@ -181,191 +120,25 @@ metube.example.com {
- **Forward Auth Controller**: `app/controllers/api/forward_auth_controller.rb` - **Forward Auth Controller**: `app/controllers/api/forward_auth_controller.rb`
- **Authentication Logic**: `app/controllers/concerns/authentication.rb` - **Authentication Logic**: `app/controllers/concerns/authentication.rb`
- **Caddy Examples**: `docs/caddy-example.md` - **Caddy Examples**: `docs/caddy-example.md`
- **Implementation Details**: See technical documentation below - **Authelia Analysis**: `docs/authelia-forward-auth.md`
## Testing ## Testing
```bash ```bash
# Test forward auth endpoint directly # Test forward auth endpoint directly
curl -v http://localhost:3000/api/verify?rd=https://clinch.example.com curl -v http://localhost:9000/api/verify?rd=https://clinch.aapamilne.com
# Should return 302 redirect to login page # Should return 302 redirect to login page
# Or 200 OK if you have a valid session cookie # 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 ## Troubleshooting
### Common Issues ### Common Issues
1. **Authentication Loop**: Check that cookies are set on the root domain 1. **Authentication Loop**: Check that cookies are set on the root domain
2. **Session Not Shared**: Verify `extract_root_domain` is working correctly 2. **Session Not Shared**: Verify `extract_root_domain` is working correctly
3. **Caddy Connection**: Ensure service name/IP resolves from your Caddy container 3. **Caddy Connection**: Ensure `clinch:9000` resolves from your Caddy container
4. **Race Condition After Authentication**:
- **Problem**: Forward auth fails immediately after login due to cookie timing
- **Solution**: One-time tokens automatically bridge this gap
- **Debug**: Look for "ForwardAuth: Valid one-time token used" in logs
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 ### Debug Logging
@@ -373,21 +146,8 @@ Enable debug logging in `forward_auth_controller.rb` to see:
- Headers received from Caddy - Headers received from Caddy
- Domain extraction results - Domain extraction results
- Redirect URLs being generated - Redirect URLs being generated
- Token validation during race condition resolution
```ruby ```ruby
Rails.logger.info "ForwardAuth Headers: Host=#{host}, X-Forwarded-Host=#{original_host}" Rails.logger.info "ForwardAuth Headers: Host=#{host}, X-Forwarded-Host=#{original_host}"
Rails.logger.info "Setting 302 redirect to: #{login_url}" 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

View File

@@ -1,136 +0,0 @@
# OIDC Private Key Setup
Your OIDC provider needs an RSA private key to sign ID tokens. **This key must persist across deployments** or all existing tokens will become invalid.
## Option 1: Environment Variable (Recommended for Docker/Kamal)
### 1. Generate the key
```bash
# Generate a 2048-bit RSA key
openssl genrsa -out oidc_private_key.pem 2048
# View the key (you'll copy this)
cat oidc_private_key.pem
```
### 2. Store in your `.env` file
```bash
# .env (for local development)
OIDC_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAyZ0qaICMiLVWSFs+ef9Xok3fzy0p6k/7D5TQzmxf7C2vQG7s
2Odmi8iAHLoaUBaFj70qTbaconWyMr8s+ah+qZwrwolTLUe23VrceVXvInU57hBL
...
-----END RSA PRIVATE KEY-----"
```
**Important:** Keep the quotes and include the full key with `-----BEGIN` and `-----END` lines.
### 3. For Kamal deployment
Add to your Kamal secrets:
```yaml
# config/deploy.yml
env:
secret:
- OIDC_PRIVATE_KEY
```
Then set it securely:
```bash
# Generate key
bin/generate_oidc_key > oidc_private_key.pem
# Add to .kamal/secrets
echo "OIDC_PRIVATE_KEY=$(cat oidc_private_key.pem)" >> .kamal/secrets
```
### 4. Verify it's loaded
```bash
# In Rails console
bin/rails runner "puts OidcJwtService.send(:private_key).present? ? 'Key loaded' : 'Key missing'"
```
---
## Comparison
| Feature | ENV Variable | Rails Credentials |
|---------|-------------|-------------------|
| **Best for** | Docker, Kamal, 12-factor | Simple deployments |
| **Key rotation** | Easy (just update ENV) | Medium (re-encrypt) |
| **Per-environment** | Yes (dev/staging/prod can differ) | No (same key everywhere) |
| **Secrets manager** | Compatible (AWS Secrets, etc.) | Needs RAILS_MASTER_KEY |
| **Setup complexity** | Low | Medium |
**Recommendation:** Use ENV variable (`OIDC_PRIVATE_KEY`) for production with Kamal.
---
## Security Best Practices
### DO:
- ✅ Generate the key once and keep it forever
- ✅ Store in secret manager (AWS Secrets Manager, 1Password, etc.)
- ✅ Use strong key (2048-bit RSA minimum)
- ✅ Backup the key securely
- ✅ Restrict access (only ops team)
### DON'T:
- ❌ Commit the key to git (except encrypted credentials)
- ❌ Share the key in Slack/email
- ❌ Regenerate the key (invalidates all tokens)
- ❌ Store in `.env` if it's committed to git
- ❌ Use the same key for multiple environments
---
## Key Rotation (Advanced)
Todo
---
## Troubleshooting
### "No private key found" warning
Check your setup:
```bash
# Is ENV set?
echo $OIDC_PRIVATE_KEY
# Can Rails load it?
bin/rails runner "puts ENV['OIDC_PRIVATE_KEY'].present? ? 'ENV set' : 'ENV missing'"
# Does it load correctly?
bin/rails runner "puts OidcJwtService.send(:private_key).to_s[0..50]"
```
### "invalid RSA key" error
- Make sure you include `-----BEGIN RSA PRIVATE KEY-----` header
- Ensure newlines are preserved (use quotes in ENV)
- Check for extra spaces or characters
### Different JWKS key ID on each restart
This means the key is being regenerated. You need to set `OIDC_PRIVATE_KEY` or add to credentials.
### All tokens invalid after deployment
The key changed. You either:
- Regenerated the key (don't do this!)
- Forgot to set ENV variable in production
- The key wasn't loaded correctly
Check logs for warnings and verify key is loaded:
```bash
kamal app logs --grep "OIDC"
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Some files were not shown because too many files have changed in this diff Show More