Compare commits
78 Commits
2025.01
...
6be23c2c37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be23c2c37 | ||
|
|
eb2d7379bf | ||
|
|
67d86e5835 | ||
|
|
d6029556d3 | ||
|
|
7796c38c08 | ||
|
|
e882a4d6d1 | ||
|
|
ab0085e9c9 | ||
|
|
1ee3302319 | ||
|
|
67f28faaca | ||
|
|
33ad956508 | ||
|
|
11ec753c68 | ||
|
|
4df2eee4d9 | ||
|
|
d9f11abbbf | ||
|
|
c92e69fa4a | ||
|
|
038801f34b | ||
|
|
8e0b2c28eb | ||
|
|
f02665f690 | ||
|
|
631b2b53bb | ||
|
|
6049429a41 | ||
|
|
2b15aa2c40 | ||
|
|
4f5974dd37 | ||
|
|
5de53f1841 | ||
|
|
73b2ae2f02 | ||
|
|
4c5ac344bd | ||
|
|
044b9239d6 | ||
|
|
e9b1995e89 | ||
|
|
fb14ce032f | ||
|
|
bf104a9983 | ||
|
|
ec13dd2b60 | ||
|
|
57abc0b804 | ||
|
|
19bfc21f11 | ||
|
|
ef15db77f9 | ||
|
|
4d1bc1ab66 | ||
|
|
517029247d | ||
|
|
bfcc5cdc84 | ||
|
|
81871426e9 | ||
|
|
ddcb297c74 | ||
|
|
6f7de94623 | ||
|
|
baa75a3456 | ||
|
|
c3205abffa | ||
|
|
a2008d0750 | ||
|
|
810561d74b | ||
|
|
2ee895888d | ||
|
|
6c9fc429f1 | ||
|
|
7d200b849e | ||
|
|
7074242907 | ||
|
|
da6fd5b800 | ||
|
|
cfab21b130 | ||
|
|
c80bcafdb7 | ||
|
|
f050541e14 | ||
|
|
431e947a4c | ||
|
|
8dd3e60071 | ||
|
|
e4e7a0873e | ||
|
|
b5b1d94d47 | ||
|
|
52cfd6122c | ||
|
|
87796e0478 | ||
|
|
227e29ce0a | ||
|
|
d98f777e7d | ||
|
|
88428bfd97 | ||
|
|
2679634a2b | ||
|
|
2d5823213c | ||
|
|
5921cf82c2 | ||
|
|
df834b6e57 | ||
|
|
471c16890b | ||
|
|
39757a43dc | ||
|
|
5463723455 | ||
|
|
e36850f8ba | ||
|
|
0af3dbefed | ||
|
|
d6c24e50df | ||
|
|
8c80343b89 | ||
|
|
2db7f6a9df | ||
|
|
e3f202f574 | ||
|
|
c7f391541a | ||
|
|
8e56210b74 | ||
|
|
056c69e002 | ||
|
|
225b6b0bb6 | ||
|
|
fbda018065 | ||
|
|
12e0ef66ed |
60
.env.example
60
.env.example
@@ -16,9 +16,43 @@ SMTP_AUTHENTICATION=plain
|
|||||||
SMTP_ENABLE_STARTTLS=true
|
SMTP_ENABLE_STARTTLS=true
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
CLINCH_HOST=http://localhost:9000
|
CLINCH_HOST=http://localhost:3000
|
||||||
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
|
||||||
@@ -34,3 +68,27 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
|||||||
|
|
||||||
# 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
|
||||||
|
|||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -116,7 +116,7 @@ jobs:
|
|||||||
run: bin/rails db:test:prepare test:system
|
run: bin/rails db:test:prepare test:system
|
||||||
|
|
||||||
- name: Keep screenshots from failed system tests
|
- name: Keep screenshots from failed system tests
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: screenshots
|
name: screenshots
|
||||||
|
|||||||
@@ -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 && \
|
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config libssl-dev && \
|
||||||
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
|
||||||
|
|||||||
22
Gemfile
22
Gemfile
@@ -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.0"
|
gem "rails", "~> 8.1.1"
|
||||||
# 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
|
||||||
@@ -26,17 +26,26 @@ gem "bcrypt", "~> 3.1.7"
|
|||||||
gem "rotp", "~> 6.3"
|
gem "rotp", "~> 6.3"
|
||||||
|
|
||||||
# QR code generation for TOTP setup
|
# QR code generation for TOTP setup
|
||||||
gem "rqrcode", "~> 2.0"
|
gem "rqrcode", "~> 3.1"
|
||||||
|
|
||||||
# JWT for OIDC ID tokens
|
# JWT for OIDC ID tokens
|
||||||
gem "jwt", "~> 2.9"
|
gem "jwt", "~> 3.1"
|
||||||
|
|
||||||
|
# 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 ]
|
||||||
|
|
||||||
# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable
|
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||||
gem "solid_cache"
|
gem "solid_cache"
|
||||||
gem "solid_queue"
|
|
||||||
gem "solid_cable"
|
gem "solid_cable"
|
||||||
|
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
@@ -68,6 +77,9 @@ end
|
|||||||
group :development do
|
group :development do
|
||||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||||
gem "web-console"
|
gem "web-console"
|
||||||
|
|
||||||
|
# Preview emails in browser instead of sending them
|
||||||
|
gem "letter_opener"
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
|
|||||||
200
Gemfile.lock
200
Gemfile.lock
@@ -3,29 +3,29 @@ GEM
|
|||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.15)
|
action_text-trix (2.1.15)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.0)
|
actioncable (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
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.0)
|
actionmailbox (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activejob (= 8.1.0)
|
activejob (= 8.1.1)
|
||||||
activerecord (= 8.1.0)
|
activerecord (= 8.1.1)
|
||||||
activestorage (= 8.1.0)
|
activestorage (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.0)
|
actionmailer (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
actionview (= 8.1.0)
|
actionview (= 8.1.1)
|
||||||
activejob (= 8.1.0)
|
activejob (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.1.0)
|
actionpack (8.1.1)
|
||||||
actionview (= 8.1.0)
|
actionview (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
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.0)
|
actiontext (8.1.1)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activerecord (= 8.1.0)
|
activerecord (= 8.1.1)
|
||||||
activestorage (= 8.1.0)
|
activestorage (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.0)
|
actionview (8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
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.0)
|
activejob (8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.0)
|
activemodel (8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
activerecord (8.1.0)
|
activerecord (8.1.1)
|
||||||
activemodel (= 8.1.0)
|
activemodel (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.0)
|
activestorage (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activejob (= 8.1.0)
|
activejob (= 8.1.1)
|
||||||
activerecord (= 8.1.0)
|
activerecord (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.0)
|
activesupport (8.1.1)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
@@ -77,11 +77,13 @@ 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)
|
||||||
@@ -100,21 +102,25 @@ 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)
|
||||||
|
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.4.1)
|
date (3.5.0)
|
||||||
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.1)
|
erb (5.1.3)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
|
||||||
tzinfo
|
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-aarch64-linux-musl)
|
ffi (1.17.2-aarch64-linux-musl)
|
||||||
ffi (1.17.2-arm-linux-gnu)
|
ffi (1.17.2-arm-linux-gnu)
|
||||||
@@ -122,9 +128,6 @@ GEM
|
|||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-linux-gnu)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi (1.17.2-x86_64-linux-musl)
|
ffi (1.17.2-x86_64-linux-musl)
|
||||||
fugit (1.12.1)
|
|
||||||
et-orbi (~> 1.4)
|
|
||||||
raabro (~> 1.4)
|
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
@@ -137,15 +140,15 @@ 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.2)
|
irb (1.15.3)
|
||||||
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.1)
|
json (2.15.2)
|
||||||
jwt (2.10.2)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.8.1)
|
kamal (2.8.1)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
@@ -159,6 +162,12 @@ GEM
|
|||||||
thor (~> 1.3)
|
thor (~> 1.3)
|
||||||
zeitwerk (>= 2.6.18, < 3.0)
|
zeitwerk (>= 2.6.18, < 3.0)
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
|
launchy (3.1.1)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
childprocess (~> 5.0)
|
||||||
|
logger (~> 1.6)
|
||||||
|
letter_opener (1.10.0)
|
||||||
|
launchy (>= 2.2, < 4)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.24.1)
|
loofah (2.24.1)
|
||||||
@@ -191,7 +200,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.4)
|
nio4r (2.7.5)
|
||||||
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)
|
||||||
@@ -206,6 +215,9 @@ 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)
|
||||||
@@ -225,9 +237,8 @@ GEM
|
|||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
raabro (1.4.0)
|
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.4)
|
||||||
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)
|
||||||
@@ -235,20 +246,20 @@ GEM
|
|||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.2.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.0)
|
rails (8.1.1)
|
||||||
actioncable (= 8.1.0)
|
actioncable (= 8.1.1)
|
||||||
actionmailbox (= 8.1.0)
|
actionmailbox (= 8.1.1)
|
||||||
actionmailer (= 8.1.0)
|
actionmailer (= 8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
actiontext (= 8.1.0)
|
actiontext (= 8.1.1)
|
||||||
actionview (= 8.1.0)
|
actionview (= 8.1.1)
|
||||||
activejob (= 8.1.0)
|
activejob (= 8.1.1)
|
||||||
activemodel (= 8.1.0)
|
activemodel (= 8.1.1)
|
||||||
activerecord (= 8.1.0)
|
activerecord (= 8.1.1)
|
||||||
activestorage (= 8.1.0)
|
activestorage (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.0)
|
railties (= 8.1.1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -256,9 +267,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.0)
|
railties (8.1.1)
|
||||||
actionpack (= 8.1.0)
|
actionpack (= 8.1.1)
|
||||||
activesupport (= 8.1.0)
|
activesupport (= 8.1.1)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -266,8 +277,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.0)
|
rake (13.3.1)
|
||||||
rdoc (6.15.0)
|
rdoc (6.15.1)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
@@ -276,10 +287,10 @@ GEM
|
|||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (2.2.0)
|
rqrcode (3.1.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 1.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (1.2.0)
|
rqrcode_core (2.0.0)
|
||||||
rubocop (1.81.6)
|
rubocop (1.81.6)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
@@ -312,14 +323,22 @@ GEM
|
|||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.2.0)
|
rubyzip (3.2.1)
|
||||||
|
safety_net_attestation (0.5.0)
|
||||||
|
jwt (>= 2.0, < 4.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.37.0)
|
selenium-webdriver (4.38.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
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)
|
||||||
@@ -329,13 +348,6 @@ GEM
|
|||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_queue (1.2.2)
|
|
||||||
activejob (>= 7.1)
|
|
||||||
activerecord (>= 7.1)
|
|
||||||
concurrent-ruby (>= 1.3.1)
|
|
||||||
fugit (~> 1.11)
|
|
||||||
railties (>= 7.1)
|
|
||||||
thor (>= 1.3.1)
|
|
||||||
sqlite3 (2.7.4-aarch64-linux-gnu)
|
sqlite3 (2.7.4-aarch64-linux-gnu)
|
||||||
sqlite3 (2.7.4-aarch64-linux-musl)
|
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||||
sqlite3 (2.7.4-arm-linux-gnu)
|
sqlite3 (2.7.4-arm-linux-gnu)
|
||||||
@@ -367,7 +379,11 @@ 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.3)
|
timeout (0.4.4)
|
||||||
|
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)
|
||||||
@@ -377,13 +393,21 @@ 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.0.4)
|
uri (1.1.0)
|
||||||
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
|
||||||
@@ -414,18 +438,21 @@ DEPENDENCIES
|
|||||||
image_processing (~> 1.2)
|
image_processing (~> 1.2)
|
||||||
importmap-rails
|
importmap-rails
|
||||||
jbuilder
|
jbuilder
|
||||||
jwt (~> 2.9)
|
jwt (~> 3.1)
|
||||||
kamal
|
kamal
|
||||||
|
letter_opener
|
||||||
propshaft
|
propshaft
|
||||||
|
public_suffix (~> 6.0)
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.0)
|
rails (~> 8.1.1)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 2.0)
|
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
|
||||||
solid_queue
|
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
tailwindcss-rails
|
tailwindcss-rails
|
||||||
@@ -433,6 +460,7 @@ 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
|
||||||
|
|||||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Dan Milne
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
158
README.md
158
README.md
@@ -1,9 +1,29 @@
|
|||||||
# Clinch
|
# Clinch
|
||||||
|
|
||||||
**A lightweight, self-hosted identity & SSO portal**
|
> [!NOTE]
|
||||||
|
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||||
|
|
||||||
|
**A lightweight, self-hosted identity & SSO / IpD 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, refresh tokens, and token revocation
|
||||||
|
* Configurable token expiry per application (access, refresh, ID tokens)
|
||||||
|
* 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?
|
||||||
|
|
||||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||||
@@ -18,6 +38,35 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### User Dashboard
|
||||||
|
[](docs/screenshots/0-dashboard.png)
|
||||||
|
|
||||||
|
### Sign In
|
||||||
|
[](docs/screenshots/1-signin.png)
|
||||||
|
|
||||||
|
### Sign In with 2FA
|
||||||
|
[](docs/screenshots/2-signin.png)
|
||||||
|
|
||||||
|
### Users Management
|
||||||
|
[](docs/screenshots/3-users.png)
|
||||||
|
|
||||||
|
### Welcome Screen
|
||||||
|
[](docs/screenshots/4-welcome.png)
|
||||||
|
|
||||||
|
### Welcome Setup
|
||||||
|
[](docs/screenshots/5-welcome-2.png)
|
||||||
|
|
||||||
|
### Setup 2FA
|
||||||
|
[](docs/screenshots/6-setup-2fa.png)
|
||||||
|
|
||||||
|
### Forward Auth Example 1
|
||||||
|
[](docs/screenshots/7-forward-auth-1.png)
|
||||||
|
|
||||||
|
### Forward Auth Example 2
|
||||||
|
[](docs/screenshots/8-forward-auth-2.png)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### User Management
|
### User Management
|
||||||
@@ -27,22 +76,29 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
|||||||
- **User statuses** - Active, disabled, or pending invitation
|
- **User statuses** - Active, disabled, or pending invitation
|
||||||
|
|
||||||
### Authentication Methods
|
### Authentication Methods
|
||||||
|
- **WebAuthn/Passkeys** - Modern passwordless authentication using FIDO2 standards
|
||||||
- **Password authentication** - Secure bcrypt-based password storage
|
- **Password authentication** - Secure bcrypt-based password storage
|
||||||
- **Magic login links** - Passwordless login via email (15-minute expiry)
|
|
||||||
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
|
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
|
||||||
- **Backup codes** - 10 single-use recovery codes per user
|
- **Backup codes** - 10 single-use recovery codes per user
|
||||||
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users/groups
|
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users
|
||||||
|
|
||||||
### SSO Protocols
|
### SSO Protocols
|
||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint
|
- `/authorize` - Authorization endpoint with PKCE support
|
||||||
- `/token` - Token endpoint
|
- `/token` - Token endpoint (authorization_code and refresh_token grants)
|
||||||
- `/userinfo` - User info endpoint
|
- `/userinfo` - User info endpoint
|
||||||
|
- `/revoke` - Token revocation endpoint (RFC 7009)
|
||||||
|
|
||||||
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens.
|
Features:
|
||||||
|
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
||||||
|
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
|
||||||
|
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens
|
||||||
|
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
|
||||||
|
|
||||||
|
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
||||||
|
|
||||||
#### Trusted-Header SSO (ForwardAuth)
|
#### Trusted-Header SSO (ForwardAuth)
|
||||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||||
@@ -66,9 +122,54 @@ Send emails for:
|
|||||||
- **Session revocation** - Users and admins can revoke individual sessions
|
- **Session revocation** - Users and admins can revoke individual sessions
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
- **Group-based allowlists** - Restrict applications to specific user groups
|
|
||||||
- **Per-application access** - Each app defines which groups can access it
|
#### Group-Based Application Access
|
||||||
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
|
Clinch uses groups to control which users can access which applications:
|
||||||
|
|
||||||
|
- **Create groups** - Organize users into logical groups (readers, editors, family, developers, etc.)
|
||||||
|
- **Assign groups to applications** - Each app defines which groups are allowed to access it
|
||||||
|
- Example: Kavita app allows the "readers" group → only users in the "readers" group can sign in
|
||||||
|
- If no groups are assigned to an app → all active users can access it
|
||||||
|
- **Automatic enforcement** - Access checks happen automatically:
|
||||||
|
- During OIDC authorization flow (before consent)
|
||||||
|
- During ForwardAuth verification (before proxying requests)
|
||||||
|
- Users not in allowed groups receive a "You do not have permission" error
|
||||||
|
|
||||||
|
#### Group Claims in Tokens
|
||||||
|
- **OIDC tokens include group membership** - ID tokens contain a `groups` claim with all user's groups
|
||||||
|
- **Custom claims** - Add arbitrary key-value pairs to tokens via groups and users
|
||||||
|
- Group claims apply to all members (e.g., `{"role": "viewer"}`)
|
||||||
|
- User claims override group claims for fine-grained control
|
||||||
|
- Perfect for app-specific authorization (e.g., admin vs. read-only roles)
|
||||||
|
|
||||||
|
#### Custom Claims Merging
|
||||||
|
Custom claims from groups and users are merged into OIDC ID tokens with the following precedence:
|
||||||
|
|
||||||
|
1. **Default OIDC claims** - Standard claims (`iss`, `sub`, `aud`, `exp`, `email`, etc.)
|
||||||
|
2. **Standard Clinch claims** - `groups` array (list of user's group names)
|
||||||
|
3. **Group custom claims** - Merged in order; later groups override earlier ones
|
||||||
|
4. **User custom claims** - Override all group claims
|
||||||
|
5. **Application-specific claims** - Highest priority; override all other claims
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- Group "readers" has `{"role": "viewer", "max_items": 10}`
|
||||||
|
- Group "premium" has `{"role": "subscriber", "max_items": 100}`
|
||||||
|
- User (in both groups) has `{"max_items": 500}`
|
||||||
|
- **Result:** `{"role": "subscriber", "max_items": 500}` (user overrides max_items, premium overrides role)
|
||||||
|
|
||||||
|
#### Application-Specific Claims
|
||||||
|
Configure different claims for different applications on a per-user basis:
|
||||||
|
|
||||||
|
- **Per-app customization** - Each application can have unique claims for each user
|
||||||
|
- **Highest precedence** - App-specific claims override group and user global claims
|
||||||
|
- **Use case** - Different roles in different apps (e.g., admin in Kavita, user in Audiobookshelf)
|
||||||
|
- **Admin UI** - Configure via Admin → Users → Edit User → App-Specific Claim Overrides
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- User Alice, global claims: `{"theme": "dark"}`
|
||||||
|
- Kavita app-specific: `{"kavita_groups": ["admin"]}`
|
||||||
|
- Audiobookshelf app-specific: `{"abs_groups": ["user"]}`
|
||||||
|
- **Result:** Kavita receives `{"theme": "dark", "kavita_groups": ["admin"]}`, Audiobookshelf receives `{"theme": "dark", "abs_groups": ["user"]}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -83,11 +184,13 @@ 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**
|
||||||
@@ -100,28 +203,34 @@ Send emails for:
|
|||||||
|
|
||||||
**Application**
|
**Application**
|
||||||
- Name and slug (URL-safe identifier)
|
- Name and slug (URL-safe identifier)
|
||||||
- Type (oidc, trusted_header, saml)
|
- Type (oidc or forward_auth)
|
||||||
- Client ID and secret (for OIDC)
|
- Client ID and secret (for OIDC apps)
|
||||||
- Redirect URIs (JSON array)
|
- Redirect URIs (for OIDC apps)
|
||||||
|
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
||||||
|
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
|
||||||
|
- Token TTL configuration (access_token_ttl, refresh_token_ttl, id_token_ttl)
|
||||||
- Metadata (flexible JSON storage)
|
- Metadata (flexible JSON storage)
|
||||||
- Active flag
|
- Active flag
|
||||||
- Many-to-many with Groups (allowlist)
|
- Many-to-many with Groups (allowlist)
|
||||||
|
|
||||||
**OIDC Tokens**
|
**OIDC Tokens**
|
||||||
- Authorization codes (10-minute expiry, one-time use)
|
- Authorization codes (10-minute expiry, one-time use, PKCE support)
|
||||||
- Access tokens (1-hour expiry, revocable)
|
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
|
||||||
|
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
|
||||||
|
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication Flows
|
## Authentication Flows
|
||||||
|
|
||||||
### OIDC Authorization Flow
|
### OIDC Authorization Flow
|
||||||
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope
|
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope (optional PKCE)
|
||||||
2. User authenticates with Clinch (username/password + optional TOTP)
|
2. User authenticates with Clinch (username/password + optional TOTP)
|
||||||
3. Access control check: Is user in an allowed group for this app?
|
3. Access control check: Is user in an allowed group for this app?
|
||||||
4. If allowed, generate authorization code and redirect to client
|
4. If allowed, generate authorization code and redirect to client
|
||||||
5. Client exchanges code for access token at `/token`
|
5. Client exchanges code at `/token` for ID token, access token, and refresh token
|
||||||
6. Client uses access token to fetch user info from `/userinfo`
|
6. Client uses access token to fetch fresh user info from `/userinfo`
|
||||||
|
7. When access token expires, client uses refresh token to get new tokens (no re-authentication)
|
||||||
|
|
||||||
### ForwardAuth Flow
|
### ForwardAuth Flow
|
||||||
1. User requests protected resource at `https://app.example.com/dashboard`
|
1. User requests protected resource at `https://app.example.com/dashboard`
|
||||||
@@ -167,7 +276,7 @@ bin/dev
|
|||||||
docker build -t clinch .
|
docker build -t clinch .
|
||||||
|
|
||||||
# Run container
|
# Run container
|
||||||
docker run -p 9000:9000 \
|
docker run -p 3000:3000 \
|
||||||
-v clinch-storage:/rails/storage \
|
-v clinch-storage:/rails/storage \
|
||||||
-e SECRET_KEY_BASE=your-secret-key \
|
-e SECRET_KEY_BASE=your-secret-key \
|
||||||
-e SMTP_ADDRESS=smtp.example.com \
|
-e SMTP_ADDRESS=smtp.example.com \
|
||||||
@@ -205,10 +314,14 @@ SMTP_ENABLE_STARTTLS=true
|
|||||||
# Application
|
# Application
|
||||||
CLINCH_HOST=https://auth.example.com
|
CLINCH_HOST=https://auth.example.com
|
||||||
CLINCH_FROM_EMAIL=noreply@example.com
|
CLINCH_FROM_EMAIL=noreply@example.com
|
||||||
|
|
||||||
|
# OIDC (optional - generates temporary key in development)
|
||||||
|
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||||
|
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
||||||
```
|
```
|
||||||
|
|
||||||
### First Run
|
### First Run
|
||||||
1. Visit Clinch at `http://localhost:9000` (or your configured domain)
|
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
||||||
2. First-run wizard creates initial admin user
|
2. First-run wizard creates initial admin user
|
||||||
3. Admin can then:
|
3. Admin can then:
|
||||||
- Create groups
|
- Create groups
|
||||||
@@ -227,12 +340,14 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
|||||||
- First-run wizard
|
- First-run wizard
|
||||||
|
|
||||||
### Planned Features
|
### Planned Features
|
||||||
|
- **Audit logging** - Track all authentication events
|
||||||
|
- **WebAuthn/Passkeys** - Hardware key support
|
||||||
|
|
||||||
|
#### Maybe
|
||||||
- **SAML support** - SAML 2.0 identity provider
|
- **SAML support** - SAML 2.0 identity provider
|
||||||
- **Policy engine** - Rule-based access control
|
- **Policy engine** - Rule-based access control
|
||||||
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
||||||
- Stored as JSON, evaluated after auth but before consent
|
- Stored as JSON, evaluated after auth but before consent
|
||||||
- **Audit logging** - Track all authentication events
|
|
||||||
- **WebAuthn/Passkeys** - Hardware key support
|
|
||||||
- **LDAP sync** - Import users from LDAP/Active Directory
|
- **LDAP sync** - Import users from LDAP/Active Directory
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -251,4 +366,3 @@ CLINCH_FROM_EMAIL=noreply@example.com
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
|||||||
101
app/controllers/active_sessions_controller.rb
Normal file
101
app/controllers/active_sessions_controller.rb
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
# Send backchannel logout notification before revoking consent
|
||||||
|
if application.supports_backchannel_logout?
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: @user.id,
|
||||||
|
application_id: application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Revoke all tokens for this user-application pair
|
||||||
|
now = Time.current
|
||||||
|
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||||
|
.update_all(revoked_at: now)
|
||||||
|
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||||
|
.update_all(revoked_at: now)
|
||||||
|
|
||||||
|
Rails.logger.info "ActiveSessionsController: Revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens for #{application.name}"
|
||||||
|
|
||||||
|
# Revoke the consent
|
||||||
|
consent.destroy
|
||||||
|
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
||||||
|
end
|
||||||
|
|
||||||
|
def logout_from_app
|
||||||
|
@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 root_path, alert: "No active session found for this application."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Send backchannel logout notification
|
||||||
|
if application.supports_backchannel_logout?
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: @user.id,
|
||||||
|
application_id: application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Revoke all tokens for this user-application pair
|
||||||
|
now = Time.current
|
||||||
|
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
||||||
|
.update_all(revoked_at: now)
|
||||||
|
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
||||||
|
.update_all(revoked_at: now)
|
||||||
|
|
||||||
|
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
||||||
|
|
||||||
|
# Keep the consent intact - this is the key difference from revoke_consent
|
||||||
|
redirect_to root_path, notice: "Successfully logged out of #{application.name}."
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_all_consents
|
||||||
|
@user = Current.session.user
|
||||||
|
consents = @user.oidc_user_consents.includes(:application)
|
||||||
|
count = consents.count
|
||||||
|
|
||||||
|
if count > 0
|
||||||
|
# Send backchannel logout notifications before revoking consents
|
||||||
|
consents.each do |consent|
|
||||||
|
next unless consent.application.supports_backchannel_logout?
|
||||||
|
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: @user.id,
|
||||||
|
application_id: consent.application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
Rails.logger.info "ActiveSessionsController: Enqueued #{count} backchannel logout notifications"
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -17,6 +17,7 @@ module Admin
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@application = Application.new(application_params)
|
@application = Application.new(application_params)
|
||||||
|
@available_groups = Group.order(:name)
|
||||||
|
|
||||||
if @application.save
|
if @application.save
|
||||||
# Handle group assignments
|
# Handle group assignments
|
||||||
@@ -25,9 +26,22 @@ module Admin
|
|||||||
@application.allowed_groups = Group.where(id: group_ids)
|
@application.allowed_groups = Group.where(id: group_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to admin_application_path(@application), notice: "Application created successfully."
|
# Get the plain text client secret to show one time
|
||||||
|
client_secret = nil
|
||||||
|
if @application.oidc?
|
||||||
|
client_secret = @application.generate_new_client_secret!
|
||||||
|
end
|
||||||
|
|
||||||
|
if @application.oidc? && client_secret
|
||||||
|
flash[:notice] = "Application created successfully."
|
||||||
|
flash[:client_id] = @application.client_id
|
||||||
|
flash[:client_secret] = client_secret
|
||||||
|
else
|
||||||
|
flash[:notice] = "Application created successfully."
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to admin_application_path(@application)
|
||||||
else
|
else
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -60,11 +74,17 @@ module Admin
|
|||||||
|
|
||||||
def regenerate_credentials
|
def regenerate_credentials
|
||||||
if @application.oidc?
|
if @application.oidc?
|
||||||
@application.update!(
|
# Generate new client ID and secret
|
||||||
client_id: SecureRandom.urlsafe_base64(32),
|
new_client_id = SecureRandom.urlsafe_base64(32)
|
||||||
client_secret: SecureRandom.urlsafe_base64(48)
|
client_secret = @application.generate_new_client_secret!
|
||||||
)
|
|
||||||
redirect_to admin_application_path(@application), notice: "Credentials regenerated successfully. Make sure to update your application configuration."
|
@application.update!(client_id: new_client_id)
|
||||||
|
|
||||||
|
flash[:notice] = "Credentials regenerated successfully."
|
||||||
|
flash[:client_id] = @application.client_id
|
||||||
|
flash[:client_secret] = client_secret
|
||||||
|
|
||||||
|
redirect_to admin_application_path(@application)
|
||||||
else
|
else
|
||||||
redirect_to admin_application_path(@application), alert: "Only OIDC applications have credentials."
|
redirect_to admin_application_path(@application), alert: "Only OIDC applications have credentials."
|
||||||
end
|
end
|
||||||
@@ -77,7 +97,15 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def application_params
|
def application_params
|
||||||
params.require(:application).permit(:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata)
|
params.require(:application).permit(
|
||||||
|
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||||
|
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||||
|
:icon, :backchannel_logout_uri,
|
||||||
|
headers_config: {}
|
||||||
|
).tap do |whitelisted|
|
||||||
|
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||||
|
whitelisted.delete(:client_secret)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
module Admin
|
|
||||||
class ForwardAuthRulesController < BaseController
|
|
||||||
before_action :set_forward_auth_rule, only: [:show, :edit, :update, :destroy]
|
|
||||||
|
|
||||||
def index
|
|
||||||
@forward_auth_rules = ForwardAuthRule.ordered
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
@allowed_groups = @forward_auth_rule.allowed_groups
|
|
||||||
end
|
|
||||||
|
|
||||||
def new
|
|
||||||
@forward_auth_rule = ForwardAuthRule.new
|
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
|
|
||||||
|
|
||||||
if @forward_auth_rule.save
|
|
||||||
# Handle group assignments
|
|
||||||
if params[:forward_auth_rule][:group_ids].present?
|
|
||||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
|
||||||
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule created successfully."
|
|
||||||
else
|
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
render :new, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def edit
|
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
if @forward_auth_rule.update(forward_auth_rule_params)
|
|
||||||
# Handle group assignments
|
|
||||||
if params[:forward_auth_rule][:group_ids].present?
|
|
||||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
|
||||||
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
|
||||||
else
|
|
||||||
@forward_auth_rule.allowed_groups = []
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule updated successfully."
|
|
||||||
else
|
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
render :edit, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
@forward_auth_rule.destroy
|
|
||||||
redirect_to admin_forward_auth_rules_path, notice: "Forward auth rule deleted successfully."
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_forward_auth_rule
|
|
||||||
@forward_auth_rule = ForwardAuthRule.find(params[:id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def forward_auth_rule_params
|
|
||||||
params.require(:forward_auth_rule).permit(:domain_pattern, :active)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -18,7 +18,25 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@group = Group.new(group_params)
|
create_params = group_params
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if create_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
create_params[:custom_claims] = JSON.parse(create_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@group = Group.new
|
||||||
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@available_users = User.order(:email_address)
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
create_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
@group = Group.new(create_params)
|
||||||
|
|
||||||
if @group.save
|
if @group.save
|
||||||
# Handle user assignments
|
# Handle user assignments
|
||||||
@@ -39,7 +57,24 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
if @group.update(group_params)
|
update_params = group_params
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if update_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@group.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@available_users = User.order(:email_address)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
update_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
if @group.update(update_params)
|
||||||
# Handle user assignments
|
# Handle user assignments
|
||||||
if params[:group][:user_ids].present?
|
if params[:group][:user_ids].present?
|
||||||
user_ids = params[:group][:user_ids].reject(&:blank?)
|
user_ids = params[:group][:user_ids].reject(&:blank?)
|
||||||
@@ -67,7 +102,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def group_params
|
def group_params
|
||||||
params.require(:group).permit(:name, :description)
|
params.require(:group).permit(:name, :description, :custom_claims)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class UsersController < BaseController
|
class UsersController < BaseController
|
||||||
before_action :set_user, only: [:show, :edit, :update, :destroy]
|
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@users = User.order(created_at: :desc)
|
@users = User.order(created_at: :desc)
|
||||||
@@ -16,36 +16,59 @@ module Admin
|
|||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||||
|
@user.status = :pending_invitation
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
redirect_to admin_users_path, notice: "User created successfully."
|
InvitationsMailer.invite_user(@user).deliver_later
|
||||||
|
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||||
else
|
else
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
# Prevent changing params for the current user's email and admin status
|
update_params = user_params
|
||||||
# to avoid locking themselves out
|
|
||||||
update_params = user_params.dup
|
|
||||||
|
|
||||||
if @user == Current.session.user
|
|
||||||
update_params.delete(:admin)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only update password if provided
|
# Only update password if provided
|
||||||
update_params.delete(:password) if update_params[:password].blank?
|
update_params.delete(:password) if update_params[:password].blank?
|
||||||
|
|
||||||
|
# Parse custom_claims JSON if provided
|
||||||
|
if update_params[:custom_claims].present?
|
||||||
|
begin
|
||||||
|
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
||||||
|
rescue JSON::ParserError
|
||||||
|
@user.errors.add(:custom_claims, "must be valid JSON")
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# If empty or blank, set to empty hash (NOT NULL constraint)
|
||||||
|
update_params[:custom_claims] = {}
|
||||||
|
end
|
||||||
|
|
||||||
if @user.update(update_params)
|
if @user.update(update_params)
|
||||||
redirect_to admin_users_path, notice: "User updated successfully."
|
redirect_to admin_users_path, notice: "User updated successfully."
|
||||||
else
|
else
|
||||||
|
@applications = Application.active.order(:name)
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resend_invitation
|
||||||
|
unless @user.pending_invitation?
|
||||||
|
redirect_to admin_users_path, alert: "Cannot send invitation. User is not pending invitation."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
InvitationsMailer.invite_user(@user).deliver_later
|
||||||
|
redirect_to admin_users_path, notice: "Invitation email resent to #{@user.email_address}."
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
# Prevent admin from deleting themselves
|
# Prevent admin from deleting themselves
|
||||||
if @user == Current.session.user
|
if @user == Current.session.user
|
||||||
@@ -57,6 +80,41 @@ module Admin
|
|||||||
redirect_to admin_users_path, notice: "User deleted successfully."
|
redirect_to admin_users_path, notice: "User deleted successfully."
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /admin/users/:id/update_application_claims
|
||||||
|
def update_application_claims
|
||||||
|
application = Application.find(params[:application_id])
|
||||||
|
|
||||||
|
claims_json = params[:custom_claims].presence || "{}"
|
||||||
|
begin
|
||||||
|
claims = JSON.parse(claims_json)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "Invalid JSON format for claims."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
app_claim = @user.application_user_claims.find_or_initialize_by(application: application)
|
||||||
|
app_claim.custom_claims = claims
|
||||||
|
|
||||||
|
if app_claim.save
|
||||||
|
redirect_to edit_admin_user_path(@user), notice: "App-specific claims updated for #{application.name}."
|
||||||
|
else
|
||||||
|
error_message = app_claim.errors.full_messages.join(", ")
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "Failed to update claims: #{error_message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /admin/users/:id/delete_application_claims
|
||||||
|
def delete_application_claims
|
||||||
|
application = Application.find(params[:application_id])
|
||||||
|
app_claim = @user.application_user_claims.find_by(application: application)
|
||||||
|
|
||||||
|
if app_claim&.destroy
|
||||||
|
redirect_to edit_admin_user_path(@user), notice: "App-specific claims removed for #{application.name}."
|
||||||
|
else
|
||||||
|
redirect_to edit_admin_user_path(@user), alert: "No claims found to remove."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
@@ -64,7 +122,15 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:email_address, :password, :admin, :status)
|
# Base attributes that all admins can modify
|
||||||
|
base_params = params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
||||||
|
|
||||||
|
# Only allow modifying admin status when editing other users (prevent self-demotion)
|
||||||
|
if params[:id] != Current.session.user.id.to_s
|
||||||
|
base_params[:admin] = params[:user][:admin] if params[:user][:admin].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
base_params
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
57
app/controllers/api/csp_controller.rb
Normal file
57
app/controllers/api/csp_controller.rb
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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']
|
||||||
|
|
||||||
|
# Validate that we have a proper CSP report
|
||||||
|
unless csp_report.is_a?(Hash) && csp_report.present?
|
||||||
|
Rails.logger.warn "Received empty or invalid CSP violation report"
|
||||||
|
head :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -3,22 +3,27 @@ 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 ForwardAuthRule
|
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
|
||||||
|
|
||||||
|
# Check for one-time forward auth token first (to handle race condition)
|
||||||
|
session_id = check_forward_auth_token
|
||||||
|
|
||||||
|
# If no token found, try to get session from cookie
|
||||||
|
session_id ||= extract_session_id
|
||||||
|
|
||||||
# Get the session from cookie
|
|
||||||
session_id = extract_session_id
|
|
||||||
unless session_id
|
unless session_id
|
||||||
# No session cookie - user is not authenticated
|
# No session cookie or token - user is not authenticated
|
||||||
return render_unauthorized("No session cookie")
|
return render_unauthorized("No session cookie")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the session
|
# Find the session with user association (eager loading for performance)
|
||||||
session = Session.find_by(id: session_id)
|
session = Session.includes(:user).find_by(id: session_id)
|
||||||
unless session
|
unless session
|
||||||
# Invalid session
|
# Invalid session
|
||||||
return render_unauthorized("Invalid session")
|
return render_unauthorized("Invalid session")
|
||||||
@@ -30,52 +35,64 @@ module Api
|
|||||||
return render_unauthorized("Session expired")
|
return render_unauthorized("Session expired")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Update last activity
|
# Update last activity (skip validations for performance)
|
||||||
session.update_column(:last_activity_at, Time.current)
|
session.update_column(:last_activity_at, Time.current)
|
||||||
|
|
||||||
# Get the user
|
# Get the user (already loaded via includes(:user))
|
||||||
user = session.user
|
user = session.user
|
||||||
unless user.active?
|
unless user.active?
|
||||||
return render_unauthorized("User account is not active")
|
return render_unauthorized("User account is not active")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check for forward auth rule authorization
|
# Check for forward auth application 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?
|
||||||
# Find matching forward auth rule for this domain
|
# Load active forward auth applications with their associations for better performance
|
||||||
rule = ForwardAuthRule.active.find { |r| r.matches_domain?(forwarded_host) }
|
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||||
|
apps = Application.forward_auth.includes(:allowed_groups).active
|
||||||
|
|
||||||
unless rule
|
# Find matching forward auth application for this domain
|
||||||
Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
|
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
||||||
return render_forbidden("No authentication rule configured for this domain")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if user is allowed by this rule
|
if app
|
||||||
unless rule.user_allowed?(user)
|
# Check if user is allowed by this application
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by rule #{rule.domain_pattern}"
|
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")
|
return render_forbidden("You do not have permission to access this domain")
|
||||||
end
|
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)})"
|
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
|
||||||
else
|
else
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||||
end
|
end
|
||||||
|
|
||||||
# User is authenticated and authorized
|
# User is authenticated and authorized
|
||||||
# Return 200 with user information headers
|
# Return 200 with user information headers using app-specific configuration
|
||||||
response.headers["Remote-User"] = user.email_address
|
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||||
response.headers["Remote-Email"] = user.email_address
|
case key
|
||||||
response.headers["Remote-Name"] = user.email_address
|
when :user, :email, :name
|
||||||
|
[header_name, user.email_address]
|
||||||
# Add groups if user has any
|
when :groups
|
||||||
if user.groups.any?
|
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||||
response.headers["Remote-Groups"] = user.groups.pluck(:name).join(",")
|
when :admin
|
||||||
|
[header_name, user.admin? ? "true" : "false"]
|
||||||
end
|
end
|
||||||
|
}.compact.to_h
|
||||||
|
|
||||||
# Add admin flag
|
headers.each { |key, value| response.headers[key] = value }
|
||||||
response.headers["Remote-Admin"] = user.admin? ? "true" : "false"
|
|
||||||
|
# Log what headers we're sending (helpful for debugging)
|
||||||
|
if headers.any?
|
||||||
|
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
||||||
|
else
|
||||||
|
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||||
|
end
|
||||||
|
|
||||||
# Return 200 OK with no body
|
# Return 200 OK with no body
|
||||||
head :ok
|
head :ok
|
||||||
@@ -83,14 +100,34 @@ 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
|
||||||
cookies.signed[:session_id]
|
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 ForwardAuthRule domain matching
|
# This method is deprecated since we now use Application (forward_auth type) domain matching
|
||||||
# Keeping it for backward compatibility but it's no longer used
|
# Keeping it for backward compatibility but it's no longer used
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -98,11 +135,9 @@ 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
|
||||||
base_url = params[:rd] || "https://clinch.aapamilne.com"
|
redirect_url = validate_redirect_url(params[:rd])
|
||||||
|
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
|
||||||
@@ -113,11 +148,11 @@ module Api
|
|||||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
||||||
|
|
||||||
original_url = if original_host
|
original_url = if original_host
|
||||||
# Use the forwarded host and URI
|
# Use the forwarded host and URI (original behavior)
|
||||||
"https://#{original_host}#{original_uri}"
|
"https://#{original_host}#{original_uri}"
|
||||||
else
|
else
|
||||||
# Fallback: just redirect to the root of the original host
|
# Fallback: use the validated redirect URL or default
|
||||||
"https://#{request.headers['Host']}"
|
redirect_url || "https://clinch.aapamilne.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Debug: log what we're redirecting to after login
|
# Debug: log what we're redirecting to after login
|
||||||
@@ -141,11 +176,65 @@ 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?
|
||||||
|
host = ENV['CLINCH_HOST']
|
||||||
|
# Ensure URL has https:// protocol
|
||||||
|
host.match?(/^https?:\/\//) ? host : "https://#{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
|
||||||
|
|||||||
@@ -5,4 +5,7 @@ class ApplicationController < ActionController::Base
|
|||||||
|
|
||||||
# Changes to the importmap will invalidate the etag for HTML responses
|
# Changes to the importmap will invalidate the etag for HTML responses
|
||||||
stale_when_importmap_changes
|
stale_when_importmap_changes
|
||||||
|
|
||||||
|
# CSRF protection
|
||||||
|
protect_from_forgery with: :exception
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
require 'uri'
|
||||||
|
require 'public_suffix'
|
||||||
|
require 'ipaddr'
|
||||||
|
|
||||||
module Authentication
|
module Authentication
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
@@ -31,14 +35,17 @@ module Authentication
|
|||||||
|
|
||||||
def request_authentication
|
def request_authentication
|
||||||
session[:return_to_after_authenticating] = request.url
|
session[:return_to_after_authenticating] = request.url
|
||||||
redirect_to new_session_path
|
redirect_to signin_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_authentication_url
|
def after_authentication_url
|
||||||
session.delete(:return_to_after_authenticating) || root_url
|
return_url = session[:return_to_after_authenticating]
|
||||||
|
final_url = session.delete(:return_to_after_authenticating) || root_url
|
||||||
|
final_url
|
||||||
end
|
end
|
||||||
|
|
||||||
def start_new_session_for(user)
|
def start_new_session_for(user)
|
||||||
|
user.update!(last_sign_in_at: Time.current)
|
||||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||||
Current.session = session
|
Current.session = session
|
||||||
|
|
||||||
@@ -56,6 +63,10 @@ module Authentication
|
|||||||
cookie_options[:domain] = domain if domain.present?
|
cookie_options[:domain] = domain if domain.present?
|
||||||
|
|
||||||
cookies.signed.permanent[:session_id] = cookie_options
|
cookies.signed.permanent[:session_id] = cookie_options
|
||||||
|
|
||||||
|
# Create a one-time token for immediate forward auth after authentication
|
||||||
|
# This solves the race condition where browser hasn't processed cookie yet
|
||||||
|
create_forward_auth_token(session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -64,36 +75,75 @@ module Authentication
|
|||||||
cookies.delete(:session_id)
|
cookies.delete(:session_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extract root domain for cross-subdomain cookies
|
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||||
|
#
|
||||||
|
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||||
|
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||||
|
# both app.example.com and api.example.com).
|
||||||
|
#
|
||||||
|
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||||
|
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||||
|
# and setting a domain cookie would break authentication.
|
||||||
|
#
|
||||||
|
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||||
|
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||||
|
#
|
||||||
# Examples:
|
# Examples:
|
||||||
# - clinch.aapamilne.com -> .aapamilne.com
|
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||||
# - app.example.co.uk -> .example.co.uk
|
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||||
# - localhost -> nil (no domain setting for local development)
|
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||||
|
# - localhost -> nil (local development, no domain cookie)
|
||||||
|
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||||
|
#
|
||||||
|
# @param host [String] The request host (may include port)
|
||||||
|
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||||
def extract_root_domain(host)
|
def extract_root_domain(host)
|
||||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
# Split hostname into parts
|
# Strip port number for domain parsing
|
||||||
parts = host.split('.')
|
host_without_port = host.split(':').first
|
||||||
|
|
||||||
# For normal domains like example.com, we need at least 2 parts
|
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||||
# For complex domains like co.uk, we need at least 3 parts
|
return nil if IPAddr.new(host_without_port) rescue false
|
||||||
return nil if parts.length < 2
|
|
||||||
|
|
||||||
# Extract root domain with leading dot for cross-subdomain cookies
|
# Use Public Suffix List for accurate domain parsing
|
||||||
if parts.length >= 3
|
domain = PublicSuffix.parse(host_without_port)
|
||||||
# Check if it's a known complex TLD
|
".#{domain.domain}"
|
||||||
complex_tlds = %w[co.uk com.au co.nz co.za co.jp]
|
rescue PublicSuffix::DomainInvalid
|
||||||
second_level = "#{parts[-2]}.#{parts[-1]}"
|
# Fallback for invalid domains or IPs
|
||||||
|
nil
|
||||||
if complex_tlds.include?(second_level)
|
|
||||||
# For complex TLDs, include more parts: app.example.co.uk -> .example.co.uk
|
|
||||||
root_parts = parts[-3..-1]
|
|
||||||
return ".#{root_parts.join('.')}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# For regular domains: app.example.com -> .example.com
|
# Create a one-time token for forward auth to handle the race condition
|
||||||
root_parts = parts[-2..-1]
|
# where the browser hasn't processed the session cookie yet
|
||||||
".#{root_parts.join('.')}"
|
def create_forward_auth_token(session_obj)
|
||||||
|
# Generate a secure random token
|
||||||
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
|
|
||||||
|
# Store it with an expiry of 60 seconds
|
||||||
|
Rails.cache.write(
|
||||||
|
"forward_auth_token:#{token}",
|
||||||
|
session_obj.id,
|
||||||
|
expires_in: 60.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)
|
||||||
|
|
||||||
|
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
|
||||||
|
unless uri.path&.start_with?("/oauth/")
|
||||||
|
# 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
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,5 +8,10 @@ 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
|
||||||
|
|||||||
50
app/controllers/invitations_controller.rb
Normal file
50
app/controllers/invitations_controller.rb
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
class InvitationsController < ApplicationController
|
||||||
|
include Authentication
|
||||||
|
allow_unauthenticated_access
|
||||||
|
before_action :set_user_by_invitation_token, only: %i[ show update ]
|
||||||
|
|
||||||
|
def show
|
||||||
|
# Show the password setup form
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
# Validate password manually since empty passwords might not trigger validation
|
||||||
|
password = params[:password]
|
||||||
|
password_confirmation = params[:password_confirmation]
|
||||||
|
|
||||||
|
if password.blank? || password_confirmation.blank? || password != password_confirmation || password.length < 8
|
||||||
|
redirect_to invitation_path(params[:token]), alert: "Passwords did not match."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if @user.update(password: password, password_confirmation: password_confirmation)
|
||||||
|
@user.update!(status: :active)
|
||||||
|
@user.sessions.destroy_all
|
||||||
|
start_new_session_for @user
|
||||||
|
redirect_to root_path, notice: "Your account has been set up successfully. Welcome!"
|
||||||
|
else
|
||||||
|
redirect_to invitation_path(params[:token]), alert: "Passwords did not match."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_user_by_invitation_token
|
||||||
|
@user = User.find_by_token_for(:invitation_login, params[:token])
|
||||||
|
|
||||||
|
# Check if user is still pending invitation
|
||||||
|
if @user.nil?
|
||||||
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
|
return false
|
||||||
|
elsif @user.pending_invitation?
|
||||||
|
# User is valid and pending - proceed
|
||||||
|
return true
|
||||||
|
else
|
||||||
|
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
||||||
skip_before_action :verify_authenticity_token, only: [:token]
|
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
||||||
|
|
||||||
# GET /.well-known/openid-configuration
|
# GET /.well-known/openid-configuration
|
||||||
def discovery
|
def discovery
|
||||||
@@ -11,14 +11,21 @@ class OidcController < ApplicationController
|
|||||||
issuer: base_url,
|
issuer: base_url,
|
||||||
authorization_endpoint: "#{base_url}/oauth/authorize",
|
authorization_endpoint: "#{base_url}/oauth/authorize",
|
||||||
token_endpoint: "#{base_url}/oauth/token",
|
token_endpoint: "#{base_url}/oauth/token",
|
||||||
|
revocation_endpoint: "#{base_url}/oauth/revoke",
|
||||||
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
||||||
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
||||||
|
end_session_endpoint: "#{base_url}/logout",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
|
response_modes_supported: ["query"],
|
||||||
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
subject_types_supported: ["public"],
|
subject_types_supported: ["public"],
|
||||||
id_token_signing_alg_values_supported: ["RS256"],
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
scopes_supported: ["openid", "profile", "email", "groups"],
|
scopes_supported: ["openid", "profile", "email", "groups"],
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"]
|
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
|
||||||
|
code_challenge_methods_supported: ["plain", "S256"],
|
||||||
|
backchannel_logout_supported: true,
|
||||||
|
backchannel_logout_session_supported: true
|
||||||
}
|
}
|
||||||
|
|
||||||
render json: config
|
render json: config
|
||||||
@@ -31,30 +38,71 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# GET /oauth/authorize
|
# GET /oauth/authorize
|
||||||
def authorize
|
def authorize
|
||||||
# Get parameters
|
# Get parameters (ignore forward auth tokens and other unknown params)
|
||||||
client_id = params[:client_id]
|
client_id = params[:client_id]
|
||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
state = params[:state]
|
state = params[:state]
|
||||||
nonce = params[:nonce]
|
nonce = params[:nonce]
|
||||||
scope = params[:scope] || "openid"
|
scope = params[:scope] || "openid"
|
||||||
response_type = params[:response_type]
|
response_type = params[:response_type]
|
||||||
|
code_challenge = params[:code_challenge]
|
||||||
|
code_challenge_method = params[:code_challenge_method] || "plain"
|
||||||
|
|
||||||
# Validate required parameters
|
# Validate required parameters
|
||||||
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
unless client_id.present? && redirect_uri.present? && response_type == "code"
|
||||||
render plain: "Invalid request: missing required parameters", status: :bad_request
|
error_details = []
|
||||||
|
error_details << "client_id is required" unless client_id.present?
|
||||||
|
error_details << "redirect_uri is required" unless redirect_uri.present?
|
||||||
|
error_details << "response_type must be 'code'" unless response_type == "code"
|
||||||
|
|
||||||
|
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Validate PKCE parameters if present
|
||||||
|
if code_challenge.present?
|
||||||
|
unless %w[plain S256].include?(code_challenge_method)
|
||||||
|
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||||
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
|
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
unless @application
|
unless @application
|
||||||
render plain: "Invalid client_id", status: :bad_request
|
# Log all OIDC applications for debugging
|
||||||
|
all_oidc_apps = Application.where(app_type: "oidc")
|
||||||
|
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
|
||||||
|
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
||||||
|
|
||||||
|
error_msg = if Rails.env.development?
|
||||||
|
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
|
||||||
|
else
|
||||||
|
"Invalid request: Application not found"
|
||||||
|
end
|
||||||
|
|
||||||
|
render plain: error_msg, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI
|
# Validate redirect URI
|
||||||
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||||
render plain: "Invalid redirect_uri", status: :bad_request
|
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
||||||
|
|
||||||
|
# For development, show detailed error
|
||||||
|
error_msg = if Rails.env.development?
|
||||||
|
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
|
||||||
|
else
|
||||||
|
"Invalid request: Redirect URI not registered for this application"
|
||||||
|
end
|
||||||
|
|
||||||
|
render plain: error_msg, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -66,7 +114,9 @@ class OidcController < ApplicationController
|
|||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
state: state,
|
state: state,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope
|
scope: scope,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method
|
||||||
}
|
}
|
||||||
redirect_to signin_path, alert: "Please sign in to continue"
|
redirect_to signin_path, alert: "Please sign in to continue"
|
||||||
return
|
return
|
||||||
@@ -81,18 +131,66 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
requested_scopes = scope.split(" ")
|
||||||
|
|
||||||
|
# Check if user has already granted consent for these scopes
|
||||||
|
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||||
|
if existing_consent
|
||||||
|
# User has already consented, generate authorization code directly
|
||||||
|
code = SecureRandom.urlsafe_base64(32)
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: user,
|
||||||
|
code: code,
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
scope: scope,
|
||||||
|
nonce: nonce,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect back to client with authorization code
|
||||||
|
redirect_uri = "#{redirect_uri}?code=#{code}"
|
||||||
|
redirect_uri += "&state=#{state}" if state.present?
|
||||||
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Store OAuth parameters for consent page
|
# Store OAuth parameters for consent page
|
||||||
session[:oauth_params] = {
|
session[:oauth_params] = {
|
||||||
client_id: client_id,
|
client_id: client_id,
|
||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
state: state,
|
state: state,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope
|
scope: scope,
|
||||||
|
code_challenge: code_challenge,
|
||||||
|
code_challenge_method: code_challenge_method
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render consent page
|
# Render consent page with dynamic CSP for OAuth redirect
|
||||||
@redirect_uri = redirect_uri
|
@redirect_uri = redirect_uri
|
||||||
@scopes = scope.split(" ")
|
@scopes = requested_scopes
|
||||||
|
|
||||||
|
# Add the redirect URI to CSP form-action for this specific request
|
||||||
|
# This allows the OAuth redirect to work while maintaining security
|
||||||
|
# CSP must allow the OAuth client's redirect_uri as a form submission target
|
||||||
|
if redirect_uri.present?
|
||||||
|
begin
|
||||||
|
redirect_host = URI.parse(redirect_uri).host
|
||||||
|
csp = request.content_security_policy
|
||||||
|
if csp && redirect_host
|
||||||
|
# Only modify if form_action is available and mutable
|
||||||
|
if csp.respond_to?(:form_action) && csp.form_action.respond_to?(:<<)
|
||||||
|
csp.form_action << "https://#{redirect_host}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
# Log CSP modification errors but don't fail the request
|
||||||
|
Rails.logger.warn "OAuth: Could not modify CSP for redirect_uri #{redirect_uri}: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
render :consent
|
render :consent
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -108,36 +206,49 @@ class OidcController < ApplicationController
|
|||||||
# User denied consent
|
# User denied consent
|
||||||
if params[:deny].present?
|
if params[:deny].present?
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
error_uri = "#{oauth_params[:redirect_uri]}?error=access_denied"
|
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
||||||
error_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
|
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the application
|
# Find the application
|
||||||
application = Application.find_by(client_id: oauth_params[:client_id])
|
client_id = oauth_params['client_id']
|
||||||
|
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
|
# Record user consent
|
||||||
|
requested_scopes = oauth_params['scope'].split(' ')
|
||||||
|
OidcUserConsent.upsert(
|
||||||
|
{
|
||||||
|
user_id: user.id,
|
||||||
|
application_id: application.id,
|
||||||
|
scopes_granted: requested_scopes.join(' '),
|
||||||
|
granted_at: Time.current
|
||||||
|
},
|
||||||
|
unique_by: [:user_id, :application_id]
|
||||||
|
)
|
||||||
|
|
||||||
# Generate authorization code
|
# Generate authorization code
|
||||||
code = SecureRandom.urlsafe_base64(32)
|
code = SecureRandom.urlsafe_base64(32)
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
code: code,
|
code: code,
|
||||||
redirect_uri: oauth_params[:redirect_uri],
|
redirect_uri: oauth_params['redirect_uri'],
|
||||||
scope: oauth_params[:scope],
|
scope: oauth_params['scope'],
|
||||||
|
nonce: oauth_params['nonce'],
|
||||||
|
code_challenge: oauth_params['code_challenge'],
|
||||||
|
code_challenge_method: oauth_params['code_challenge_method'],
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store nonce in the authorization code metadata if needed
|
|
||||||
# For now, we'll pass it through the code itself
|
|
||||||
|
|
||||||
# Clear OAuth params from session
|
# Clear OAuth params from session
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
|
|
||||||
# Redirect back to client with authorization code
|
# Redirect back to client with authorization code
|
||||||
redirect_uri = "#{oauth_params[:redirect_uri]}?code=#{code}"
|
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
|
||||||
redirect_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
|
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||||
|
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
end
|
end
|
||||||
@@ -146,10 +257,17 @@ class OidcController < ApplicationController
|
|||||||
def token
|
def token
|
||||||
grant_type = params[:grant_type]
|
grant_type = params[:grant_type]
|
||||||
|
|
||||||
unless grant_type == "authorization_code"
|
case grant_type
|
||||||
|
when "authorization_code"
|
||||||
|
handle_authorization_code_grant
|
||||||
|
when "refresh_token"
|
||||||
|
handle_refresh_token_grant
|
||||||
|
else
|
||||||
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
||||||
return
|
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_authorization_code_grant
|
||||||
|
|
||||||
# Get client credentials from Authorization header or params
|
# Get client credentials from Authorization header or params
|
||||||
client_id, client_secret = extract_client_credentials
|
client_id, client_secret = extract_client_credentials
|
||||||
@@ -161,7 +279,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Find and validate the application
|
# Find and validate the application
|
||||||
application = Application.find_by(client_id: client_id)
|
application = Application.find_by(client_id: client_id)
|
||||||
unless application && application.client_secret == client_secret
|
unless application && application.authenticate_client_secret(client_secret)
|
||||||
render json: { error: "invalid_client" }, status: :unauthorized
|
render json: { error: "invalid_client" }, status: :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -169,11 +287,11 @@ class OidcController < ApplicationController
|
|||||||
# Get the authorization code
|
# Get the authorization code
|
||||||
code = params[:code]
|
code = params[:code]
|
||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
|
code_verifier = params[:code_verifier]
|
||||||
|
|
||||||
auth_code = OidcAuthorizationCode.find_by(
|
auth_code = OidcAuthorizationCode.find_by(
|
||||||
application: application,
|
application: application,
|
||||||
code: code,
|
code: code
|
||||||
used: false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
unless auth_code
|
unless auth_code
|
||||||
@@ -181,6 +299,31 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Use a transaction with pessimistic locking to prevent code reuse
|
||||||
|
begin
|
||||||
|
OidcAuthorizationCode.transaction do
|
||||||
|
# Lock the record to prevent concurrent access
|
||||||
|
auth_code.lock!
|
||||||
|
|
||||||
|
# Check if code has already been used (CRITICAL: check AFTER locking)
|
||||||
|
if auth_code.used?
|
||||||
|
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it
|
||||||
|
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
|
||||||
|
|
||||||
|
# Revoke all access tokens issued from this authorization code
|
||||||
|
OidcAccessToken.where(
|
||||||
|
application: application,
|
||||||
|
user: auth_code.user,
|
||||||
|
created_at: auth_code.created_at..Time.current
|
||||||
|
).update_all(expires_at: Time.current)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Authorization code has already been used"
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Check if code is expired
|
# Check if code is expired
|
||||||
if auth_code.expires_at < Time.current
|
if auth_code.expires_at < Time.current
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||||
@@ -193,34 +336,162 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Mark code as used
|
# Validate PKCE if code challenge is present
|
||||||
|
pkce_result = validate_pkce(auth_code, code_verifier)
|
||||||
|
unless pkce_result[:valid]
|
||||||
|
render json: {
|
||||||
|
error: pkce_result[:error],
|
||||||
|
error_description: pkce_result[:error_description]
|
||||||
|
}, status: pkce_result[:status]
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark code as used BEFORE generating tokens (prevents reuse)
|
||||||
auth_code.update!(used: true)
|
auth_code.update!(used: true)
|
||||||
|
|
||||||
# Get the user
|
# Get the user
|
||||||
user = auth_code.user
|
user = auth_code.user
|
||||||
|
|
||||||
# Generate access token
|
# Generate access token record (opaque token with BCrypt hashing)
|
||||||
access_token = SecureRandom.urlsafe_base64(32)
|
access_token_record = OidcAccessToken.create!(
|
||||||
OidcAccessToken.create!(
|
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
token: access_token,
|
scope: auth_code.scope
|
||||||
scope: auth_code.scope,
|
|
||||||
expires_at: 1.hour.from_now
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate ID token
|
# Generate refresh token (opaque, with hashing)
|
||||||
id_token = OidcJwtService.generate_id_token(user, application)
|
refresh_token_record = OidcRefreshToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
oidc_access_token: access_token_record,
|
||||||
|
scope: auth_code.scope
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find user consent for this application
|
||||||
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
||||||
|
|
||||||
|
unless consent
|
||||||
|
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
|
||||||
|
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate ID token (JWT) with pairwise SID
|
||||||
|
id_token = OidcJwtService.generate_id_token(user, application, consent: consent, nonce: auth_code.nonce)
|
||||||
|
|
||||||
# Return tokens
|
# Return tokens
|
||||||
render json: {
|
render json: {
|
||||||
access_token: access_token,
|
access_token: access_token_record.plaintext_token, # Opaque token
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: 3600,
|
expires_in: application.access_token_ttl || 3600,
|
||||||
id_token: id_token,
|
id_token: id_token, # JWT
|
||||||
|
refresh_token: refresh_token_record.token, # Opaque token
|
||||||
scope: auth_code.scope
|
scope: auth_code.scope
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "invalid_grant" }, status: :bad_request
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_refresh_token_grant
|
||||||
|
# Get client credentials from Authorization header or params
|
||||||
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
|
unless client_id && client_secret
|
||||||
|
render json: { error: "invalid_client" }, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find and validate the application
|
||||||
|
application = Application.find_by(client_id: client_id)
|
||||||
|
unless application && application.authenticate_client_secret(client_secret)
|
||||||
|
render json: { error: "invalid_client" }, status: :unauthorized
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the refresh token
|
||||||
|
refresh_token = params[:refresh_token]
|
||||||
|
unless refresh_token.present?
|
||||||
|
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find the refresh token record
|
||||||
|
# Note: This is inefficient with BCrypt hashing, but necessary for security
|
||||||
|
# In production, consider adding a token prefix for faster lookup
|
||||||
|
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||||
|
rt.token_matches?(refresh_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless refresh_token_record
|
||||||
|
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if refresh token is expired
|
||||||
|
if refresh_token_record.expired?
|
||||||
|
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if refresh token is revoked
|
||||||
|
if refresh_token_record.revoked?
|
||||||
|
# If a revoked refresh token is used, it's a security issue
|
||||||
|
# Revoke all tokens in the family (token rotation attack detection)
|
||||||
|
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
||||||
|
refresh_token_record.revoke_family!
|
||||||
|
|
||||||
|
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the user
|
||||||
|
user = refresh_token_record.user
|
||||||
|
|
||||||
|
# Revoke the old refresh token (token rotation)
|
||||||
|
refresh_token_record.revoke!
|
||||||
|
|
||||||
|
# Generate new access token record (opaque token with BCrypt hashing)
|
||||||
|
new_access_token = OidcAccessToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
scope: refresh_token_record.scope
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate new refresh token (token rotation)
|
||||||
|
new_refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
oidc_access_token: new_access_token,
|
||||||
|
scope: refresh_token_record.scope,
|
||||||
|
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find user consent for this application
|
||||||
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
||||||
|
|
||||||
|
unless consent
|
||||||
|
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
|
||||||
|
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate new ID token (JWT with pairwise SID, no nonce for refresh grants)
|
||||||
|
id_token = OidcJwtService.generate_id_token(user, application, consent: consent)
|
||||||
|
|
||||||
|
# Return new tokens
|
||||||
|
render json: {
|
||||||
|
access_token: new_access_token.plaintext_token, # Opaque token
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: application.access_token_ttl || 3600,
|
||||||
|
id_token: id_token, # JWT
|
||||||
|
refresh_token: new_refresh_token.token, # Opaque token
|
||||||
|
scope: refresh_token_record.scope
|
||||||
|
}
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "invalid_grant" }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
# GET /oauth/userinfo
|
# GET /oauth/userinfo
|
||||||
def userinfo
|
def userinfo
|
||||||
@@ -231,31 +502,33 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
access_token = auth_header.sub("Bearer ", "")
|
token = auth_header.sub("Bearer ", "")
|
||||||
|
|
||||||
# Find the access token
|
# Find and validate access token (opaque token with BCrypt hashing)
|
||||||
token_record = OidcAccessToken.find_by(token: access_token)
|
access_token = OidcAccessToken.find_by_token(token)
|
||||||
unless token_record
|
unless access_token&.active?
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if token is expired
|
# Get the user (with fresh data from database)
|
||||||
if token_record.expires_at < Time.current
|
user = access_token.user
|
||||||
|
unless user
|
||||||
head :unauthorized
|
head :unauthorized
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the user
|
# Find user consent for this application to get pairwise SID
|
||||||
user = token_record.user
|
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
|
||||||
|
subject = consent&.sid || user.id.to_s
|
||||||
|
|
||||||
# Return user claims
|
# Return user claims
|
||||||
claims = {
|
claims = {
|
||||||
sub: user.id.to_s,
|
sub: subject,
|
||||||
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.email_address
|
name: user.name.presence || user.email_address
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add groups if user has any
|
# Add groups if user has any
|
||||||
@@ -263,14 +536,182 @@ class OidcController < ApplicationController
|
|||||||
claims[:groups] = user.groups.pluck(:name)
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add admin claim if user is admin
|
# Merge custom claims from groups
|
||||||
claims[:admin] = true if user.admin?
|
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)
|
||||||
|
|
||||||
|
# Merge app-specific custom claims (highest priority)
|
||||||
|
application = access_token.application
|
||||||
|
claims.merge!(application.custom_claims_for_user(user))
|
||||||
|
|
||||||
render json: claims
|
render json: claims
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /oauth/revoke
|
||||||
|
# RFC 7009 - Token Revocation
|
||||||
|
def revoke
|
||||||
|
# Get client credentials
|
||||||
|
client_id, client_secret = extract_client_credentials
|
||||||
|
|
||||||
|
unless client_id && client_secret
|
||||||
|
# RFC 7009 says we should return 200 OK even for invalid client
|
||||||
|
# But log the attempt for security monitoring
|
||||||
|
Rails.logger.warn "OAuth: Token revocation attempted with invalid client credentials"
|
||||||
|
head :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find and validate the application
|
||||||
|
application = Application.find_by(client_id: client_id)
|
||||||
|
unless application && application.authenticate_client_secret(client_secret)
|
||||||
|
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
||||||
|
head :ok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get the token to revoke
|
||||||
|
token = params[:token]
|
||||||
|
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
|
||||||
|
|
||||||
|
unless token.present?
|
||||||
|
# RFC 7009: Missing token parameter is an error
|
||||||
|
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try to find and revoke the token
|
||||||
|
# Check token type hint first for efficiency, otherwise try both
|
||||||
|
revoked = false
|
||||||
|
|
||||||
|
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
||||||
|
# Try to find as refresh token
|
||||||
|
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
||||||
|
rt.token_matches?(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
if refresh_token_record
|
||||||
|
refresh_token_record.revoke!
|
||||||
|
Rails.logger.info "OAuth: Refresh token revoked for application #{application.name}"
|
||||||
|
revoked = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
||||||
|
# Try to find as access token
|
||||||
|
access_token_record = OidcAccessToken.where(application: application).find do |at|
|
||||||
|
at.token_matches?(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
if access_token_record
|
||||||
|
access_token_record.revoke!
|
||||||
|
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
||||||
|
revoked = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# RFC 7009: Always return 200 OK, even if token was not found
|
||||||
|
# This prevents token scanning attacks
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /logout
|
||||||
|
def logout
|
||||||
|
# OpenID Connect RP-Initiated Logout
|
||||||
|
# Handle id_token_hint and post_logout_redirect_uri parameters
|
||||||
|
|
||||||
|
id_token_hint = params[:id_token_hint]
|
||||||
|
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
||||||
|
state = params[:state]
|
||||||
|
|
||||||
|
# If user is authenticated, log them out
|
||||||
|
if authenticated?
|
||||||
|
user = Current.session.user
|
||||||
|
|
||||||
|
# Send backchannel logout notifications to all connected applications
|
||||||
|
send_backchannel_logout_notifications(user)
|
||||||
|
|
||||||
|
# Invalidate the current session
|
||||||
|
Current.session&.destroy
|
||||||
|
reset_session
|
||||||
|
end
|
||||||
|
|
||||||
|
# If post_logout_redirect_uri is provided, validate and redirect
|
||||||
|
if post_logout_redirect_uri.present?
|
||||||
|
validated_uri = validate_logout_redirect_uri(post_logout_redirect_uri)
|
||||||
|
|
||||||
|
if validated_uri
|
||||||
|
redirect_uri = validated_uri
|
||||||
|
redirect_uri += "?state=#{state}" if state.present?
|
||||||
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
|
else
|
||||||
|
# Invalid redirect URI - log warning and go to default
|
||||||
|
Rails.logger.warn "OIDC Logout: Invalid post_logout_redirect_uri attempted: #{post_logout_redirect_uri}"
|
||||||
|
redirect_to root_path
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# Default redirect to home page
|
||||||
|
redirect_to root_path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def validate_pkce(auth_code, code_verifier)
|
||||||
|
# Skip PKCE validation if no code challenge was stored (legacy clients)
|
||||||
|
return { valid: true } unless auth_code.code_challenge.present?
|
||||||
|
|
||||||
|
# PKCE is required but no verifier provided
|
||||||
|
unless code_verifier.present?
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "code_verifier is required when code_challenge was provided",
|
||||||
|
status: :bad_request
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate code verifier format (base64url-encoded, 43-128 characters)
|
||||||
|
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding",
|
||||||
|
status: :bad_request
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recreate code challenge based on method
|
||||||
|
expected_challenge = case auth_code.code_challenge_method
|
||||||
|
when "plain"
|
||||||
|
code_verifier
|
||||||
|
when "S256"
|
||||||
|
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||||
|
else
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "server_error",
|
||||||
|
error_description: "Unsupported code challenge method",
|
||||||
|
status: :internal_server_error
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate the code challenge
|
||||||
|
unless auth_code.code_challenge == expected_challenge
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Invalid code verifier",
|
||||||
|
status: :bad_request
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{ valid: true }
|
||||||
|
end
|
||||||
|
|
||||||
def extract_client_credentials
|
def extract_client_credentials
|
||||||
# Try Authorization header first (Basic auth)
|
# Try Authorization header first (Basic auth)
|
||||||
if request.headers["Authorization"]&.start_with?("Basic ")
|
if request.headers["Authorization"]&.start_with?("Basic ")
|
||||||
@@ -282,4 +723,76 @@ class OidcController < ApplicationController
|
|||||||
[params[:client_id], params[:client_secret]]
|
[params[:client_id], params[:client_secret]]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_logout_redirect_uri(uri)
|
||||||
|
return nil unless uri.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
parsed_uri = URI.parse(uri)
|
||||||
|
|
||||||
|
# Only allow HTTP/HTTPS schemes (prevent javascript:, data:, etc.)
|
||||||
|
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
||||||
|
|
||||||
|
# Only allow HTTPS in production
|
||||||
|
return nil if Rails.env.production? && parsed_uri.scheme != 'https'
|
||||||
|
|
||||||
|
# Check if URI matches any registered OIDC application's redirect URIs
|
||||||
|
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
||||||
|
Application.oidc.active.find_each do |app|
|
||||||
|
# Check if this URI matches any of the app's registered redirect URIs
|
||||||
|
if app.parsed_redirect_uris.any? { |registered_uri| logout_uri_matches?(uri, registered_uri) }
|
||||||
|
return uri
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# No matching application found
|
||||||
|
nil
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if logout URI matches a registered redirect URI
|
||||||
|
# More lenient than exact match - allows same host/path with different query params
|
||||||
|
def logout_uri_matches?(provided, registered)
|
||||||
|
# Exact match is always valid
|
||||||
|
return true if provided == registered
|
||||||
|
|
||||||
|
# Parse both URIs to compare components
|
||||||
|
begin
|
||||||
|
provided_parsed = URI.parse(provided)
|
||||||
|
registered_parsed = URI.parse(registered)
|
||||||
|
|
||||||
|
# Match if scheme, host, port, and path are the same
|
||||||
|
# (allows different query params which is common for logout redirects)
|
||||||
|
provided_parsed.scheme == registered_parsed.scheme &&
|
||||||
|
provided_parsed.host == registered_parsed.host &&
|
||||||
|
provided_parsed.port == registered_parsed.port &&
|
||||||
|
provided_parsed.path == registered_parsed.path
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_backchannel_logout_notifications(user)
|
||||||
|
# Find all active OIDC consents for this user
|
||||||
|
consents = OidcUserConsent.where(user: user).includes(:application)
|
||||||
|
|
||||||
|
consents.each do |consent|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
next unless consent.application.supports_backchannel_logout?
|
||||||
|
|
||||||
|
# Enqueue background job to send logout notification
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: user.id,
|
||||||
|
application_id: consent.application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "OidcController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
||||||
|
rescue => e
|
||||||
|
# Log error but don't block logout
|
||||||
|
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PasswordsController < ApplicationController
|
|||||||
PasswordsMailer.reset(user).deliver_later
|
PasswordsMailer.reset(user).deliver_later
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
redirect_to signin_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@@ -20,7 +20,7 @@ class PasswordsController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
if @user.update(params.permit(:password, :password_confirmation))
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
@user.sessions.destroy_all
|
@user.sessions.destroy_all
|
||||||
redirect_to new_session_path, notice: "Password has been reset."
|
redirect_to signin_path, notice: "Password has been reset."
|
||||||
else
|
else
|
||||||
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
||||||
end
|
end
|
||||||
@@ -28,7 +28,8 @@ class PasswordsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_user_by_token
|
def set_user_by_token
|
||||||
@user = User.find_by_password_reset_token!(params[:token])
|
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||||
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -11,7 +10,6 @@ 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
|
||||||
@@ -19,7 +17,6 @@ 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
|
||||||
@@ -27,7 +24,6 @@ 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
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
class SessionsController < ApplicationController
|
class SessionsController < ApplicationController
|
||||||
allow_unauthenticated_access only: %i[ new create verify_totp ]
|
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ]
|
||||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||||
rate_limit to: 5, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||||
|
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
|
||||||
redirect_to signup_path if User.count.zero?
|
if User.count.zero?
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to signup_path }
|
||||||
|
format.json { render json: { error: "No users exist. Please complete initial setup." }, status: :service_unavailable }
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html # render HTML login page
|
||||||
|
format.json { render json: { error: "Authentication required" }, status: :unauthorized }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -16,24 +28,44 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Store the redirect URL from forward auth if present
|
# Store the redirect URL from forward auth if present (after validation)
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
session[:return_to_after_authenticating] = params[:rd]
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
|
session[:return_to_after_authenticating] = validated_url if validated_url
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if user is active
|
# Check if user is active
|
||||||
unless user.active?
|
unless user.active?
|
||||||
|
if user.pending_invitation?
|
||||||
|
redirect_to signin_path, alert: "Please check your email for an invitation to set up your account."
|
||||||
|
else
|
||||||
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if TOTP is required
|
# Check if TOTP is required or enabled
|
||||||
if user.totp_enabled?
|
if user.totp_required? || user.totp_enabled?
|
||||||
|
# If TOTP is required but not yet set up, redirect to setup
|
||||||
|
if user.totp_required? && !user.totp_enabled?
|
||||||
|
# Store user ID in session for TOTP setup
|
||||||
|
session[:pending_totp_setup_user_id] = user.id
|
||||||
|
# Preserve the redirect URL through TOTP setup
|
||||||
|
if params[:rd].present?
|
||||||
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
|
session[:totp_redirect_url] = validated_url if validated_url
|
||||||
|
end
|
||||||
|
redirect_to new_totp_path, alert: "Your administrator requires two-factor authentication. Please set it up now to continue."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# TOTP is enabled, proceed to verification
|
||||||
# Store user ID in session temporarily for TOTP verification
|
# Store user ID in session temporarily for TOTP verification
|
||||||
session[:pending_totp_user_id] = user.id
|
session[:pending_totp_user_id] = user.id
|
||||||
# Preserve the redirect URL through TOTP verification
|
# Preserve the redirect URL through TOTP verification (after validation)
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
session[:totp_redirect_url] = params[:rd]
|
validated_url = validate_redirect_url(params[:rd])
|
||||||
|
session[:totp_redirect_url] = validated_url if validated_url
|
||||||
end
|
end
|
||||||
redirect_to totp_verification_path(rd: params[:rd])
|
redirect_to totp_verification_path(rd: params[:rd])
|
||||||
return
|
return
|
||||||
@@ -63,6 +95,12 @@ class SessionsController < ApplicationController
|
|||||||
if request.post?
|
if request.post?
|
||||||
code = params[:code]&.strip
|
code = params[:code]&.strip
|
||||||
|
|
||||||
|
# Check if user is already authenticated (prevent duplicate submissions)
|
||||||
|
if authenticated?
|
||||||
|
redirect_to root_path, notice: "Already signed in."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Try TOTP verification first
|
# Try TOTP verification first
|
||||||
if user.verify_totp(code)
|
if user.verify_totp(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
@@ -96,6 +134,12 @@ class SessionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
# Send backchannel logout notifications before terminating session
|
||||||
|
if authenticated?
|
||||||
|
user = Current.session.user
|
||||||
|
send_backchannel_logout_notifications(user)
|
||||||
|
end
|
||||||
|
|
||||||
terminate_session
|
terminate_session
|
||||||
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
||||||
end
|
end
|
||||||
@@ -103,6 +147,196 @@ 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 profile_path, notice: "Session revoked successfully."
|
redirect_to active_sessions_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 forward auth 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 send_backchannel_logout_notifications(user)
|
||||||
|
# Find all active OIDC consents for this user
|
||||||
|
consents = OidcUserConsent.where(user: user).includes(:application)
|
||||||
|
|
||||||
|
consents.each do |consent|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
next unless consent.application.supports_backchannel_logout?
|
||||||
|
|
||||||
|
# Enqueue background job to send logout notification
|
||||||
|
BackchannelLogoutJob.perform_later(
|
||||||
|
user_id: user.id,
|
||||||
|
application_id: consent.application.id,
|
||||||
|
consent_sid: consent.sid
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "SessionsController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
||||||
|
rescue => e
|
||||||
|
# Log error but don't block logout
|
||||||
|
Rails.logger.error "SessionsController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ class TotpController < ApplicationController
|
|||||||
|
|
||||||
# GET /totp/new - Show QR code to set up TOTP
|
# GET /totp/new - Show QR code to set up TOTP
|
||||||
def new
|
def new
|
||||||
|
# Check if user is being forced to set up TOTP by admin
|
||||||
|
@totp_setup_required = session[:pending_totp_setup_user_id].present?
|
||||||
|
|
||||||
# Generate TOTP secret but don't save yet
|
# Generate TOTP secret but don't save yet
|
||||||
@totp_secret = ROTP::Base32.random
|
@totp_secret = ROTP::Base32.random
|
||||||
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
||||||
@@ -24,11 +27,22 @@ 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
|
||||||
@user.backup_codes = generate_backup_codes
|
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
||||||
@user.save!
|
@user.save!
|
||||||
|
|
||||||
# Redirect to backup codes page with success message
|
# Store plain codes temporarily in session for display after redirect
|
||||||
|
session[:temp_backup_codes] = plain_codes
|
||||||
|
|
||||||
|
# Check if this was a required setup from login
|
||||||
|
if session[:pending_totp_setup_user_id].present?
|
||||||
|
session.delete(:pending_totp_setup_user_id)
|
||||||
|
# Mark that user should be auto-signed in after viewing backup codes
|
||||||
|
session[:auto_signin_after_forced_totp] = true
|
||||||
|
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes, then you'll be signed in."
|
||||||
|
else
|
||||||
|
# Regular setup from profile
|
||||||
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."
|
||||||
|
end
|
||||||
else
|
else
|
||||||
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
||||||
end
|
end
|
||||||
@@ -36,8 +50,21 @@ 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
|
||||||
# This will be shown after password verification
|
# Check if we have temporary codes from TOTP setup
|
||||||
@backup_codes = @user.parsed_backup_codes
|
if session[:temp_backup_codes].present?
|
||||||
|
@backup_codes = session[:temp_backup_codes]
|
||||||
|
session.delete(:temp_backup_codes) # Clear after use
|
||||||
|
|
||||||
|
# Check if this was a forced TOTP setup during login
|
||||||
|
@auto_signin_pending = session[:auto_signin_after_forced_totp].present?
|
||||||
|
if @auto_signin_pending
|
||||||
|
session.delete(:auto_signin_after_forced_totp)
|
||||||
|
end
|
||||||
|
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
|
||||||
@@ -49,6 +76,40 @@ 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
|
||||||
|
|
||||||
|
# POST /totp/complete_setup - Complete forced TOTP setup and sign in
|
||||||
|
def complete_setup
|
||||||
|
# Sign in the user after they've saved their backup codes
|
||||||
|
# This is only used when admin requires TOTP and user just set it up during login
|
||||||
|
if session[:totp_redirect_url].present?
|
||||||
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
start_new_session_for @user
|
||||||
|
redirect_to after_authentication_url, notice: "Two-factor authentication enabled. Signed in successfully.", allow_other_host: true
|
||||||
|
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])
|
||||||
@@ -56,6 +117,12 @@ class TotpController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Prevent disabling if admin requires TOTP
|
||||||
|
if @user.totp_required?
|
||||||
|
redirect_to profile_path, alert: "Two-factor authentication is required by your administrator and cannot be disabled."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
@user.disable_totp!
|
@user.disable_totp!
|
||||||
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
||||||
end
|
end
|
||||||
@@ -67,7 +134,8 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def redirect_if_totp_enabled
|
def redirect_if_totp_enabled
|
||||||
if @user.totp_enabled?
|
# Allow setup if admin requires it, even if already enabled (for regeneration)
|
||||||
|
if @user.totp_enabled? && !session[:pending_totp_setup_user_id].present?
|
||||||
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -77,8 +145,4 @@ 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
|
||||||
|
|||||||
198
app/controllers/webauthn_controller.rb
Normal file
198
app/controllers/webauthn_controller.rb
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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
|
||||||
@@ -1,2 +1,32 @@
|
|||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
|
def smtp_configured?
|
||||||
|
return true if Rails.env.test?
|
||||||
|
|
||||||
|
smtp_address = ENV["SMTP_ADDRESS"]
|
||||||
|
smtp_port = ENV["SMTP_PORT"]
|
||||||
|
|
||||||
|
smtp_address.present? &&
|
||||||
|
smtp_port.present? &&
|
||||||
|
smtp_address != "localhost" &&
|
||||||
|
!smtp_address.start_with?("127.0.0.1") &&
|
||||||
|
!smtp_address.start_with?("localhost")
|
||||||
|
end
|
||||||
|
|
||||||
|
def email_delivery_method
|
||||||
|
if Rails.env.development?
|
||||||
|
ActionMailer::Base.delivery_method
|
||||||
|
else
|
||||||
|
:smtp
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def border_class_for(type)
|
||||||
|
case type.to_s
|
||||||
|
when 'notice' then 'border-green-200'
|
||||||
|
when 'alert', 'error' then 'border-red-200'
|
||||||
|
when 'warning' then 'border-yellow-200'
|
||||||
|
when 'info' then 'border-blue-200'
|
||||||
|
else 'border-gray-200'
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
69
app/helpers/claims_helper.rb
Normal file
69
app/helpers/claims_helper.rb
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
module ClaimsHelper
|
||||||
|
include ClaimsMerger
|
||||||
|
|
||||||
|
# Preview final merged claims for a user accessing an application
|
||||||
|
def preview_user_claims(user, application)
|
||||||
|
claims = {
|
||||||
|
# Standard OIDC claims
|
||||||
|
email: user.email_address,
|
||||||
|
email_verified: true,
|
||||||
|
preferred_username: user.username.presence || user.email_address,
|
||||||
|
name: user.name.presence || user.email_address
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add groups
|
||||||
|
if user.groups.any?
|
||||||
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge group custom claims (arrays are combined, not overwritten)
|
||||||
|
user.groups.each do |group|
|
||||||
|
claims = deep_merge_claims(claims, group.parsed_custom_claims)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge user custom claims (arrays are combined, other values override)
|
||||||
|
claims = deep_merge_claims(claims, user.parsed_custom_claims)
|
||||||
|
|
||||||
|
# Merge app-specific claims (arrays are combined)
|
||||||
|
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
|
claims
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get claim sources breakdown for display
|
||||||
|
def claim_sources(user, application)
|
||||||
|
sources = []
|
||||||
|
|
||||||
|
# Group claims
|
||||||
|
user.groups.each do |group|
|
||||||
|
if group.parsed_custom_claims.any?
|
||||||
|
sources << {
|
||||||
|
type: :group,
|
||||||
|
name: group.name,
|
||||||
|
claims: group.parsed_custom_claims
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# User claims
|
||||||
|
if user.parsed_custom_claims.any?
|
||||||
|
sources << {
|
||||||
|
type: :user,
|
||||||
|
name: "User Override",
|
||||||
|
claims: user.parsed_custom_claims
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# App-specific claims
|
||||||
|
app_claims = application.custom_claims_for_user(user)
|
||||||
|
if app_claims.any?
|
||||||
|
sources << {
|
||||||
|
type: :application,
|
||||||
|
name: "App-Specific (#{application.name})",
|
||||||
|
claims: app_claims
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
sources
|
||||||
|
end
|
||||||
|
end
|
||||||
24
app/javascript/controllers/application_form_controller.js
Normal file
24
app/javascript/controllers/application_form_controller.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/javascript/controllers/backup_codes_controller.js
Normal file
28
app/javascript/controllers/backup_codes_controller.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/javascript/controllers/file_drop_controller.js
Normal file
96
app/javascript/controllers/file_drop_controller.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["input", "dropzone", "preview", "previewImage", "filename", "filesize"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Prevent default drag behaviors on the whole document
|
||||||
|
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||||
|
document.body.addEventListener(eventName, this.preventDefaults, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
||||||
|
document.body.removeEventListener(eventName, this.preventDefaults, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
preventDefaults(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
dragover(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.add("border-blue-500", "bg-blue-50")
|
||||||
|
}
|
||||||
|
|
||||||
|
dragleave(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files
|
||||||
|
if (files.length > 0) {
|
||||||
|
// Set the file to the input element
|
||||||
|
this.inputTarget.files = files
|
||||||
|
this.handleFiles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFiles() {
|
||||||
|
const file = this.inputTarget.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
||||||
|
if (!validTypes.includes(file.type)) {
|
||||||
|
alert("Please upload a PNG, JPG, GIF, or SVG image")
|
||||||
|
this.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (2MB)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
alert("File size must be less than 2MB")
|
||||||
|
this.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
this.filenameTarget.textContent = file.name
|
||||||
|
this.filesizeTarget.textContent = this.formatFileSize(file.size)
|
||||||
|
|
||||||
|
// Create preview image
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
this.previewImageTarget.src = e.target.result
|
||||||
|
this.previewTarget.classList.remove("hidden")
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(e) {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
this.inputTarget.value = ""
|
||||||
|
this.previewTarget.classList.add("hidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return "0 Bytes"
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ["Bytes", "KB", "MB"]
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/javascript/controllers/flash_controller.js
Normal file
85
app/javascript/controllers/flash_controller.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/javascript/controllers/form_errors_controller.js
Normal file
89
app/javascript/controllers/form_errors_controller.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [ "submit" ]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Prevent form auto-submission when browser autofills TOTP
|
||||||
|
this.preventAutoSubmit()
|
||||||
|
|
||||||
|
// Add double-click protection
|
||||||
|
this.submitTarget.addEventListener('dblclick', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
if (this.submitTarget.disabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable submit button and show loading state
|
||||||
|
this.submitTarget.disabled = true
|
||||||
|
this.submitTarget.textContent = 'Verifying...'
|
||||||
|
this.submitTarget.classList.add('opacity-75', 'cursor-not-allowed')
|
||||||
|
|
||||||
|
// Re-enable after 10 seconds in case of network issues
|
||||||
|
setTimeout(() => {
|
||||||
|
this.submitTarget.disabled = false
|
||||||
|
this.submitTarget.textContent = 'Verify'
|
||||||
|
this.submitTarget.classList.remove('opacity-75', 'cursor-not-allowed')
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
// Allow the form to submit normally
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
preventAutoSubmit() {
|
||||||
|
// Some browsers auto-submit forms when TOTP fields are autofilled
|
||||||
|
// This prevents that behavior while still allowing manual submission
|
||||||
|
const codeInput = this.element.querySelector('input[name="code"]')
|
||||||
|
|
||||||
|
if (codeInput) {
|
||||||
|
let hasAutoSubmitted = false
|
||||||
|
|
||||||
|
codeInput.addEventListener('input', (e) => {
|
||||||
|
// Check if this looks like an auto-fill event
|
||||||
|
// Auto-fill typically fills the entire field at once
|
||||||
|
if (e.target.value.length >= 6 && !hasAutoSubmitted) {
|
||||||
|
// Don't auto-submit, let user click the button manually
|
||||||
|
hasAutoSubmitted = true
|
||||||
|
|
||||||
|
// Optionally, focus the submit button to make it obvious
|
||||||
|
this.submitTarget.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also prevent Enter key submission on TOTP field
|
||||||
|
codeInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
this.submitTarget.click()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
connect() {
|
|
||||||
this.element.textContent = "Hello World!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
81
app/javascript/controllers/json_validator_controller.js
Normal file
81
app/javascript/controllers/json_validator_controller.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/javascript/controllers/login_form_controller.js
Normal file
92
app/javascript/controllers/login_form_controller.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/javascript/controllers/mobile_sidebar_controller.js
Normal file
48
app/javascript/controllers/mobile_sidebar_controller.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/javascript/controllers/modal_controller.js
Normal file
50
app/javascript/controllers/modal_controller.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
317
app/javascript/controllers/webauthn_controller.js
Normal file
317
app/javascript/controllers/webauthn_controller.js
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/jobs/backchannel_logout_job.rb
Normal file
52
app/jobs/backchannel_logout_job.rb
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
class BackchannelLogoutJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
# Retry with exponential backoff: 1s, 5s, 25s
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
def perform(user_id:, application_id:, consent_sid:)
|
||||||
|
# Find the records
|
||||||
|
user = User.find_by(id: user_id)
|
||||||
|
application = Application.find_by(id: application_id)
|
||||||
|
consent = OidcUserConsent.find_by(sid: consent_sid)
|
||||||
|
|
||||||
|
# Validate we have all required data
|
||||||
|
unless user && application && consent
|
||||||
|
Rails.logger.warn "BackchannelLogout: Missing data - user: #{user.present?}, app: #{application.present?}, consent: #{consent.present?}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Skip if application doesn't support backchannel logout
|
||||||
|
unless application.supports_backchannel_logout?
|
||||||
|
Rails.logger.debug "BackchannelLogout: Application #{application.name} doesn't support backchannel logout"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate the logout token
|
||||||
|
logout_token = OidcJwtService.generate_logout_token(user, application, consent)
|
||||||
|
|
||||||
|
# Send HTTP POST to the application's backchannel logout URI
|
||||||
|
uri = URI.parse(application.backchannel_logout_uri)
|
||||||
|
|
||||||
|
begin
|
||||||
|
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
|
||||||
|
request = Net::HTTP::Post.new(uri.path.presence || '/')
|
||||||
|
request['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
|
request.set_form_data({ logout_token: logout_token })
|
||||||
|
http.request(request)
|
||||||
|
end
|
||||||
|
|
||||||
|
if response.code.to_i == 200
|
||||||
|
Rails.logger.info "BackchannelLogout: Successfully sent logout notification to #{application.name} (#{application.backchannel_logout_uri})"
|
||||||
|
else
|
||||||
|
Rails.logger.warn "BackchannelLogout: Application #{application.name} returned HTTP #{response.code} from #{application.backchannel_logout_uri}"
|
||||||
|
end
|
||||||
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
|
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
||||||
|
raise # Retry on timeout
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
|
||||||
|
raise # Retry on error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class OidcTokenCleanupJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
# Delete expired access tokens (keep revoked ones for audit trail)
|
||||||
|
expired_access_tokens = OidcAccessToken.where("expires_at < ?", 7.days.ago)
|
||||||
|
deleted_count = expired_access_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired access tokens"
|
||||||
|
|
||||||
|
# Delete expired refresh tokens (keep revoked ones for audit trail)
|
||||||
|
expired_refresh_tokens = OidcRefreshToken.where("expires_at < ?", 7.days.ago)
|
||||||
|
deleted_count = expired_refresh_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired refresh tokens"
|
||||||
|
|
||||||
|
# Delete old revoked tokens (after 30 days for audit trail)
|
||||||
|
old_revoked_access_tokens = OidcAccessToken.where("revoked_at < ?", 30.days.ago)
|
||||||
|
deleted_count = old_revoked_access_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked access tokens"
|
||||||
|
|
||||||
|
old_revoked_refresh_tokens = OidcRefreshToken.where("revoked_at < ?", 30.days.ago)
|
||||||
|
deleted_count = old_revoked_refresh_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked refresh tokens"
|
||||||
|
|
||||||
|
# Delete old used authorization codes (after 7 days)
|
||||||
|
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
|
||||||
|
deleted_count = old_auth_codes.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
class ApplicationMailer < ActionMailer::Base
|
||||||
default from: "from@example.com"
|
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
|
||||||
layout "mailer"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
6
app/mailers/invitations_mailer.rb
Normal file
6
app/mailers/invitations_mailer.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class InvitationsMailer < ApplicationMailer
|
||||||
|
def invite_user(user)
|
||||||
|
@user = user
|
||||||
|
mail subject: "You're invited to join Clinch", to: user.email_address
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,32 +1,73 @@
|
|||||||
class Application < ApplicationRecord
|
class Application < ApplicationRecord
|
||||||
|
has_secure_password :client_secret, validations: false
|
||||||
|
|
||||||
|
has_one_attached :icon
|
||||||
|
|
||||||
|
# Fix SVG content type after attachment
|
||||||
|
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
||||||
|
|
||||||
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 :application_user_claims, dependent: :destroy
|
||||||
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_refresh_tokens, dependent: :destroy
|
||||||
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
|
|
||||||
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 saml] }
|
inclusion: { in: %w[oidc forward_auth] }
|
||||||
validates :client_id, uniqueness: { allow_nil: true }
|
validates :client_id, uniqueness: { allow_nil: true }
|
||||||
|
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
|
||||||
|
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" }
|
||||||
|
validates :backchannel_logout_uri, format: {
|
||||||
|
with: URI::regexp(%w[http https]),
|
||||||
|
allow_nil: true,
|
||||||
|
message: "must be a valid HTTP or HTTPS URL"
|
||||||
|
}
|
||||||
|
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
||||||
|
|
||||||
|
# Icon validation using ActiveStorage validators
|
||||||
|
validate :icon_validation, if: -> { icon.attached? }
|
||||||
|
|
||||||
|
# Token TTL validations (for OIDC apps)
|
||||||
|
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||||
|
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
|
||||||
|
validates :id_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
|
normalizes :domain_pattern, with: ->(pattern) {
|
||||||
|
normalized = pattern&.strip&.downcase
|
||||||
|
normalized.blank? ? nil : normalized
|
||||||
|
}
|
||||||
|
|
||||||
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 :saml, -> { where(app_type: "saml") }
|
scope :forward_auth, -> { where(app_type: "forward_auth") }
|
||||||
|
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||||
|
|
||||||
# Type checks
|
# Type checks
|
||||||
def oidc?
|
def oidc?
|
||||||
app_type == "oidc"
|
app_type == "oidc"
|
||||||
end
|
end
|
||||||
|
|
||||||
def saml?
|
def forward_auth?
|
||||||
app_type == "saml"
|
app_type == "forward_auth"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
@@ -56,10 +97,184 @@ class Application < ApplicationRecord
|
|||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ForwardAuth helpers
|
||||||
|
def parsed_headers_config
|
||||||
|
return {} unless headers_config.present?
|
||||||
|
headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a domain matches this application's pattern (for ForwardAuth)
|
||||||
|
def matches_domain?(domain)
|
||||||
|
return false if domain.blank? || !forward_auth?
|
||||||
|
|
||||||
|
pattern = domain_pattern.gsub('.', '\.')
|
||||||
|
pattern = pattern.gsub('*', '[^.]*')
|
||||||
|
|
||||||
|
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||||
|
regex.match?(domain.downcase)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Policy determination based on user status (for ForwardAuth)
|
||||||
|
def policy_for_user(user)
|
||||||
|
return 'deny' unless active?
|
||||||
|
return 'deny' unless user.active?
|
||||||
|
|
||||||
|
# If no groups specified, bypass authentication
|
||||||
|
return 'bypass' if allowed_groups.empty?
|
||||||
|
|
||||||
|
# If user is in allowed groups, determine auth level
|
||||||
|
if user_allowed?(user)
|
||||||
|
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||||
|
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||||
|
else
|
||||||
|
'deny'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get effective header configuration (for ForwardAuth)
|
||||||
|
def effective_headers
|
||||||
|
DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate headers for a specific user (for ForwardAuth)
|
||||||
|
def headers_for_user(user)
|
||||||
|
headers = {}
|
||||||
|
effective = effective_headers
|
||||||
|
|
||||||
|
# Only generate headers that are configured (not set to nil/false)
|
||||||
|
effective.each do |key, header_name|
|
||||||
|
next unless header_name.present? # Skip disabled headers
|
||||||
|
|
||||||
|
case key
|
||||||
|
when :user, :email
|
||||||
|
headers[header_name] = user.email_address
|
||||||
|
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
|
||||||
|
|
||||||
|
headers
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if all headers are disabled (for ForwardAuth)
|
||||||
|
def headers_disabled?
|
||||||
|
headers_config.present? && effective_headers.values.all?(&:blank?)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate and return a new client secret
|
||||||
|
def generate_new_client_secret!
|
||||||
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.client_secret = secret
|
||||||
|
self.save!
|
||||||
|
secret
|
||||||
|
end
|
||||||
|
|
||||||
|
# Token TTL helper methods (for OIDC)
|
||||||
|
def access_token_expiry
|
||||||
|
(access_token_ttl || 3600).seconds.from_now
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token_expiry
|
||||||
|
(refresh_token_ttl || 2592000).seconds.from_now
|
||||||
|
end
|
||||||
|
|
||||||
|
def id_token_expiry_seconds
|
||||||
|
id_token_ttl || 3600
|
||||||
|
end
|
||||||
|
|
||||||
|
# Human-readable TTL for display
|
||||||
|
def access_token_ttl_human
|
||||||
|
duration_to_human(access_token_ttl || 3600)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh_token_ttl_human
|
||||||
|
duration_to_human(refresh_token_ttl || 2592000)
|
||||||
|
end
|
||||||
|
|
||||||
|
def id_token_ttl_human
|
||||||
|
duration_to_human(id_token_ttl || 3600)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get app-specific custom claims for a user
|
||||||
|
def custom_claims_for_user(user)
|
||||||
|
app_claim = application_user_claims.find_by(user: user)
|
||||||
|
app_claim&.parsed_custom_claims || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if this application supports backchannel logout
|
||||||
|
def supports_backchannel_logout?
|
||||||
|
backchannel_logout_uri.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a user has an active session with this application
|
||||||
|
# (i.e., has valid, non-revoked tokens)
|
||||||
|
def user_has_active_session?(user)
|
||||||
|
oidc_access_tokens.where(user: user).valid.exists? ||
|
||||||
|
oidc_refresh_tokens.where(user: user).valid.exists?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def fix_icon_content_type
|
||||||
|
return unless icon.attached?
|
||||||
|
|
||||||
|
# Fix SVG content type if it was detected incorrectly
|
||||||
|
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
|
||||||
|
icon.blob.update(content_type: "image/svg+xml")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def icon_validation
|
||||||
|
return unless icon.attached?
|
||||||
|
|
||||||
|
# Check content type
|
||||||
|
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
|
||||||
|
unless allowed_types.include?(icon.content_type)
|
||||||
|
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check file size (2MB limit)
|
||||||
|
if icon.blob.byte_size > 2.megabytes
|
||||||
|
errors.add(:icon, 'must be less than 2MB')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def duration_to_human(seconds)
|
||||||
|
if seconds < 3600
|
||||||
|
"#{seconds / 60} minutes"
|
||||||
|
elsif seconds < 86400
|
||||||
|
"#{seconds / 3600} hours"
|
||||||
|
else
|
||||||
|
"#{seconds / 86400} days"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def generate_client_credentials
|
def generate_client_credentials
|
||||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||||
self.client_secret ||= SecureRandom.urlsafe_base64(48)
|
# Generate and hash the client secret
|
||||||
|
if new_record? && client_secret.blank?
|
||||||
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.client_secret = secret
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def backchannel_logout_uri_must_be_https_in_production
|
||||||
|
return unless Rails.env.production?
|
||||||
|
return unless backchannel_logout_uri.present?
|
||||||
|
|
||||||
|
begin
|
||||||
|
uri = URI.parse(backchannel_logout_uri)
|
||||||
|
unless uri.scheme == 'https'
|
||||||
|
errors.add(:backchannel_logout_uri, 'must use HTTPS in production')
|
||||||
|
end
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
# Let the format validator handle invalid URIs
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
31
app/models/application_user_claim.rb
Normal file
31
app/models/application_user_claim.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class ApplicationUserClaim < ApplicationRecord
|
||||||
|
belongs_to :application
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
# Reserved OIDC claim names that should not be overridden
|
||||||
|
RESERVED_CLAIMS = %w[
|
||||||
|
iss sub aud exp iat nbf jti nonce azp
|
||||||
|
email email_verified preferred_username name
|
||||||
|
groups
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
validates :user_id, uniqueness: { scope: :application_id }
|
||||||
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
|
# Parse custom_claims JSON field
|
||||||
|
def parsed_custom_claims
|
||||||
|
return {} if custom_claims.blank?
|
||||||
|
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def no_reserved_claim_names
|
||||||
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
|
if reserved_used.any?
|
||||||
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
class ForwardAuthRule < ApplicationRecord
|
|
||||||
has_many :forward_auth_rule_groups, dependent: :destroy
|
|
||||||
has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group
|
|
||||||
|
|
||||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }
|
|
||||||
validates :active, inclusion: { in: [true, false] }
|
|
||||||
|
|
||||||
normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
|
|
||||||
|
|
||||||
# Scopes
|
|
||||||
scope :active, -> { where(active: true) }
|
|
||||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
|
||||||
|
|
||||||
# Check if a domain matches this rule
|
|
||||||
def matches_domain?(domain)
|
|
||||||
return false if domain.blank?
|
|
||||||
|
|
||||||
pattern = domain_pattern.gsub('.', '\.')
|
|
||||||
pattern = pattern.gsub('*', '[^.]*')
|
|
||||||
|
|
||||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
|
||||||
regex.match?(domain.downcase)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Access control for forward auth
|
|
||||||
def user_allowed?(user)
|
|
||||||
return false unless active?
|
|
||||||
return false unless user.active?
|
|
||||||
|
|
||||||
# If no groups are specified, allow all active users (bypass)
|
|
||||||
return true if allowed_groups.empty?
|
|
||||||
|
|
||||||
# Otherwise, user must be in at least one of the allowed groups
|
|
||||||
(user.groups & allowed_groups).any?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Policy determination based on user status and rule configuration
|
|
||||||
def policy_for_user(user)
|
|
||||||
return 'deny' unless active?
|
|
||||||
return 'deny' unless user.active?
|
|
||||||
|
|
||||||
# If no groups specified, bypass authentication
|
|
||||||
return 'bypass' if allowed_groups.empty?
|
|
||||||
|
|
||||||
# If user is in allowed groups, determine auth level
|
|
||||||
if user_allowed?(user)
|
|
||||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
|
||||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
|
||||||
else
|
|
||||||
'deny'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
class ForwardAuthRuleGroup < ApplicationRecord
|
|
||||||
belongs_to :forward_auth_rule
|
|
||||||
belongs_to :group
|
|
||||||
|
|
||||||
validates :forward_auth_rule_id, uniqueness: { scope: :group_id }
|
|
||||||
end
|
|
||||||
@@ -4,6 +4,31 @@ class Group < ApplicationRecord
|
|||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :applications, through: :application_groups
|
has_many :applications, through: :application_groups
|
||||||
|
|
||||||
|
# Reserved OIDC claim names that should not be overridden
|
||||||
|
RESERVED_CLAIMS = %w[
|
||||||
|
iss sub aud exp iat nbf jti nonce azp
|
||||||
|
email email_verified preferred_username name
|
||||||
|
groups
|
||||||
|
].freeze
|
||||||
|
|
||||||
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 }
|
||||||
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
|
# Parse custom_claims JSON field
|
||||||
|
def parsed_custom_claims
|
||||||
|
return {} if custom_claims.blank?
|
||||||
|
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def no_reserved_claim_names
|
||||||
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
|
if reserved_used.any?
|
||||||
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,34 +1,83 @@
|
|||||||
class OidcAccessToken < ApplicationRecord
|
class OidcAccessToken < ApplicationRecord
|
||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
has_many :oidc_refresh_tokens, dependent: :destroy
|
||||||
|
|
||||||
before_validation :generate_token, on: :create
|
before_validation :generate_token, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
validates :token, presence: true, uniqueness: true
|
validates :token, uniqueness: true, presence: true
|
||||||
|
|
||||||
scope :valid, -> { where("expires_at > ?", Time.current) }
|
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||||
|
scope :active, -> { valid }
|
||||||
|
|
||||||
|
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def revoked?
|
||||||
|
revoked_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
def active?
|
def active?
|
||||||
!expired?
|
!expired? && !revoked?
|
||||||
end
|
end
|
||||||
|
|
||||||
def revoke!
|
def revoke!
|
||||||
update!(expires_at: Time.current)
|
update!(revoked_at: Time.current)
|
||||||
|
# Also revoke associated refresh tokens
|
||||||
|
oidc_refresh_tokens.each(&:revoke!)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a plaintext token matches the hashed token
|
||||||
|
def token_matches?(plaintext_token)
|
||||||
|
return false if plaintext_token.blank?
|
||||||
|
|
||||||
|
# Use BCrypt to compare if token_digest exists
|
||||||
|
if token_digest.present?
|
||||||
|
BCrypt::Password.new(token_digest) == plaintext_token
|
||||||
|
# Fall back to direct comparison for backward compatibility
|
||||||
|
elsif token.present?
|
||||||
|
token == plaintext_token
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find by token (validates and checks if revoked)
|
||||||
|
def self.find_by_token(plaintext_token)
|
||||||
|
return nil if plaintext_token.blank?
|
||||||
|
|
||||||
|
# Find all non-revoked, non-expired tokens
|
||||||
|
valid.find_each do |access_token|
|
||||||
|
# Use BCrypt to compare (if token_digest exists) or direct comparison
|
||||||
|
if access_token.token_digest.present?
|
||||||
|
return access_token if BCrypt::Password.new(access_token.token_digest) == plaintext_token
|
||||||
|
elsif access_token.token == plaintext_token
|
||||||
|
return access_token
|
||||||
|
end
|
||||||
|
end
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_token
|
def generate_token
|
||||||
self.token ||= SecureRandom.urlsafe_base64(48)
|
return if token.present?
|
||||||
|
|
||||||
|
# Generate opaque access token
|
||||||
|
plaintext = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.plaintext_token = plaintext # Store temporarily for returning to client
|
||||||
|
self.token_digest = BCrypt::Password.create(plaintext)
|
||||||
|
# Keep token column for backward compatibility during migration
|
||||||
|
self.token = plaintext
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
self.expires_at ||= 1.hour.from_now
|
self.expires_at ||= application.access_token_expiry
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
|
|
||||||
validates :code, presence: true, uniqueness: true
|
validates :code, presence: true, uniqueness: true
|
||||||
validates :redirect_uri, presence: true
|
validates :redirect_uri, presence: true
|
||||||
|
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
|
||||||
|
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
||||||
|
|
||||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
@@ -23,6 +25,10 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
update!(used: true)
|
update!(used: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def uses_pkce?
|
||||||
|
code_challenge.present?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
@@ -32,4 +38,11 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
def set_expiry
|
def set_expiry
|
||||||
self.expires_at ||= 10.minutes.from_now
|
self.expires_at ||= 10.minutes.from_now
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_code_challenge_format
|
||||||
|
# PKCE code challenge should be base64url-encoded, 43-128 characters
|
||||||
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
|
errors.add(:code_challenge, "must be 43-128 characters of base64url encoding")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
87
app/models/oidc_refresh_token.rb
Normal file
87
app/models/oidc_refresh_token.rb
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
class OidcRefreshToken < ApplicationRecord
|
||||||
|
belongs_to :application
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :oidc_access_token
|
||||||
|
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
|
||||||
|
|
||||||
|
before_validation :generate_token, on: :create
|
||||||
|
before_validation :set_expiry, on: :create
|
||||||
|
before_validation :set_token_family_id, on: :create
|
||||||
|
|
||||||
|
validates :token_digest, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||||
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||||
|
scope :active, -> { valid }
|
||||||
|
|
||||||
|
# For token rotation detection (prevents reuse attacks)
|
||||||
|
scope :in_family, ->(family_id) { where(token_family_id: family_id) }
|
||||||
|
|
||||||
|
attr_accessor :token # Store plaintext token temporarily for returning to client
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
expires_at <= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoked?
|
||||||
|
revoked_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
!expired? && !revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke!
|
||||||
|
update!(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Revoke all refresh tokens in the same family (token rotation security)
|
||||||
|
def revoke_family!
|
||||||
|
return unless token_family_id.present?
|
||||||
|
|
||||||
|
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verify a plaintext token against the stored digest
|
||||||
|
def self.find_by_token(plaintext_token)
|
||||||
|
return nil if plaintext_token.blank?
|
||||||
|
|
||||||
|
# Try to find tokens that could match (we can't search by hash directly)
|
||||||
|
# This is less efficient but necessary with BCrypt
|
||||||
|
# In production, you might want to add a token prefix or other optimization
|
||||||
|
all.find do |refresh_token|
|
||||||
|
refresh_token.token_matches?(plaintext_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_matches?(plaintext_token)
|
||||||
|
return false if plaintext_token.blank? || token_digest.blank?
|
||||||
|
|
||||||
|
BCrypt::Password.new(token_digest) == plaintext_token
|
||||||
|
rescue BCrypt::Errors::InvalidHash
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_token
|
||||||
|
# Generate a secure random token
|
||||||
|
plaintext = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.token = plaintext # Store temporarily for returning to client
|
||||||
|
|
||||||
|
# Hash it with BCrypt for storage
|
||||||
|
self.token_digest = BCrypt::Password.create(plaintext)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_expiry
|
||||||
|
# Use application's configured refresh token TTL
|
||||||
|
self.expires_at ||= application.refresh_token_expiry
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_token_family_id
|
||||||
|
# Use a random ID to group tokens in the same rotation chain
|
||||||
|
# This helps detect token reuse attacks
|
||||||
|
self.token_family_id ||= SecureRandom.random_number(2**31)
|
||||||
|
end
|
||||||
|
end
|
||||||
62
app/models/oidc_user_consent.rb
Normal file
62
app/models/oidc_user_consent.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
class OidcUserConsent < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :application
|
||||||
|
|
||||||
|
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
||||||
|
validates :user_id, uniqueness: { scope: :application_id }
|
||||||
|
|
||||||
|
before_validation :set_granted_at, on: :create
|
||||||
|
before_validation :set_sid, on: :create
|
||||||
|
|
||||||
|
# Parse scopes_granted into an array
|
||||||
|
def scopes
|
||||||
|
scopes_granted.split(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set scopes from an array
|
||||||
|
def scopes=(scope_array)
|
||||||
|
self.scopes_granted = Array(scope_array).uniq.join(' ')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if this consent covers the requested scopes
|
||||||
|
def covers_scopes?(requested_scopes)
|
||||||
|
requested = Array(requested_scopes).map(&:to_s)
|
||||||
|
granted = scopes
|
||||||
|
|
||||||
|
# All requested scopes must be included in granted scopes
|
||||||
|
(requested - granted).empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a human-readable list of scopes
|
||||||
|
def formatted_scopes
|
||||||
|
scopes.map do |scope|
|
||||||
|
case scope
|
||||||
|
when 'openid'
|
||||||
|
'Basic authentication'
|
||||||
|
when 'profile'
|
||||||
|
'Profile information'
|
||||||
|
when 'email'
|
||||||
|
'Email address'
|
||||||
|
when 'groups'
|
||||||
|
'Group membership'
|
||||||
|
else
|
||||||
|
scope.humanize
|
||||||
|
end
|
||||||
|
end.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find consent by SID
|
||||||
|
def self.find_by_sid(sid)
|
||||||
|
find_by(sid: sid)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_granted_at
|
||||||
|
self.granted_at ||= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_sid
|
||||||
|
self.sid ||= SecureRandom.uuid
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,17 +3,40 @@ 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 :application_user_claims, 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, expires_in: 7.days
|
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||||
generates_token_for :password_reset, expires_in: 1.hour
|
updated_at
|
||||||
generates_token_for :magic_login, expires_in: 15.minutes
|
end
|
||||||
|
|
||||||
|
generates_token_for :password_reset, expires_in: 1.hour do
|
||||||
|
updated_at
|
||||||
|
end
|
||||||
|
|
||||||
|
generates_token_for :magic_login, expires_in: 15.minutes do
|
||||||
|
last_sign_in_at
|
||||||
|
end
|
||||||
|
|
||||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||||
|
normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
|
||||||
|
|
||||||
|
# Reserved OIDC claim names that should not be overridden
|
||||||
|
RESERVED_CLAIMS = %w[
|
||||||
|
iss sub aud exp iat nbf jti nonce azp
|
||||||
|
email email_verified preferred_username name
|
||||||
|
groups
|
||||||
|
].freeze
|
||||||
|
|
||||||
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
||||||
format: { with: URI::MailTo::EMAIL_REGEXP }
|
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||||
|
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
|
||||||
|
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" },
|
||||||
|
length: { minimum: 2, maximum: 30 }
|
||||||
validates :password, length: { minimum: 8 }, allow_nil: true
|
validates :password, length: { minimum: 8 }, allow_nil: true
|
||||||
|
validate :no_reserved_claim_names
|
||||||
|
|
||||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||||
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
||||||
@@ -34,7 +57,9 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def disable_totp!
|
def disable_totp!
|
||||||
update!(totp_secret: nil, totp_required: false, backup_codes: nil)
|
# Note: This does NOT clear totp_required flag
|
||||||
|
# Admins control that flag via admin panel, users cannot remove admin-required 2FA
|
||||||
|
update!(totp_secret: nil, backup_codes: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
def totp_provisioning_uri(issuer: "Clinch")
|
def totp_provisioning_uri(issuer: "Clinch")
|
||||||
@@ -56,24 +81,164 @@ 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?
|
||||||
|
|
||||||
codes = JSON.parse(backup_codes)
|
# Rate limiting: prevent brute force attacks on backup codes
|
||||||
if codes.include?(code)
|
if rate_limit_backup_code_verification?
|
||||||
codes.delete(code)
|
Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
|
||||||
update(backup_codes: codes.to_json)
|
return false
|
||||||
|
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
|
||||||
|
|
||||||
def parsed_backup_codes
|
# Rate limiting for backup code verification to prevent brute force attacks
|
||||||
return [] unless backup_codes.present?
|
def rate_limit_backup_code_verification?
|
||||||
JSON.parse(backup_codes)
|
# Use Rails.cache to track failed attempts
|
||||||
|
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
|
||||||
|
|
||||||
|
def has_oidc_consent?(application, requested_scopes)
|
||||||
|
oidc_user_consents
|
||||||
|
.where(application: application)
|
||||||
|
.find { |consent| consent.covers_scopes?(requested_scopes) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_consent!(application)
|
||||||
|
consent = oidc_user_consents.find_by(application: application)
|
||||||
|
consent&.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke_all_consents!
|
||||||
|
oidc_user_consents.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parse custom_claims JSON field
|
||||||
|
def parsed_custom_claims
|
||||||
|
return {} if custom_claims.blank?
|
||||||
|
custom_claims.is_a?(Hash) ? custom_claims : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get fully merged claims for a specific application
|
||||||
|
def merged_claims_for_application(application)
|
||||||
|
merged = {}
|
||||||
|
|
||||||
|
# Start with group claims (in order)
|
||||||
|
groups.each do |group|
|
||||||
|
merged.merge!(group.parsed_custom_claims)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge user global claims
|
||||||
|
merged.merge!(parsed_custom_claims)
|
||||||
|
|
||||||
|
# Merge app-specific claims (highest priority)
|
||||||
|
merged.merge!(application.custom_claims_for_user(self))
|
||||||
|
|
||||||
|
merged
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def no_reserved_claim_names
|
||||||
|
return if custom_claims.blank?
|
||||||
|
|
||||||
|
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
||||||
|
if reserved_used.any?
|
||||||
|
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def generate_backup_codes
|
def generate_backup_codes
|
||||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
# Generate plain codes for user to see/save
|
||||||
|
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
|
||||||
|
|||||||
96
app/models/webauthn_credential.rb
Normal file
96
app/models/webauthn_credential.rb
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
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
|
||||||
35
app/services/concerns/claims_merger.rb
Normal file
35
app/services/concerns/claims_merger.rb
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
module ClaimsMerger
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
# Deep merge claims, combining arrays instead of overwriting them
|
||||||
|
# This ensures that array values (like roles) are combined across group/user/app claims
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# base = { "roles" => ["user"], "level" => 1 }
|
||||||
|
# incoming = { "roles" => ["admin"], "department" => "IT" }
|
||||||
|
# deep_merge_claims(base, incoming)
|
||||||
|
# # => { "roles" => ["user", "admin"], "level" => 1, "department" => "IT" }
|
||||||
|
def deep_merge_claims(base, incoming)
|
||||||
|
result = base.dup
|
||||||
|
|
||||||
|
incoming.each do |key, value|
|
||||||
|
if result.key?(key)
|
||||||
|
# If both values are arrays, combine them (union to avoid duplicates)
|
||||||
|
if result[key].is_a?(Array) && value.is_a?(Array)
|
||||||
|
result[key] = (result[key] + value).uniq
|
||||||
|
# If both values are hashes, recursively merge them
|
||||||
|
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
|
||||||
|
result[key] = deep_merge_claims(result[key], value)
|
||||||
|
else
|
||||||
|
# Otherwise, incoming value wins (override)
|
||||||
|
result[key] = value
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# New key, just add it
|
||||||
|
result[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
class OidcJwtService
|
class OidcJwtService
|
||||||
|
extend ClaimsMerger
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# Generate an ID token (JWT) for the user
|
||||||
def generate_id_token(user, application, nonce: nil)
|
def generate_id_token(user, application, consent: nil, nonce: nil)
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
|
ttl = application.id_token_expiry_seconds
|
||||||
|
|
||||||
|
# Use pairwise SID from consent if available, fallback to user ID
|
||||||
|
subject = consent&.sid || user.id.to_s
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
sub: user.id.to_s,
|
sub: subject,
|
||||||
aud: application.client_id,
|
aud: application.client_id,
|
||||||
exp: now + 3600, # 1 hour
|
exp: now + ttl,
|
||||||
iat: now,
|
iat: now,
|
||||||
email: user.email_address,
|
email: user.email_address,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
preferred_username: user.email_address,
|
preferred_username: user.username.presence || user.email_address,
|
||||||
name: user.email_address
|
name: user.name.presence || user.email_address
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||||
@@ -24,9 +31,41 @@ class OidcJwtService
|
|||||||
payload[:groups] = user.groups.pluck(:name)
|
payload[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Add admin claim if user is admin
|
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||||
payload[:admin] = true if user.admin?
|
user.groups.each do |group|
|
||||||
|
payload = deep_merge_claims(payload, group.parsed_custom_claims)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merge custom claims from user (arrays are combined, other values override)
|
||||||
|
payload = deep_merge_claims(payload, user.parsed_custom_claims)
|
||||||
|
|
||||||
|
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||||
|
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
|
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate a backchannel logout token (JWT)
|
||||||
|
# Per OIDC Back-Channel Logout spec, this token:
|
||||||
|
# - MUST include iss, aud, iat, jti, events claims
|
||||||
|
# - MUST include sub or sid (or both) - we always include both
|
||||||
|
# - MUST NOT include nonce claim
|
||||||
|
def generate_logout_token(user, application, consent)
|
||||||
|
now = Time.current.to_i
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
iss: issuer_url,
|
||||||
|
sub: consent.sid, # Pairwise subject identifier
|
||||||
|
aud: application.client_id,
|
||||||
|
iat: now,
|
||||||
|
jti: SecureRandom.uuid, # Unique identifier for this logout token
|
||||||
|
sid: consent.sid, # Session ID - always included for granular logout
|
||||||
|
events: {
|
||||||
|
"http://schemas.openid.net/event/backchannel-logout" => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Important: Do NOT include nonce in logout tokens (spec requirement)
|
||||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -55,7 +94,14 @@ class OidcJwtService
|
|||||||
def issuer_url
|
def issuer_url
|
||||||
# In production, this should come from ENV or config
|
# In production, this should come from ENV or config
|
||||||
# For now, we'll use a placeholder that can be overridden
|
# For now, we'll use a placeholder that can be overridden
|
||||||
ENV.fetch("CLINCH_HOST", "http://localhost:3000")
|
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
||||||
|
# Ensure URL has protocol - use https:// in production, http:// in development
|
||||||
|
if host.match?(/^https?:\/\//)
|
||||||
|
host
|
||||||
|
else
|
||||||
|
protocol = Rails.env.production? ? "https" : "http"
|
||||||
|
"#{protocol}://#{host}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -63,17 +109,37 @@ class OidcJwtService
|
|||||||
# Get or generate RSA private key
|
# Get or generate RSA private key
|
||||||
def private_key
|
def private_key
|
||||||
@private_key ||= begin
|
@private_key ||= begin
|
||||||
|
key_source = nil
|
||||||
|
|
||||||
# Try ENV variable first (best for Docker/Kamal)
|
# Try ENV variable first (best for Docker/Kamal)
|
||||||
if ENV["OIDC_PRIVATE_KEY"].present?
|
if ENV["OIDC_PRIVATE_KEY"].present?
|
||||||
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"])
|
key_source = ENV["OIDC_PRIVATE_KEY"]
|
||||||
# Then try Rails credentials
|
# Then try Rails credentials
|
||||||
elsif Rails.application.credentials.oidc_private_key.present?
|
elsif Rails.application.credentials.oidc_private_key.present?
|
||||||
OpenSSL::PKey::RSA.new(Rails.application.credentials.oidc_private_key)
|
key_source = Rails.application.credentials.oidc_private_key
|
||||||
|
end
|
||||||
|
|
||||||
|
if key_source.present?
|
||||||
|
begin
|
||||||
|
# Handle both actual newlines and escaped \n sequences
|
||||||
|
# Some .env loaders may escape newlines, so we need to convert them back
|
||||||
|
key_data = key_source.gsub("\\n", "\n")
|
||||||
|
OpenSSL::PKey::RSA.new(key_data)
|
||||||
|
rescue OpenSSL::PKey::RSAError => e
|
||||||
|
Rails.logger.error "OIDC: Failed to load private key: #{e.message}"
|
||||||
|
Rails.logger.error "OIDC: Key source length: #{key_source.length}, starts with: #{key_source[0..50]}"
|
||||||
|
raise "Invalid OIDC private key format. Please ensure the key is in PEM format with proper newlines."
|
||||||
|
end
|
||||||
else
|
else
|
||||||
# Generate a new key for development
|
# In production, we should never generate a key on the fly
|
||||||
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials
|
# because it would be different across servers/deployments
|
||||||
|
if Rails.env.production?
|
||||||
|
raise "OIDC private key not configured. Set OIDC_PRIVATE_KEY environment variable or add to Rails credentials."
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate a new key for development/test only
|
||||||
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
||||||
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable in production!"
|
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable for consistency across restarts"
|
||||||
OpenSSL::PKey::RSA.new(2048)
|
OpenSSL::PKey::RSA.new(2048)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
114
app/views/active_sessions/show.html.erb
Normal file
114
app/views/active_sessions/show.html.erb
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<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>
|
||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
||||||
<% if application.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<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" %>
|
||||||
@@ -34,16 +17,76 @@
|
|||||||
<%= 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 :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<% if application.icon.attached? %>
|
||||||
|
<div class="mt-2 mb-3 flex items-center gap-4">
|
||||||
|
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
<p class="font-medium">Current icon</p>
|
||||||
|
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mt-2" data-controller="file-drop">
|
||||||
|
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
|
||||||
|
data-file-drop-target="dropzone"
|
||||||
|
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop">
|
||||||
|
<div class="space-y-1 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||||
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex text-sm text-gray-600">
|
||||||
|
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<%= form.file_field :icon,
|
||||||
|
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
||||||
|
class: "sr-only",
|
||||||
|
data: { file_drop_target: "input", action: "change->file-drop#handleFiles" } %>
|
||||||
|
</label>
|
||||||
|
<p class="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-file-drop-target="preview" class="mt-3 hidden">
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200">
|
||||||
|
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p>
|
||||||
|
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
disabled: application.persisted?,
|
||||||
|
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" style="<%= 'display: none;' unless application.oidc? || !application.persisted? %>">
|
<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">
|
||||||
<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>
|
||||||
@@ -51,6 +94,108 @@
|
|||||||
<%= 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>
|
||||||
|
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.url_field :backchannel_logout_uri, 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://app.example.com/oidc/backchannel-logout" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
|
||||||
|
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||||
|
Leave blank if the application doesn't support backchannel logout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, 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-xs text-gray-500">
|
||||||
|
Range: 5 min - 24 hours
|
||||||
|
<br>Default: 1 hour (3600s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, 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-xs text-gray-500">
|
||||||
|
Range: 1 day - 90 days
|
||||||
|
<br>Default: 30 days (2592000s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, 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-xs text-gray-500">
|
||||||
|
Range: 5 min - 24 hours
|
||||||
|
<br>Default: 1 hour (3600s)
|
||||||
|
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
|
||||||
|
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
|
||||||
|
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
||||||
|
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
|
||||||
|
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
||||||
|
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forward Auth-specific fields -->
|
||||||
|
<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">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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 :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||||
|
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
|
||||||
|
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 class="font-medium">Optional: Customize header names sent to your application.</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='{"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>
|
||||||
|
</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>
|
||||||
@@ -82,18 +227,3 @@
|
|||||||
</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');
|
|
||||||
|
|
||||||
if (appTypeSelect && oidcFields) {
|
|
||||||
appTypeSelect.addEventListener('change', function() {
|
|
||||||
if (this.value === 'oidc') {
|
|
||||||
oidcFields.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
oidcFields.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700">Manage OIDC applications.</p>
|
<p class="mt-2 text-sm text-gray-700">Manage OIDC Clients.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
<%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
<%= link_to "New Application", new_admin_application_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<table class="min-w-full divide-y divide-gray-300">
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Application</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||||
@@ -28,7 +28,18 @@
|
|||||||
<% @applications.each do |application| %>
|
<% @applications.each do |application| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<% if application.icon.attached? %>
|
||||||
|
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 flex-shrink-0", alt: "#{application.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="h-10 w-10 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
||||||
@@ -37,6 +48,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" %>
|
||||||
|
<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>
|
||||||
<% when "saml" %>
|
<% when "saml" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
<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 %>
|
||||||
@@ -56,9 +69,11 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<div class="flex justify-end space-x-3">
|
||||||
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<%= link_to "View", admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900" %>
|
<%= link_to "Edit", edit_admin_application_path(application), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
|
<%= button_to "Delete", admin_application_path(application), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this application?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="sm:flex sm:items-center sm:justify-between">
|
<% if flash[:client_id] && flash[:client_secret] %>
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||||
|
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
|
||||||
|
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
||||||
|
</div>
|
||||||
|
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
||||||
|
<div class="mt-3">
|
||||||
|
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||||
|
</div>
|
||||||
|
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="sm:flex sm:items-start sm:justify-between">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<% if @application.icon.attached? %>
|
||||||
|
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{@application.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
||||||
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
|
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
|
||||||
</div>
|
</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" %>
|
||||||
<%= 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" %>
|
||||||
@@ -27,8 +55,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 "saml" %>
|
<% when "forward_auth" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,6 +70,16 @@
|
|||||||
<% 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>
|
||||||
@@ -51,10 +89,11 @@
|
|||||||
<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">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Credentials</h3>
|
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Configuration</h3>
|
||||||
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
||||||
</div>
|
</div>
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
|
<% unless flash[:client_id] && flash[:client_secret] %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
@@ -64,9 +103,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_secret %></code>
|
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
||||||
|
🔒 Client secret is stored securely and cannot be displayed
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
To get a new client secret, use the "Regenerate Credentials" button above.
|
||||||
|
</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
@@ -79,6 +124,56 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">
|
||||||
|
Backchannel Logout URI
|
||||||
|
<% if @application.supports_backchannel_logout? %>
|
||||||
|
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Enabled</span>
|
||||||
|
<% end %>
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @application.backchannel_logout_uri.present? %>
|
||||||
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400 italic">Not configured</span>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
||||||
|
<% if @application.forward_auth? %>
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Forward Auth Configuration</h3>
|
||||||
|
<dl class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs"><%= @application.domain_pattern %></code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<% if @application.headers_config.present? && @application.headers_config.any? %>
|
||||||
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
||||||
|
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<% content_for :title, "Edit Forward Auth Rule" %>
|
|
||||||
|
|
||||||
<div class="md:flex md:items-center md:justify-between">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
|
||||||
Edit Forward Auth Rule
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
|
||||||
<%= render "shared/form_errors", form: form %>
|
|
||||||
|
|
||||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
|
||||||
<div class="px-4 py-6 sm:p-8">
|
|
||||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
|
||||||
<div class="mt-2">
|
|
||||||
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
|
||||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
|
||||||
<div class="mt-2">
|
|
||||||
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-full">
|
|
||||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
|
||||||
Groups
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 space-y-2">
|
|
||||||
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
|
||||||
{ selected: @forward_auth_rule.allowed_groups.map(&:id), prompt: "Select groups (leave empty for bypass)" },
|
|
||||||
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
|
||||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
|
||||||
<%= link_to "Cancel", admin_forward_auth_rule_path(@forward_auth_rule), class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
|
||||||
<%= form.submit "Update Rule", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<% content_for :title, "Forward Auth Rules" %>
|
|
||||||
|
|
||||||
<div class="sm:flex sm:items-center">
|
|
||||||
<div class="sm:flex-auto">
|
|
||||||
<h1 class="text-base font-semibold leading-6 text-gray-900">Forward Auth Rules</h1>
|
|
||||||
<p class="mt-2 text-sm text-gray-700">A list of all forward authentication rules for domain-based access control.</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
|
|
||||||
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 flow-root">
|
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
|
||||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
|
||||||
<% if @forward_auth_rules.any? %>
|
|
||||||
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
|
||||||
<table class="min-w-full divide-y divide-gray-300">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Domain Pattern</th>
|
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
|
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
|
||||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
|
|
||||||
<span class="sr-only">Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
|
||||||
<% @forward_auth_rules.each do |rule| %>
|
|
||||||
<tr>
|
|
||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
|
|
||||||
<%= rule.domain_pattern %>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-4 text-sm text-gray-500">
|
|
||||||
<% if rule.allowed_groups.any? %>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<% rule.allowed_groups.each do |group| %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
|
||||||
<%= group.name %>
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
|
||||||
Bypass (All Users)
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-4 text-sm text-gray-500">
|
|
||||||
<% if rule.active? %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
<% else %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
|
||||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
|
||||||
<%= link_to "Delete", admin_forward_auth_rule_path(rule),
|
|
||||||
data: {
|
|
||||||
turbo_method: :delete,
|
|
||||||
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
|
||||||
},
|
|
||||||
class: "text-red-600 hover:text-red-900" %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
<h3 class="mt-2 text-sm font-semibold text-gray-900">No forward auth rules</h3>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a new forward authentication rule.</p>
|
|
||||||
<div class="mt-6">
|
|
||||||
<%= link_to "Add rule", new_admin_forward_auth_rule_path, class: "inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<% content_for :title, "New Forward Auth Rule" %>
|
|
||||||
|
|
||||||
<div class="md:flex md:items-center md:justify-between">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
|
||||||
New Forward Auth Rule
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
|
||||||
<%= render "shared/form_errors", form: form %>
|
|
||||||
|
|
||||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
|
||||||
<div class="px-4 py-6 sm:p-8">
|
|
||||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
|
||||||
<div class="mt-2">
|
|
||||||
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
|
||||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sm:col-span-4">
|
|
||||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
|
||||||
<div class="mt-2">
|
|
||||||
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-full">
|
|
||||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
|
||||||
Groups
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 space-y-2">
|
|
||||||
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
|
||||||
{ prompt: "Select groups (leave empty for bypass)" },
|
|
||||||
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
|
||||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
|
||||||
<%= link_to "Cancel", admin_forward_auth_rules_path, class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
|
||||||
<%= form.submit "Create Rule", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
<% content_for :title, "Forward Auth Rule: #{@forward_auth_rule.domain_pattern}" %>
|
|
||||||
|
|
||||||
<div class="md:flex md:items-center md:justify-between">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
|
||||||
<%= @forward_auth_rule.domain_pattern %>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex md:ml-4 md:mt-0">
|
|
||||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
|
||||||
<%= link_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule),
|
|
||||||
data: {
|
|
||||||
turbo_method: :delete,
|
|
||||||
turbo_confirm: "Are you sure you want to delete this forward auth rule?"
|
|
||||||
},
|
|
||||||
class: "ml-3 inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
|
||||||
<div class="px-4 py-5 sm:px-6">
|
|
||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Rule Details</h3>
|
|
||||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Forward authentication rule configuration.</p>
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-gray-200">
|
|
||||||
<dl>
|
|
||||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
|
||||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @forward_auth_rule.domain_pattern %></code>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
|
||||||
<% if @forward_auth_rule.active? %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
<% else %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700">
|
|
||||||
Inactive
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Access Policy</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
|
||||||
<% if @allowed_groups.any? %>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-sm">Only users in these groups are allowed access:</p>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<% @allowed_groups.each do |group| %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">
|
|
||||||
<%= group.name %>
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
|
|
||||||
Bypass - All authenticated users allowed
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
|
||||||
<%= @forward_auth_rule.created_at.strftime("%B %d, %Y at %I:%M %p") %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
|
||||||
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
|
||||||
<%= @forward_auth_rule.updated_at.strftime("%B %d, %Y at %I:%M %p") %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8">
|
|
||||||
<div class="bg-blue-50 border-l-4 border-blue-400 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
||||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-sm font-medium text-blue-800">How this rule works</h3>
|
|
||||||
<div class="mt-2 text-sm text-blue-700">
|
|
||||||
<ul class="list-disc list-inside space-y-1">
|
|
||||||
<li>This rule matches domains that fit the pattern: <code class="bg-blue-100 px-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></li>
|
|
||||||
<% if @allowed_groups.any? %>
|
|
||||||
<li>Only users belonging to the specified groups will be granted access</li>
|
|
||||||
<li>Users will be required to authenticate with password (and 2FA if enabled)</li>
|
|
||||||
<% else %>
|
|
||||||
<li>All authenticated users will be granted access (bypass mode)</li>
|
|
||||||
<% end %>
|
|
||||||
<li>Inactive rules are ignored during authentication</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,22 +1,5 @@
|
|||||||
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||||
<% if group.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<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" %>
|
||||||
@@ -49,6 +32,27 @@
|
|||||||
<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" %>
|
||||||
|
|||||||
@@ -39,9 +39,11 @@
|
|||||||
<%= pluralize(group.applications.count, "app") %>
|
<%= pluralize(group.applications.count, "app") %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<div class="flex justify-end space-x-3">
|
||||||
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900" %>
|
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||||
|
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
185
app/views/admin/users/_application_claims.html.erb
Normal file
185
app/views/admin/users/_application_claims.html.erb
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<% oidc_apps = applications.select(&:oidc?) %>
|
||||||
|
<% forward_auth_apps = applications.select(&:forward_auth?) %>
|
||||||
|
|
||||||
|
<!-- OIDC Apps: Custom Claims -->
|
||||||
|
<% if oidc_apps.any? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<% oidc_apps.each do |app| %>
|
||||||
|
<% app_claim = user.application_user_claims.find_by(application: app) %>
|
||||||
|
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
|
||||||
|
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700">
|
||||||
|
OIDC
|
||||||
|
</span>
|
||||||
|
<% if app_claim&.custom_claims&.any? %>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-700">
|
||||||
|
<%= app_claim.custom_claims.keys.count %> claim(s)
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<%= form_with url: update_application_claims_admin_user_path(user), method: :post, class: "space-y-4", data: { controller: "json-validator" } do |form| %>
|
||||||
|
<%= hidden_field_tag :application_id, app.id %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Custom Claims (JSON)</label>
|
||||||
|
<%= text_area_tag :custom_claims,
|
||||||
|
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
|
||||||
|
rows: 8,
|
||||||
|
class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
|
||||||
|
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
|
||||||
|
data: {
|
||||||
|
action: "input->json-validator#validate blur->json-validator#format",
|
||||||
|
json_validator_target: "textarea"
|
||||||
|
} %>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<p class="text-xs text-gray-600">
|
||||||
|
Example for <%= app.name %>: Add claims that this app specifically needs to read.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-600">
|
||||||
|
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 px-1 rounded">groups</code>, <code class="bg-amber-50 px-1 rounded">email</code>, <code class="bg-amber-50 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 px-1 rounded">kavita_groups</code> instead.
|
||||||
|
</p>
|
||||||
|
<div data-json-validator-target="status" class="text-xs font-medium"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<%= button_tag type: :submit, class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" do %>
|
||||||
|
<%= app_claim ? "Update" : "Add" %> Claims
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if app_claim %>
|
||||||
|
<%= button_to "Remove Override",
|
||||||
|
delete_application_claims_admin_user_path(user, application_id: app.id),
|
||||||
|
method: :delete,
|
||||||
|
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
|
||||||
|
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" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Preview merged claims -->
|
||||||
|
<div class="mt-4 border-t pt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3">
|
||||||
|
<pre class="text-xs font-mono text-gray-800 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-2">
|
||||||
|
<summary class="cursor-pointer text-xs text-gray-600 hover:text-gray-900">Show claim sources</summary>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<% claim_sources(user, app).each do |source| %>
|
||||||
|
<div class="flex gap-2 items-start text-xs">
|
||||||
|
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 text-blue-700' : (source[:type] == :user ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700') %>">
|
||||||
|
<%= source[:name] %>
|
||||||
|
</span>
|
||||||
|
<code class="text-gray-700"><%= source[:claims].to_json %></code>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- ForwardAuth Apps: Headers Preview -->
|
||||||
|
<% if forward_auth_apps.any? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<% forward_auth_apps.each do |app| %>
|
||||||
|
<details class="border rounded-lg">
|
||||||
|
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-medium text-gray-900"><%= app.name %></span>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
|
||||||
|
FORWARD AUTH
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
<%= app.domain_pattern %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 border">
|
||||||
|
<% headers = app.headers_for_user(user) %>
|
||||||
|
<% if headers.any? %>
|
||||||
|
<dl class="space-y-2 text-xs font-mono">
|
||||||
|
<% headers.each do |header_name, value| %>
|
||||||
|
<div class="flex">
|
||||||
|
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
|
||||||
|
<dd class="text-gray-800 flex-1"><%= value %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-xs text-gray-500 italic">All headers disabled for this application.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
|
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if user.groups.any? %>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-2">User's Groups</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% user.groups.each do |group| %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= group.name %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
|
||||||
|
<div class="mt-12 border-t pt-8">
|
||||||
|
<div class="text-center py-12 bg-gray-50 rounded-lg">
|
||||||
|
<p class="text-gray-500">No active applications found.</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -1,28 +1,23 @@
|
|||||||
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
|
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||||
<% if user.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<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 :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :username, 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: "jsmith" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
|
||||||
|
</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: Full 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" %>
|
||||||
@@ -46,6 +41,46 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= form.check_box :totp_required, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :totp_required, "Require Two-Factor Authentication", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
<% if user.totp_required? && !user.totp_enabled? %>
|
||||||
|
<span class="ml-2 text-xs text-amber-600">(User has not set up 2FA yet)</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if user.totp_required? && !user.totp_enabled? %>
|
||||||
|
<p class="mt-1 text-sm text-amber-600">
|
||||||
|
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<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>
|
||||||
|
Warning: This user will be prompted to set up 2FA on their next login.
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">When enabled, this user must use two-factor authentication to sign in.</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">
|
||||||
|
<%= 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" %>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<div class="max-w-2xl">
|
<div class="max-w-4xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
|
||||||
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
|
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
|
||||||
|
|
||||||
|
<div class="max-w-2xl">
|
||||||
<%= render "form", user: @user %>
|
<%= render "form", user: @user %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @user.persisted? %>
|
||||||
|
<%= render "application_claims", user: @user, applications: @applications %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% unless smtp_configured? %>
|
||||||
|
<div class="mt-6 rounded-md bg-yellow-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">
|
||||||
|
Email delivery not configured
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>
|
||||||
|
<% if Rails.env.development? %>
|
||||||
|
Emails are being delivered using <span class="font-mono"><%= email_delivery_method %></span> and will open in your browser.
|
||||||
|
<% else %>
|
||||||
|
SMTP settings are not configured. Invitation emails and other notifications will not be sent.
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1">
|
||||||
|
<% if Rails.env.development? %>
|
||||||
|
To configure SMTP for production, set environment variables like <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, etc.
|
||||||
|
<% else %>
|
||||||
|
Configure SMTP settings by setting environment variables: <span class="font-mono">SMTP_ADDRESS</span>, <span class="font-mono">SMTP_PORT</span>, <span class="font-mono">SMTP_USERNAME</span>, <span class="font-mono">SMTP_PASSWORD</span>, etc.
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="mt-8 flow-root">
|
<div class="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||||
@@ -52,22 +85,36 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<% if user.totp_enabled? %>
|
<% if user.totp_enabled? %>
|
||||||
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Enabled">
|
||||||
<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>
|
<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>
|
||||||
</svg>
|
</svg>
|
||||||
<% else %>
|
<% else %>
|
||||||
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Not Enabled">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if user.totp_required? %>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700" title="2FA Required by Admin">Required</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<%= user.groups.count %>
|
<%= user.groups.count %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %>
|
<div class="flex justify-end space-x-3">
|
||||||
<%= button_to "Delete", admin_user_path(user), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this user?" }, class: "text-red-600 hover:text-red-900" %>
|
<% if user.pending_invitation? %>
|
||||||
|
<%= link_to "Resend", resend_invitation_admin_user_path(user),
|
||||||
|
data: { turbo_method: :post },
|
||||||
|
class: "text-yellow-600 hover:text-yellow-900" %>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
<%= link_to "Delete", admin_user_path(user),
|
||||||
|
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },
|
||||||
|
class: "text-red-600 hover:text-red-900" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -93,6 +93,82 @@
|
|||||||
<% 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-start gap-3 mb-4">
|
||||||
|
<% if app.icon.attached? %>
|
||||||
|
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{app.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
||||||
|
<%= app.name %>
|
||||||
|
</h3>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0
|
||||||
|
<% if app.oidc? %>
|
||||||
|
bg-blue-100 text-blue-800
|
||||||
|
<% else %>
|
||||||
|
bg-green-100 text-green-800
|
||||||
|
<% end %>">
|
||||||
|
<%= app.app_type.humanize %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% if app.description.present? %>
|
||||||
|
<p class="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||||
|
<%= app.description %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<% 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 %>
|
||||||
|
|
||||||
|
<% if app.user_has_active_session?(@user) %>
|
||||||
|
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
|
||||||
|
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
|
||||||
|
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|||||||
22
app/views/invitations/show.html.erb
Normal file
22
app/views/invitations/show.html.erb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<div class="mx-auto md:w-2/3 w-full">
|
||||||
|
<% if alert = flash[:alert] %>
|
||||||
|
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h1 class="font-bold text-4xl">Welcome to Clinch!</h1>
|
||||||
|
<p class="mt-2 text-gray-600">You've been invited to join Clinch. Please create your password to complete your account setup.</p>
|
||||||
|
|
||||||
|
<%= form_with url: invitation_path(params[:token]), method: :put, class: "contents" do |form| %>
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-5">
|
||||||
|
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Confirm your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline">
|
||||||
|
<%= form.submit "Create Account", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
12
app/views/invitations_mailer/invite_user.html.erb
Normal file
12
app/views/invitations_mailer/invite_user.html.erb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<p>
|
||||||
|
You've been invited to join Clinch! To set up your account and create your password, please visit
|
||||||
|
<%= link_to "this invitation page", invitation_url(@user.generate_token_for(:invitation_login)) %>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This invitation link will expire in 24 hours.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you didn't expect this invitation, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
8
app/views/invitations_mailer/invite_user.text.erb
Normal file
8
app/views/invitations_mailer/invite_user.text.erb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
You've been invited to join Clinch!
|
||||||
|
|
||||||
|
To set up your account and create your password, please visit:
|
||||||
|
#{invite_url(@user.invitation_login_token)}
|
||||||
|
|
||||||
|
This invitation link will expire in #{distance_of_time_in_words(0, @user.invitation_login_token_expires_in)}.
|
||||||
|
|
||||||
|
If you didn't expect this invitation, you can safely ignore this email.
|
||||||
@@ -25,11 +25,15 @@
|
|||||||
|
|
||||||
<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"
|
||||||
|
class="-m-2.5 p-2.5 text-gray-700"
|
||||||
|
id="mobile-menu-button"
|
||||||
|
data-action="click->mobile-sidebar#openSidebar">
|
||||||
<span class="sr-only">Open sidebar</span>
|
<span class="sr-only">Open sidebar</span>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
@@ -44,31 +48,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<!-- Public layout (signup/signin) -->
|
<!-- Public layout (signup/signin) -->
|
||||||
<main class="container mx-auto mt-28 px-5 flex">
|
<main class="container mx-auto mt-28 px-5">
|
||||||
<%= render "shared/flash" %>
|
<%= render "shared/flash" %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
<% 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>
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-md">
|
||||||
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
|
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
|
||||||
<div class="mb-8">
|
<div class="mb-8 text-center">
|
||||||
|
<% if @application.icon.attached? %>
|
||||||
|
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 shadow-sm mb-4", alt: "#{@application.name} icon" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 border-2 border-gray-200 flex items-center justify-center mb-4">
|
||||||
|
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
<strong><%= @application.name %></strong> is requesting access to your account.
|
<strong><%= @application.name %></strong> is requesting access to your account.
|
||||||
@@ -57,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3" do |form| %>
|
<%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
|
||||||
<%= form.submit "Authorize",
|
<%= form.submit "Authorize",
|
||||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
|
|
||||||
|
|||||||
@@ -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" do |form| %>
|
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } 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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="space-y-8">
|
<div class="space-y-8" data-controller="modal">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Profile & Settings</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
|
||||||
<p class="mt-2 text-sm text-gray-600">Manage your account settings and security preferences.</p>
|
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Account Information -->
|
<!-- Account Information -->
|
||||||
@@ -98,17 +98,52 @@
|
|||||||
<p class="text-sm font-medium text-green-800">
|
<p class="text-sm font-medium text-green-800">
|
||||||
Two-factor authentication is enabled
|
Two-factor authentication is enabled
|
||||||
</p>
|
</p>
|
||||||
|
<% if @user.totp_required? %>
|
||||||
|
<p class="mt-1 text-sm text-green-700">
|
||||||
|
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Required by administrator
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% if @user.totp_required? %>
|
||||||
|
<div class="mt-4 rounded-md bg-blue-50 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0" 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>
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
Your administrator requires two-factor authentication. You cannot disable it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-4 flex gap-3">
|
<div class="mt-4 flex gap-3">
|
||||||
<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">
|
<button type="button"
|
||||||
Disable 2FA
|
data-action="click->modal#show"
|
||||||
</button>
|
data-modal-id="view-backup-codes-modal"
|
||||||
<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">
|
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>
|
||||||
|
<% else %>
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<button type="button"
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to new_totp_path, class: "inline-flex items-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" do %>
|
<%= link_to new_totp_path, class: "inline-flex items-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" do %>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
@@ -119,7 +154,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Disable 2FA Modal -->
|
<!-- 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">
|
<div id="disable-2fa-modal"
|
||||||
|
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">
|
||||||
@@ -143,7 +180,9 @@
|
|||||||
<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" 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">
|
<button type="button"
|
||||||
|
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>
|
||||||
@@ -153,15 +192,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- View Backup Codes Modal -->
|
<!-- Regenerate 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">
|
<div id="view-backup-codes-modal"
|
||||||
|
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">View Backup Codes</h3>
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Generate New Backup Codes</h3>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<p class="text-sm text-gray-500">Enter your password to view your backup codes.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<%= form_with url: verify_password_totp_path, method: :post, class: "mt-4" do |form| %>
|
<div class="mt-3 p-3 bg-yellow-50 rounded-md">
|
||||||
|
<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",
|
||||||
@@ -170,9 +221,11 @@
|
|||||||
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 "View Codes",
|
<%= form.submit "Generate New 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" 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">
|
<button type="button"
|
||||||
|
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>
|
||||||
@@ -181,64 +234,123 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Passkeys (WebAuthn) -->
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Active Sessions -->
|
|
||||||
<div class="bg-white shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6" data-controller="webauthn" data-webauthn-challenge-url-value="/webauthn/challenge" data-webauthn-create-url-value="/webauthn/create">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Active Sessions</h3>
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Passkeys</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>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
|
<p>Use your fingerprint, face recognition, or security key to sign in without passwords.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Passkey Form -->
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<% if @active_sessions.any? %>
|
<div id="add-passkey-form" class="space-y-4">
|
||||||
<ul role="list" class="divide-y divide-gray-200">
|
<div>
|
||||||
<% @active_sessions.each do |session| %>
|
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700">Passkey Name</label>
|
||||||
<li class="py-4">
|
<input type="text"
|
||||||
<div class="flex items-center justify-between">
|
id="passkey-nickname"
|
||||||
<div class="flex flex-col">
|
data-webauthn-target="nickname"
|
||||||
<p class="text-sm font-medium text-gray-900">
|
placeholder="e.g., MacBook Touch ID, iPhone Face ID"
|
||||||
<%= session.device_name || "Unknown Device" %>
|
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||||
<% if session.id == Current.session.id %>
|
<p class="mt-1 text-sm text-gray-500">Give this passkey a memorable name so you can identify it later.</p>
|
||||||
<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">
|
</div>
|
||||||
This device
|
|
||||||
|
<div>
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->webauthn#register"
|
||||||
|
data-webauthn-target="submitButton"
|
||||||
|
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>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<% 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>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
<%= link_to webauthn_credential_path(credential),
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
method: :delete,
|
||||||
<%= session.ip_address %>
|
data: {
|
||||||
</p>
|
confirm: "Are you sure you want to delete '#{credential.nickname}'? You'll need to set it up again to sign in with this device.",
|
||||||
<p class="mt-1 text-xs text-gray-400">
|
turbo_method: :delete
|
||||||
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
|
},
|
||||||
</p>
|
class: "text-red-600 hover:text-red-800 text-sm font-medium" do %>
|
||||||
</div>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<% if session.id != Current.session.id %>
|
<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>
|
||||||
<%= button_to "Revoke", session_path(session), method: :delete,
|
</svg>
|
||||||
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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</div>
|
||||||
|
|
||||||
|
<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 %>
|
||||||
<p class="text-sm text-gray-500">No other active sessions.</p>
|
<div class="text-center py-8">
|
||||||
|
<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="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>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No passkeys</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by adding your first passkey for passwordless sign-in.</p>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<div class="mx-auto md:w-2/3 w-full">
|
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
|
||||||
<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" do |form| %>
|
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } 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,9 +13,35 @@
|
|||||||
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 id="webauthn-section" data-login-form-target="webauthnSection" class="my-5 hidden">
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-green-800">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Password section - shown by default, hidden if WebAuthn is required -->
|
||||||
|
<div id="password-section" data-login-form-target="passwordSection">
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
||||||
<%= form.password_field :password,
|
<%= form.password_field :password,
|
||||||
@@ -30,9 +56,24 @@
|
|||||||
<%= form.submit "Sign in",
|
<%= 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" %>
|
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>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6" do |form| %>
|
<%= form_with url: totp_verification_path, method: :post, class: "space-y-6", data: {
|
||||||
|
controller: "form-submit-protection",
|
||||||
|
turbo: false
|
||||||
|
} do |form| %>
|
||||||
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
<%= label_tag :code, "Verification Code", class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.submit "Verify",
|
<%= form.submit "Verify",
|
||||||
|
data: { form_submit_protection_target: "submit" },
|
||||||
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,29 +1,73 @@
|
|||||||
<% if flash[:alert] %>
|
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||||
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
|
<% flash.each do |type, message| %>
|
||||||
<div class="flex">
|
<% next if message.blank? %>
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if flash[:notice] %>
|
<%
|
||||||
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
|
# 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="flex-shrink-0">
|
<div class="shrink-0">
|
||||||
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-5 w-5 <%= icon_class %>" 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 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"/>
|
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3 flex-1">
|
||||||
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
|
<p class="text-sm font-medium <%= text_class %>"><%= message %></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 %>
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
<% if form.object.errors.any? %>
|
<%# Usage: render "shared/form_errors", object: @user %>
|
||||||
<div class="rounded-md bg-red-50 p-4">
|
<%# Usage: render "shared/form_errors", form: form %>
|
||||||
|
|
||||||
|
<% 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.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" />
|
<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">
|
<div class="ml-3 flex-1">
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
|
||||||
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
|
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
|
||||||
</h3>
|
</h3>
|
||||||
<div class="mt-2 text-sm text-red-700">
|
<div class="mt-2">
|
||||||
<ul class="list-disc space-y-1 pl-5">
|
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
|
||||||
<% 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 %>
|
||||||
@@ -57,16 +57,6 @@
|
|||||||
<% 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 %>
|
||||||
@@ -88,9 +78,19 @@
|
|||||||
<% 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 }, 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, 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>
|
||||||
@@ -105,12 +105,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile sidebar overlay -->
|
<!-- Mobile sidebar overlay -->
|
||||||
<div class="relative z-50 lg:hidden hidden" id="mobile-sidebar-overlay">
|
<div class="relative z-50 lg:hidden hidden"
|
||||||
|
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" class="-m-2.5 p-2.5" id="mobile-menu-close">
|
<button type="button"
|
||||||
|
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" />
|
||||||
@@ -138,7 +144,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 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= 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 %>
|
||||||
<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>
|
||||||
@@ -147,7 +153,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 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= 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 %>
|
||||||
<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>
|
||||||
@@ -155,7 +161,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 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= 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 %>
|
||||||
<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>
|
||||||
@@ -163,24 +169,16 @@
|
|||||||
<% 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 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' }", data: { action: "click->mobile-sidebar#closeSidebar" } 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 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
|
<%= 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 %>
|
||||||
<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>
|
||||||
@@ -188,7 +186,15 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<%= 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 %>
|
<%= 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 %>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
|
||||||
<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 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">
|
<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">
|
||||||
<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 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">
|
<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">
|
||||||
<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>
|
||||||
@@ -45,34 +45,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
|
<% if @auto_signin_pending %>
|
||||||
|
<%= button_to "Continue to Sign In", complete_totp_setup_path, method: :post,
|
||||||
|
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" %>
|
||||||
|
<% else %>
|
||||||
<%= link_to "Done", profile_path,
|
<%= link_to "Done", profile_path,
|
||||||
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" %>
|
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" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
|
|||||||
45
app/views/totp/regenerate_backup_codes.html.erb
Normal file
45
app/views/totp/regenerate_backup_codes.html.erb
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<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>
|
||||||
@@ -4,17 +4,8 @@
|
|||||||
<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" do |form| %>
|
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||||
<% if @user.errors.any? %>
|
<%= render "shared/form_errors", form: form %>
|
||||||
<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" %>
|
||||||
|
|||||||
@@ -23,5 +23,18 @@ module Clinch
|
|||||||
#
|
#
|
||||||
# config.time_zone = "Central Time (US & Canada)"
|
# config.time_zone = "Central Time (US & Canada)"
|
||||||
# config.eager_load_paths << Rails.root.join("extras")
|
# config.eager_load_paths << Rails.root.join("extras")
|
||||||
|
|
||||||
|
# Configure SMTP settings using environment variables
|
||||||
|
config.action_mailer.delivery_method = :smtp
|
||||||
|
config.action_mailer.smtp_settings = {
|
||||||
|
address: ENV.fetch('SMTP_ADDRESS', 'localhost'),
|
||||||
|
port: ENV.fetch('SMTP_PORT', 587),
|
||||||
|
domain: ENV.fetch('SMTP_DOMAIN', 'localhost'),
|
||||||
|
user_name: ENV.fetch('SMTP_USERNAME', nil),
|
||||||
|
password: ENV.fetch('SMTP_PASSWORD', nil),
|
||||||
|
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain').to_sym,
|
||||||
|
enable_starttls_auto: ENV.fetch('SMTP_STARTTLS_AUTO', 'true') == 'true',
|
||||||
|
openssl_verify_mode: OpenSSL::SSL::VERIFY_PEER
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ Rails.application.configure do
|
|||||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||||
config.active_storage.service = :local
|
config.active_storage.service = :local
|
||||||
|
|
||||||
# Don't care if the mailer can't send.
|
# Preview emails in browser using letter_opener
|
||||||
config.action_mailer.raise_delivery_errors = false
|
config.action_mailer.delivery_method = :letter_opener
|
||||||
|
config.action_mailer.perform_deliveries = true
|
||||||
|
|
||||||
# Make template changes take effect immediately.
|
# Make template changes take effect immediately.
|
||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
@@ -58,9 +59,8 @@ Rails.application.configure do
|
|||||||
# Highlight code that enqueued background job in logs.
|
# Highlight code that enqueued background job in logs.
|
||||||
config.active_job.verbose_enqueue_logs = true
|
config.active_job.verbose_enqueue_logs = true
|
||||||
|
|
||||||
# Use Solid Queue for background jobs (same as production).
|
# Use async processor for background jobs in development
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :async
|
||||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
||||||
|
|
||||||
|
|
||||||
# Highlight code that triggered redirect in logs.
|
# Highlight code that triggered redirect in logs.
|
||||||
@@ -83,4 +83,14 @@ 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
|
||||||
|
|||||||
@@ -49,16 +49,17 @@ Rails.application.configure do
|
|||||||
# Replace the default in-process memory cache store with a durable alternative.
|
# Replace the default in-process memory cache store with a durable alternative.
|
||||||
config.cache_store = :solid_cache_store
|
config.cache_store = :solid_cache_store
|
||||||
|
|
||||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
# Use async processor for background jobs (modify as needed for production)
|
||||||
config.active_job.queue_adapter = :solid_queue
|
config.active_job.queue_adapter = :async
|
||||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# Ignore bad email addresses and do not raise email delivery errors.
|
||||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||||
# config.action_mailer.raise_delivery_errors = false
|
# config.action_mailer.raise_delivery_errors = false
|
||||||
|
|
||||||
# Set host to be used by links generated in mailer templates.
|
# Set host to be used by links generated in mailer templates.
|
||||||
config.action_mailer.default_url_options = { host: "example.com" }
|
config.action_mailer.default_url_options = {
|
||||||
|
host: ENV.fetch('CLINCH_HOST', 'example.com')
|
||||||
|
}
|
||||||
|
|
||||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||||
# config.action_mailer.smtp_settings = {
|
# config.action_mailer.smtp_settings = {
|
||||||
@@ -79,12 +80,85 @@ Rails.application.configure do
|
|||||||
# Only use :id for inspections in production.
|
# Only use :id for inspections in production.
|
||||||
config.active_record.attributes_for_inspect = [ :id ]
|
config.active_record.attributes_for_inspect = [ :id ]
|
||||||
|
|
||||||
|
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
|
||||||
|
def self.extract_domain(host)
|
||||||
|
return host if host.blank?
|
||||||
|
# Remove protocol (http:// or https://) if present
|
||||||
|
host.gsub(/^https?:\/\//, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper method to ensure URL has https:// protocol
|
||||||
|
def self.ensure_https(url)
|
||||||
|
return url if url.blank?
|
||||||
|
# Add https:// if no protocol is present
|
||||||
|
url.match?(/^https?:\/\//) ? url : "https://#{url}"
|
||||||
|
end
|
||||||
|
|
||||||
# Enable DNS rebinding protection and other `Host` header attacks.
|
# Enable DNS rebinding protection and other `Host` header attacks.
|
||||||
# config.hosts = [
|
# Configure allowed hosts based on deployment scenario
|
||||||
# "example.com", # Allow requests from example.com
|
allowed_hosts = [
|
||||||
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
|
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
|
||||||
# ]
|
]
|
||||||
#
|
|
||||||
|
# Use PublicSuffix to extract registrable domain and allow all subdomains
|
||||||
|
host_domain = extract_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
|
||||||
|
|||||||
@@ -50,4 +50,8 @@ 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
|
||||||
|
ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] = "false"
|
||||||
end
|
end
|
||||||
|
|||||||
14
config/initializers/active_storage.rb
Normal file
14
config/initializers/active_storage.rb
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Configure ActiveStorage content type resolution
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
# Ensure SVG files are served with the correct content type
|
||||||
|
ActiveStorage::Blob.class_eval do
|
||||||
|
def content_type_for_serving
|
||||||
|
# Override content type for SVG files
|
||||||
|
if filename.extension == "svg" && content_type == "application/octet-stream"
|
||||||
|
"image/svg+xml"
|
||||||
|
else
|
||||||
|
content_type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,26 +4,66 @@
|
|||||||
# 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|
|
||||||
# policy.default_src :self, :https
|
# Default to self for everything, plus blob: for file downloads
|
||||||
# policy.font_src :self, :https, :data
|
policy.default_src :self, "blob:"
|
||||||
# policy.img_src :self, :https, :data
|
|
||||||
# policy.object_src :none
|
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
|
||||||
# policy.script_src :self, :https
|
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
|
||||||
# policy.style_src :self, :https
|
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:"
|
||||||
# # Specify URI for violation reports
|
|
||||||
# # policy.report_uri "/csp-violation-report-endpoint"
|
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
|
||||||
# end
|
# and Stimulus controller style manipulations
|
||||||
#
|
policy.style_src :self, :unsafe_inline
|
||||||
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
|
|
||||||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
# Images: Allow self, data URLs, and https for external images
|
||||||
# config.content_security_policy_nonce_directives = %w(script-src style-src)
|
policy.img_src :self, :data, :https
|
||||||
#
|
|
||||||
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
|
# Fonts: Allow self and data URLs
|
||||||
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
|
policy.font_src :self, :data
|
||||||
# # config.content_security_policy_nonce_auto = true
|
|
||||||
#
|
# Connect: Allow self for API calls, WebAuthn, and ActionCable if needed
|
||||||
# # Report violations without enforcing the policy.
|
# WebAuthn endpoints are on the same domain, so self is sufficient
|
||||||
# # config.content_security_policy_report_only = true
|
policy.connect_src :self, "wss:"
|
||||||
# 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
|
||||||
|
# Note: OAuth redirects will be handled dynamically in the consent page
|
||||||
|
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
|
||||||
|
|
||||||
|
# CSP reporting using report_uri (supported method)
|
||||||
|
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
|
||||||
128
config/initializers/csp_local_logger.rb
Normal file
128
config/initializers/csp_local_logger.rb
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# 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] || {}
|
||||||
|
|
||||||
|
# Skip logging if there's no meaningful violation data
|
||||||
|
return if csp_data.empty? ||
|
||||||
|
(csp_data[:violated_directive].nil? &&
|
||||||
|
csp_data[:blocked_uri].nil? &&
|
||||||
|
csp_data[:document_uri].nil?)
|
||||||
|
|
||||||
|
# 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
|
||||||
140
config/initializers/sentry.rb
Normal file
140
config/initializers/sentry.rb
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# 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
|
||||||
120
config/initializers/sentry_subscriber.rb
Normal file
120
config/initializers/sentry_subscriber.rb
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# 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
|
||||||
5
config/initializers/version.rb
Normal file
5
config/initializers/version.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Clinch
|
||||||
|
VERSION = "0.6.0"
|
||||||
|
end
|
||||||
71
config/initializers/webauthn.rb
Normal file
71
config/initializers/webauthn.rb
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# WebAuthn configuration for Clinch Identity Provider
|
||||||
|
WebAuthn.configure do |config|
|
||||||
|
# Relying Party name (displayed in authenticator prompts)
|
||||||
|
# CLINCH_HOST should include protocol (https://) for WebAuthn
|
||||||
|
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
|
||||||
|
config.allowed_origins = [origin_host]
|
||||||
|
|
||||||
|
# Relying Party ID (must match origin domain without protocol)
|
||||||
|
# Extract domain from origin for RP ID if CLINCH_RP_ID not set
|
||||||
|
if ENV["CLINCH_RP_ID"].present?
|
||||||
|
config.rp_id = ENV["CLINCH_RP_ID"]
|
||||||
|
else
|
||||||
|
# Extract registrable domain from CLINCH_HOST using PublicSuffix
|
||||||
|
origin_uri = URI.parse(origin_host)
|
||||||
|
if origin_uri.host
|
||||||
|
begin
|
||||||
|
# Use PublicSuffix to get the registrable domain (e.g., "aapamilne.com" from "auth.aapamilne.com")
|
||||||
|
domain = PublicSuffix.parse(origin_uri.host)
|
||||||
|
config.rp_id = domain.domain || origin_uri.host
|
||||||
|
rescue PublicSuffix::DomainInvalid => e
|
||||||
|
Rails.logger.warn "WebAuthn: Failed to parse domain '#{origin_uri.host}': #{e.message}, using host as fallback"
|
||||||
|
config.rp_id = origin_uri.host
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Rails.logger.error "WebAuthn: Could not extract host from CLINCH_HOST '#{origin_host}'"
|
||||||
|
config.rp_id = "localhost"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -31,11 +31,11 @@ threads threads_count, threads_count
|
|||||||
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
|
||||||
port ENV.fetch("PORT", 3000)
|
port ENV.fetch("PORT", 3000)
|
||||||
|
|
||||||
|
|
||||||
# Allow puma to be restarted by `bin/rails restart` command.
|
# Allow puma to be restarted by `bin/rails restart` command.
|
||||||
plugin :tmp_restart
|
plugin :tmp_restart
|
||||||
|
|
||||||
# Run the Solid Queue supervisor inside of Puma for single-server deployments.
|
# Solid Queue plugin removed - now using async processor
|
||||||
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
|
|
||||||
|
|
||||||
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
|
||||||
# In other environments, only set the PID file if requested.
|
# In other environments, only set the PID file if requested.
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
# examples:
|
# Solid Queue Recurring Jobs Configuration
|
||||||
# periodic_cleanup:
|
# This file defines scheduled/cron-like jobs that run periodically
|
||||||
# class: CleanSoftDeletedRecordsJob
|
|
||||||
# queue: background
|
|
||||||
# args: [ 1000, { batch_size: 500 } ]
|
|
||||||
# schedule: every hour
|
|
||||||
# periodic_cleanup_with_command:
|
|
||||||
# command: "SoftDeletedRecord.due.delete_all"
|
|
||||||
# priority: 2
|
|
||||||
# schedule: at 5am every day
|
|
||||||
|
|
||||||
production:
|
production:
|
||||||
clear_solid_queue_finished_jobs:
|
oidc_token_cleanup:
|
||||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
class: OidcTokenCleanupJob
|
||||||
schedule: every hour at minute 12
|
schedule: "0 3 * * *" # Run daily at 3:00 AM
|
||||||
|
queue: default
|
||||||
|
|
||||||
|
development:
|
||||||
|
oidc_token_cleanup:
|
||||||
|
class: OidcTokenCleanupJob
|
||||||
|
schedule: "0 3 * * *" # Run daily at 3:00 AM
|
||||||
|
queue: default
|
||||||
|
|
||||||
|
test:
|
||||||
|
# No recurring jobs in test environment
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
resource :session
|
resource :session
|
||||||
resources :passwords, param: :token
|
resources :passwords, param: :token
|
||||||
|
resources :invitations, param: :token, only: [:show, :update]
|
||||||
mount ActionCable.server => "/cable"
|
mount ActionCable.server => "/cable"
|
||||||
|
|
||||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||||
@@ -18,22 +19,41 @@ 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"
|
||||||
get "/oauth/authorize", to: "oidc#authorize"
|
get "/oauth/authorize", to: "oidc#authorize"
|
||||||
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
|
||||||
post "/oauth/token", to: "oidc#token"
|
post "/oauth/token", to: "oidc#token"
|
||||||
|
post "/oauth/revoke", to: "oidc#revoke"
|
||||||
get "/oauth/userinfo", to: "oidc#userinfo"
|
get "/oauth/userinfo", to: "oidc#userinfo"
|
||||||
|
get "/logout", to: "oidc#logout"
|
||||||
|
|
||||||
# ForwardAuth / Trusted Header SSO
|
# ForwardAuth / Trusted Header SSO
|
||||||
namespace :api do
|
namespace :api do
|
||||||
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
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
resource :profile, only: [:show, :update]
|
resource :profile, only: [:show, :update] do
|
||||||
|
member do
|
||||||
|
delete :revoke_consent
|
||||||
|
delete :revoke_all_consents
|
||||||
|
end
|
||||||
|
end
|
||||||
|
resource :active_sessions, only: [:show] do
|
||||||
|
member do
|
||||||
|
delete :logout_from_app
|
||||||
|
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
|
||||||
@@ -46,18 +66,33 @@ 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
|
||||||
|
post '/totp/complete_setup', to: 'totp#complete_setup', as: :complete_totp_setup
|
||||||
|
|
||||||
|
# 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
|
||||||
root "dashboard#index"
|
root "dashboard#index"
|
||||||
resources :users
|
resources :users do
|
||||||
|
member do
|
||||||
|
post :resend_invitation
|
||||||
|
post :update_application_claims
|
||||||
|
delete :delete_application_claims
|
||||||
|
end
|
||||||
|
end
|
||||||
resources :applications do
|
resources :applications do
|
||||||
member do
|
member do
|
||||||
post :regenerate_credentials
|
post :regenerate_credentials
|
||||||
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)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user