31 Commits

Author SHA1 Message Date
Dan Milne
ab362aabac Remove the rate limit for the forward auth system
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / system-test (push) Waiting to run
2025-12-28 14:40:53 +11:00
Dan Milne
283feea175 Update depenencies, bump versoin 2025-11-30 23:13:25 +11:00
Dan Milne
7af8624bf8 Handle empty backchannel logout urls
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-27 19:19:34 +11:00
Dan Milne
f8543f98cc Add a subdirectory for active storage
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-27 19:12:09 +11:00
Dan Milne
6be23c2c37 Add backchannel logout, per application logout.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-27 16:38:27 +11:00
Dan Milne
eb2d7379bf Backchannel complete - improve oidc credential display 2025-11-27 11:52:25 +11:00
Dan Milne
67d86e5835 Add Icons for apps 2025-11-25 19:11:22 +11:00
Dan Milne
d6029556d3 Add OIDC fixes, add prefered_username, add application-user claims 2025-11-25 16:29:40 +11:00
Dan Milne
7796c38c08 Add pairwise SID with a UUIDv4, a significatant upgrade over User.id.to_s. Complete allowing admin to enforce TOTP per user
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-23 11:16:06 +11:00
Dan Milne
e882a4d6d1 More complete oidc
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-18 20:03:03 +11:00
Dan Milne
ab0085e9c9 More complete oidc 2025-11-18 20:02:45 +11:00
Dan Milne
1ee3302319 Improvements derived from rodauth-oauth 2025-11-12 22:17:55 +11:00
Dan Milne
67f28faaca Improve some front end views. More descriptive error condition reporting. Updates to CLINCH_HOST for better WEBAUTHN 2025-11-12 16:24:05 +11:00
Dan Milne
33ad956508 Add test 2025-11-12 15:50:04 +11:00
Dan Milne
11ec753c68 Bump up the forward auth token ttl, fix leaking of error data
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-09 12:27:53 +11:00
Dan Milne
4df2eee4d9 Bug fix for domain names with empty string instead of null. Form errors and some security fixes
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-09 12:22:41 +11:00
Dan Milne
d9f11abbbf Fixes for OIDC and HTML 2025-11-09 12:04:26 +11:00
Dan Milne
c92e69fa4a Add PCKE 2025-11-09 11:54:45 +11:00
Dan Milne
038801f34b Add pkce
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-09 10:21:29 +11:00
Dan Milne
8e0b2c28eb CSP fixes 2025-11-08 20:01:07 +11:00
Dan Milne
f02665f690 Consolidate all the error messages - add some stimulus controller. 2025-11-07 16:58:28 +11:00
Dan Milne
631b2b53bb Fix CSP reporting endpoitn. Fix the SER for CSP
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 23:22:15 +11:00
Dan Milne
6049429a41 Fix mobile view menu popout. Add an option SENTRY_DSN support, which uses rails event reporting
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 23:16:28 +11:00
Dan Milne
2b15aa2c40 Add sentry, set csp reporting API 2025-11-04 22:58:32 +11:00
Dan Milne
4f5974dd37 bah
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 21:33:52 +11:00
Dan Milne
5de53f1841 bug fix
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 21:21:00 +11:00
Dan Milne
73b2ae2f02 Add some docs
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 21:13:46 +11:00
Dan Milne
4c5ac344bd Bug updating OIDC apps. Update readme
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 20:14:41 +11:00
Dan Milne
044b9239d6 Ok - this time add the new controllers we stripped out of inline and add back the csp
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 18:55:20 +11:00
Dan Milne
e9b1995e89 Remove unneeded stuff
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-11-04 18:47:31 +11:00
Dan Milne
fb14ce032f Strip out more inline javascript code. Encrypt backup codes and treat the backup codes attribute as a json array 2025-11-04 18:46:11 +11:00
108 changed files with 5782 additions and 720 deletions

View File

@@ -68,3 +68,27 @@ CLINCH_ALLOW_LOCALHOST=true
# Optional: Set custom port
# PORT=9000
# Sentry Configuration (Optional)
# Enable error tracking and performance monitoring
# Leave SENTRY_DSN empty to disable Sentry completely
#
# Production: Get your DSN from https://sentry.io/settings/projects/
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
#
# Optional: Override Sentry environment (defaults to Rails.env)
# SENTRY_ENVIRONMENT=production
#
# Optional: Override Sentry release (defaults to Git commit hash)
# SENTRY_RELEASE=v1.0.0
#
# Optional: Performance monitoring sample rate (0.0 to 1.0, default 0.2)
# Higher values provide more data but cost more
# SENTRY_TRACES_SAMPLE_RATE=0.2
#
# Optional: Continuous profiling sample rate (0.0 to 1.0, default 0.0)
# Very resource intensive, only enable for performance investigations
# SENTRY_PROFILES_SAMPLE_RATE=0.0
#
# Development: Enable Sentry in development for testing
# SENTRY_ENABLED_IN_DEVELOPMENT=true

View File

@@ -11,6 +11,8 @@
ARG RUBY_VERSION=3.4.6
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
# Rails app lives here
WORKDIR /rails

View File

@@ -1,7 +1,7 @@
source "https://rubygems.org"
# 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]
gem "propshaft"
# Use sqlite3 as the database for Active Record
@@ -35,7 +35,11 @@ gem "jwt", "~> 3.1"
gem "webauthn", "~> 3.0"
# Public Suffix List for domain parsing
gem "public_suffix", "~> 6.0"
gem "public_suffix", "~> 7.0"
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
gem "sentry-ruby", "~> 6.2"
gem "sentry-rails", "~> 6.2"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

View File

@@ -3,29 +3,29 @@ GEM
specs:
action_text-trix (2.1.15)
railties
actioncable (8.1.0)
actionpack (= 8.1.0)
activesupport (= 8.1.0)
actioncable (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.0)
actionpack (= 8.1.0)
activejob (= 8.1.0)
activerecord (= 8.1.0)
activestorage (= 8.1.0)
activesupport (= 8.1.0)
actionmailbox (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
mail (>= 2.8.0)
actionmailer (8.1.0)
actionpack (= 8.1.0)
actionview (= 8.1.0)
activejob (= 8.1.0)
activesupport (= 8.1.0)
actionmailer (8.1.1)
actionpack (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activesupport (= 8.1.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.0)
actionview (= 8.1.0)
activesupport (= 8.1.0)
actionpack (8.1.1)
actionview (= 8.1.1)
activesupport (= 8.1.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.0)
actiontext (8.1.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.0)
activerecord (= 8.1.0)
activestorage (= 8.1.0)
activesupport (= 8.1.0)
actionpack (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.0)
activesupport (= 8.1.0)
actionview (8.1.1)
activesupport (= 8.1.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.0)
activesupport (= 8.1.0)
activejob (8.1.1)
activesupport (= 8.1.1)
globalid (>= 0.3.6)
activemodel (8.1.0)
activesupport (= 8.1.0)
activerecord (8.1.0)
activemodel (= 8.1.0)
activesupport (= 8.1.0)
activemodel (8.1.1)
activesupport (= 8.1.1)
activerecord (8.1.1)
activemodel (= 8.1.1)
activesupport (= 8.1.1)
timeout (>= 0.4.0)
activestorage (8.1.0)
actionpack (= 8.1.0)
activejob (= 8.1.0)
activerecord (= 8.1.0)
activesupport (= 8.1.0)
activestorage (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activesupport (= 8.1.1)
marcel (~> 1.0)
activesupport (8.1.0)
activesupport (8.1.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -75,8 +75,8 @@ GEM
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
android_key_attestation (0.3.0)
ast (2.4.3)
base64 (0.3.0)
@@ -85,13 +85,13 @@ GEM
bigdecimal (3.3.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.18.6)
bootsnap (1.19.0)
msgpack (~> 1.2)
brakeman (7.1.0)
brakeman (7.1.1)
racc
builder (3.3.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -107,19 +107,19 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
connection_pool (2.5.5)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
crass (1.0.6)
date (3.4.1)
date (3.5.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.8)
drb (2.2.3)
ed25519 (1.4.0)
erb (5.1.1)
erb (6.0.0)
erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -140,17 +140,17 @@ GEM
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.1)
irb (1.15.2)
irb (1.15.3)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.15.1)
json (2.16.0)
jwt (3.1.2)
base64
kamal (2.8.1)
kamal (2.9.0)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -184,7 +184,7 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.26.0)
minitest (5.26.2)
msgpack (1.8.0)
net-imap (0.5.12)
date
@@ -200,7 +200,7 @@ GEM
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.0)
nio4r (2.7.4)
nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
@@ -220,7 +220,7 @@ GEM
openssl (> 2.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.9.0)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
pp (0.6.3)
@@ -234,11 +234,11 @@ GEM
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
public_suffix (7.0.0)
puma (7.1.0)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.3)
rack (3.2.4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -246,20 +246,20 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.1.0)
actioncable (= 8.1.0)
actionmailbox (= 8.1.0)
actionmailer (= 8.1.0)
actionpack (= 8.1.0)
actiontext (= 8.1.0)
actionview (= 8.1.0)
activejob (= 8.1.0)
activemodel (= 8.1.0)
activerecord (= 8.1.0)
activestorage (= 8.1.0)
activesupport (= 8.1.0)
rails (8.1.1)
actioncable (= 8.1.1)
actionmailbox (= 8.1.1)
actionmailer (= 8.1.1)
actionpack (= 8.1.1)
actiontext (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activemodel (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
bundler (>= 1.15.0)
railties (= 8.1.0)
railties (= 8.1.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -267,9 +267,9 @@ GEM
rails-html-sanitizer (1.6.2)
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)
railties (8.1.0)
actionpack (= 8.1.0)
activesupport (= 8.1.0)
railties (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -277,21 +277,21 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.15.0)
rake (13.3.1)
rdoc (6.16.1)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rotp (6.3.0)
rqrcode (3.1.0)
rqrcode (3.1.1)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.81.6)
rqrcode_core (2.0.1)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -302,14 +302,14 @@ GEM
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.48.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.33.4)
rubocop-rails (2.34.2)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -323,7 +323,7 @@ GEM
ruby-vips (2.2.5)
ffi (~> 1.12)
logger
rubyzip (3.2.1)
rubyzip (3.2.2)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
securerandom (0.4.1)
@@ -333,22 +333,28 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.2.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.2.0)
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.8)
solid_cache (1.0.10)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
sqlite3 (2.7.4-aarch64-linux-gnu)
sqlite3 (2.7.4-aarch64-linux-musl)
sqlite3 (2.7.4-arm-linux-gnu)
sqlite3 (2.7.4-arm-linux-musl)
sqlite3 (2.7.4-arm64-darwin)
sqlite3 (2.7.4-x86_64-linux-gnu)
sqlite3 (2.7.4-x86_64-linux-musl)
sqlite3 (2.8.1-aarch64-linux-gnu)
sqlite3 (2.8.1-aarch64-linux-musl)
sqlite3 (2.8.1-arm-linux-gnu)
sqlite3 (2.8.1-arm-linux-musl)
sqlite3 (2.8.1-arm64-darwin)
sqlite3 (2.8.1-x86_64-linux-gnu)
sqlite3 (2.8.1-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -358,28 +364,28 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
tailwindcss-rails (4.3.0)
stringio (3.1.8)
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.13)
tailwindcss-ruby (4.1.13-aarch64-linux-gnu)
tailwindcss-ruby (4.1.13-aarch64-linux-musl)
tailwindcss-ruby (4.1.13-arm64-darwin)
tailwindcss-ruby (4.1.13-x86_64-linux-gnu)
tailwindcss-ruby (4.1.13-x86_64-linux-musl)
tailwindcss-ruby (4.1.16)
tailwindcss-ruby (4.1.16-aarch64-linux-gnu)
tailwindcss-ruby (4.1.16-aarch64-linux-musl)
tailwindcss-ruby (4.1.16-arm64-darwin)
tailwindcss-ruby (4.1.16-x86_64-linux-gnu)
tailwindcss-ruby (4.1.16-x86_64-linux-musl)
thor (1.4.0)
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
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)
turbo-rails (2.0.17)
turbo-rails (2.0.20)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -387,7 +393,7 @@ GEM
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.4)
uri (1.1.1)
useragent (0.16.11)
web-console (4.2.1)
actionview (>= 6.0.0)
@@ -436,13 +442,15 @@ DEPENDENCIES
kamal
letter_opener
propshaft
public_suffix (~> 6.0)
public_suffix (~> 7.0)
puma (>= 5.0)
rails (~> 8.1.0)
rails (~> 8.1.1)
rotp (~> 6.3)
rqrcode (~> 3.1)
rubocop-rails-omakase
selenium-webdriver
sentry-rails (~> 6.2)
sentry-ruby (~> 6.2)
solid_cable
solid_cache
sqlite3 (>= 2.1)

109
README.md
View File

@@ -3,10 +3,29 @@
> [!NOTE]
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
**A lightweight, self-hosted identity & SSO portal**
**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.
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)
* Backchannel Logout
* Per-application logout / revoke
* 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, User & App+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?
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.
@@ -59,22 +78,30 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
- **User statuses** - Active, disabled, or pending invitation
### Authentication Methods
- **WebAuthn/Passkeys** - Modern passwordless authentication using FIDO2 standards
- **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
- **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
#### OpenID Connect (OIDC)
Standard OAuth2/OIDC provider with endpoints:
- `/.well-known/openid-configuration` - Discovery endpoint
- `/authorize` - Authorization endpoint
- `/token` - Token endpoint
- `/authorize` - Authorization endpoint with PKCE support
- `/token` - Token endpoint (authorization_code and refresh_token grants)
- `/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
- **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families
- **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)
Works with reverse proxies (Caddy, Traefik, Nginx):
@@ -98,10 +125,54 @@ Send emails for:
- **Session revocation** - Users and admins can revoke individual sessions
### Access Control
- **Group-based allowlists** - Restrict applications to specific user groups
- **Per-application access** - Each app defines which groups can access it
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
#### Group-Based Application Access
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"]}`
---
@@ -140,25 +211,29 @@ Send emails for:
- 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)
- Active flag
- Many-to-many with Groups (allowlist)
**OIDC Tokens**
- Authorization codes (10-minute expiry, one-time use)
- Access tokens (1-hour expiry, revocable)
- Authorization codes (10-minute expiry, one-time use, PKCE support)
- 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
### 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)
3. Access control check: Is user in an allowed group for this app?
4. If allowed, generate authorization code and redirect to client
5. Client exchanges code for access token at `/token`
6. Client uses access token to fetch user info from `/userinfo`
5. Client exchanges code at `/token` for ID token, access token, and refresh token
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
1. User requests protected resource at `https://app.example.com/dashboard`
@@ -242,6 +317,10 @@ SMTP_ENABLE_STARTTLS=true
# Application
CLINCH_HOST=https://auth.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

View File

@@ -16,16 +16,82 @@ class ActiveSessionsController < ApplicationController
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
count = @user.oidc_user_consents.count
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

View File

@@ -99,8 +99,13 @@ module Admin
def application_params
params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, headers_config: {}
)
: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

View File

@@ -18,7 +18,25 @@ module Admin
end
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
# Handle user assignments
@@ -39,7 +57,24 @@ module Admin
end
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
if params[:group][:user_ids].present?
user_ids = params[:group][:user_ids].reject(&:blank?)
@@ -67,7 +102,7 @@ module Admin
end
def group_params
params.require(:group).permit(:name, :description, custom_claims: {})
params.require(:group).permit(:name, :description, :custom_claims)
end
end
end

View File

@@ -1,6 +1,6 @@
module Admin
class UsersController < BaseController
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims]
def index
@users = User.order(created_at: :desc)
@@ -27,23 +27,34 @@ module Admin
end
def edit
@applications = Application.active.order(:name)
end
def update
# Prevent changing params for the current user's email and admin status
# to avoid locking themselves out
update_params = user_params.dup
if @user == Current.session.user
update_params.delete(:admin)
end
update_params = user_params
# Only update password if provided
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)
redirect_to admin_users_path, notice: "User updated successfully."
else
@applications = Application.active.order(:name)
render :edit, status: :unprocessable_entity
end
end
@@ -69,6 +80,41 @@ module Admin
redirect_to admin_users_path, notice: "User deleted successfully."
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
def set_user
@@ -76,7 +122,15 @@ module Admin
end
def user_params
params.require(:user).permit(:email_address, :name, :password, :admin, :status, custom_claims: {})
# 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

View File

@@ -8,19 +8,45 @@ module Api
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: #{report_data.dig('csp-report', 'blocked-uri')}"
Rails.logger.warn " Document URI: #{report_data.dig('csp-report', 'document-uri')}"
Rails.logger.warn " Referrer: #{report_data.dig('csp-report', 'referrer')}"
Rails.logger.warn " Violated Directive: #{report_data.dig('csp-report', 'violated-directive')}"
Rails.logger.warn " Original Policy: #{report_data.dig('csp-report', 'original-policy')}"
Rails.logger.warn " 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}"
# In production, you might want to send this to a security monitoring service
# For now, we'll just log it and return a success response
# 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

View File

@@ -3,7 +3,7 @@ module Api
# ForwardAuth endpoints need session storage for return URL
allow_unauthenticated_access
skip_before_action :verify_authenticity_token
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests }
# No rate limiting on forward_auth endpoint - proxy middleware hits this frequently
# GET /api/verify
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
@@ -137,7 +137,7 @@ module Api
# Get the redirect URL from query params or construct default
redirect_url = validate_redirect_url(params[:rd])
base_url = redirect_url || "https://clinch.aapamilne.com"
base_url = determine_base_url(redirect_url)
# Set the original URL that user was trying to access
# This will be used after authentication
@@ -214,5 +214,27 @@ module Api
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

View File

@@ -5,4 +5,7 @@ class ApplicationController < ActionController::Base
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
# CSRF protection
protect_from_forgery with: :exception
end

View File

@@ -120,11 +120,11 @@ module Authentication
# Generate a secure random token
token = SecureRandom.urlsafe_base64(32)
# Store it with an expiry of 30 seconds
# Store it with an expiry of 60 seconds
Rails.cache.write(
"forward_auth_token:#{token}",
session_obj.id,
expires_in: 30.seconds
expires_in: 60.seconds
)
# Set the token as a query parameter on the redirect URL
@@ -134,13 +134,16 @@ module Authentication
original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url)
# Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token
uri.query = URI.encode_www_form(query_params)
# 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
# Update the session with the tokenized URL
controller_session[:return_to_after_authenticating] = uri.to_s
end
end
end
end

View File

@@ -1,7 +1,7 @@
class OidcController < ApplicationController
# Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :logout]
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
# GET /.well-known/openid-configuration
def discovery
@@ -11,15 +11,21 @@ class OidcController < ApplicationController
issuer: base_url,
authorization_endpoint: "#{base_url}/oauth/authorize",
token_endpoint: "#{base_url}/oauth/token",
revocation_endpoint: "#{base_url}/oauth/revoke",
userinfo_endpoint: "#{base_url}/oauth/userinfo",
jwks_uri: "#{base_url}/.well-known/jwks.json",
end_session_endpoint: "#{base_url}/logout",
response_types_supported: ["code"],
response_modes_supported: ["query"],
grant_types_supported: ["authorization_code", "refresh_token"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups"],
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
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
@@ -32,30 +38,71 @@ class OidcController < ApplicationController
# GET /oauth/authorize
def authorize
# Get parameters
# Get parameters (ignore forward auth tokens and other unknown params)
client_id = params[:client_id]
redirect_uri = params[:redirect_uri]
state = params[:state]
nonce = params[:nonce]
scope = params[:scope] || "openid"
response_type = params[:response_type]
code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method] || "plain"
# Validate required parameters
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
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
@application = Application.find_by(client_id: client_id, app_type: "oidc")
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
end
# Validate 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
end
@@ -67,7 +114,9 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri,
state: state,
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"
return
@@ -96,6 +145,8 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri,
scope: scope,
nonce: nonce,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
expires_at: 10.minutes.from_now
)
@@ -112,12 +163,34 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri,
state: state,
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
@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
end
@@ -165,6 +238,8 @@ class OidcController < ApplicationController
redirect_uri: oauth_params['redirect_uri'],
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
)
@@ -182,10 +257,17 @@ class OidcController < ApplicationController
def token
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
return
end
end
def handle_authorization_code_grant
# Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials
@@ -205,11 +287,11 @@ class OidcController < ApplicationController
# Get the authorization code
code = params[:code]
redirect_uri = params[:redirect_uri]
code_verifier = params[:code_verifier]
auth_code = OidcAuthorizationCode.find_by(
application: application,
code: code,
used: false
code: code
)
unless auth_code
@@ -217,45 +299,198 @@ class OidcController < ApplicationController
return
end
# Check if code is expired
if auth_code.expires_at < Time.current
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
# 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
if auth_code.expires_at < Time.current
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
return
end
# Validate redirect URI matches
unless auth_code.redirect_uri == redirect_uri
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
return
end
# 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)
# Get the user
user = auth_code.user
# Generate access token record (opaque token with BCrypt hashing)
access_token_record = OidcAccessToken.create!(
application: application,
user: user,
scope: auth_code.scope
)
# Generate refresh token (opaque, with hashing)
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
render json: {
access_token: access_token_record.plaintext_token, # Opaque token
token_type: "Bearer",
expires_in: application.access_token_ttl || 3600,
id_token: id_token, # JWT
refresh_token: refresh_token_record.token, # Opaque token
scope: auth_code.scope
}
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
# Validate redirect URI matches
unless auth_code.redirect_uri == redirect_uri
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
# 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
# Mark code as used
auth_code.update!(used: true)
# 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 = auth_code.user
user = refresh_token_record.user
# Generate access token
access_token = SecureRandom.urlsafe_base64(32)
OidcAccessToken.create!(
# 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,
token: access_token,
scope: auth_code.scope,
expires_at: 1.hour.from_now
scope: refresh_token_record.scope
)
# Generate ID token
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
# 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
)
# Return tokens
# 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: access_token,
access_token: new_access_token.plaintext_token, # Opaque token
token_type: "Bearer",
expires_in: 3600,
id_token: id_token,
scope: auth_code.scope
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
@@ -267,27 +502,29 @@ class OidcController < ApplicationController
return
end
access_token = auth_header.sub("Bearer ", "")
token = auth_header.sub("Bearer ", "")
# Find the access token
token_record = OidcAccessToken.find_by(token: access_token)
unless token_record
# Find and validate access token (opaque token with BCrypt hashing)
access_token = OidcAccessToken.find_by_token(token)
unless access_token&.active?
head :unauthorized
return
end
# Check if token is expired
if token_record.expires_at < Time.current
# Get the user (with fresh data from database)
user = access_token.user
unless user
head :unauthorized
return
end
# Get the user
user = token_record.user
# Find user consent for this application to get pairwise SID
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
subject = consent&.sid || user.id.to_s
# Return user claims
claims = {
sub: user.id.to_s,
sub: subject,
email: user.email_address,
email_verified: true,
preferred_username: user.email_address,
@@ -299,9 +536,6 @@ class OidcController < ApplicationController
claims[:groups] = user.groups.pluck(:name)
end
# Add admin claim if user is admin
claims[:admin] = true if user.admin?
# Merge custom claims from groups
user.groups.each do |group|
claims.merge!(group.parsed_custom_claims)
@@ -310,9 +544,80 @@ class OidcController < ApplicationController
# 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
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
@@ -324,16 +629,29 @@ class OidcController < ApplicationController
# 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, redirect there
# If post_logout_redirect_uri is provided, validate and redirect
if post_logout_redirect_uri.present?
redirect_uri = post_logout_redirect_uri
redirect_uri += "?state=#{state}" if state.present?
redirect_to redirect_uri, allow_other_host: true
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
@@ -342,6 +660,58 @@ class OidcController < ApplicationController
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
# Try Authorization header first (Basic auth)
if request.headers["Authorization"]&.start_with?("Basic ")
@@ -353,4 +723,76 @@ class OidcController < ApplicationController
[params[:client_id], params[:client_secret]]
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

View File

@@ -11,7 +11,7 @@ class PasswordsController < ApplicationController
PasswordsMailer.reset(user).deliver_later
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
def edit
@@ -20,7 +20,7 @@ class PasswordsController < ApplicationController
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
redirect_to signin_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
@@ -29,6 +29,7 @@ class PasswordsController < ApplicationController
private
def set_user_by_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
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end

View File

@@ -6,7 +6,18 @@ class SessionsController < ApplicationController
def new
# 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
def create
@@ -33,8 +44,22 @@ class SessionsController < ApplicationController
return
end
# Check if TOTP is required
if user.totp_enabled?
# Check if TOTP is required or 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
session[:pending_totp_user_id] = user.id
# Preserve the redirect URL through TOTP verification (after validation)
@@ -109,6 +134,12 @@ class SessionsController < ApplicationController
end
def destroy
# Send backchannel logout notifications before terminating session
if authenticated?
user = Current.session.user
send_backchannel_logout_notifications(user)
end
terminate_session
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
end
@@ -275,15 +306,37 @@ class SessionsController < ApplicationController
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
# Check against our ForwardAuthRules
matching_rule = ForwardAuthRule.active.find do |rule|
rule.matches_domain?(redirect_domain)
# Check against our forward auth applications
matching_app = Application.forward_auth.active.find do |app|
app.matches_domain?(redirect_domain)
end
matching_rule ? url : nil
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

View File

@@ -5,6 +5,9 @@ class TotpController < ApplicationController
# GET /totp/new - Show QR code to set up TOTP
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
@totp_secret = ROTP::Base32.random
@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)
# Save the secret and generate backup codes
@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!
# Redirect to backup codes page with success message
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
# 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."
end
else
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
end
@@ -36,8 +50,21 @@ class TotpController < ApplicationController
# GET /totp/backup_codes - Show backup codes (requires password)
def backup_codes
# This will be shown after password verification
@backup_codes = @user.parsed_backup_codes
# Check if we have temporary codes from TOTP setup
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
# POST /totp/verify_password - Verify password before showing backup codes
@@ -49,6 +76,40 @@ class TotpController < ApplicationController
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)
def destroy
unless @user.authenticate(params[:password])
@@ -56,6 +117,12 @@ class TotpController < ApplicationController
return
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!
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
end
@@ -67,7 +134,8 @@ class TotpController < ApplicationController
end
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."
end
end
@@ -77,8 +145,4 @@ class TotpController < ApplicationController
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
end
end
def generate_backup_codes
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
end
end

View File

@@ -19,4 +19,14 @@ module ApplicationHelper
: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

View 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

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

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

View 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]
}
}

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

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

View File

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

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

View 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();
}
}
}

View File

@@ -22,7 +22,11 @@ export default class extends Controller {
}
hide() {
if (this.hasDialogTarget) {
// 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");

View File

@@ -1,51 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["userSelect", "assignLink", "editForm"]
connect() {
console.log("Role management controller connected")
}
assignRole(event) {
event.preventDefault()
const link = event.currentTarget
const roleId = link.dataset.roleId
const select = document.getElementById(`assign-user-${roleId}`)
if (!select.value) {
alert("Please select a user")
return
}
// Update the href with the selected user ID
const originalHref = link.href
const newHref = originalHref.replace("PLACEHOLDER", select.value)
// Navigate to the updated URL
window.location.href = newHref
}
toggleEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.toggle("hidden")
}
}
hideEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.add("hidden")
}
}
}

View File

@@ -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

View 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

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
layout "mailer"
end

View File

@@ -1,10 +1,17 @@
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 :allowed_groups, through: :application_groups, source: :group
has_many :application_user_claims, dependent: :destroy
has_many :oidc_authorization_codes, 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
@@ -13,12 +20,33 @@ class Application < ApplicationRecord
validates :app_type, presence: true,
inclusion: { in: %w[oidc forward_auth] }
validates :client_id, uniqueness: { allow_nil: true }
validates :client_secret, presence: true, if: :oidc?
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 :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
normalizes :domain_pattern, with: ->(pattern) {
normalized = pattern&.strip&.downcase
normalized.blank? ? nil : normalized
}
normalizes :backchannel_logout_uri, with: ->(uri) {
normalized = uri&.strip
normalized.blank? ? nil : normalized
}
before_validation :generate_client_credentials, on: :create, if: :oidc?
@@ -151,8 +179,86 @@ class Application < ApplicationRecord
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
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
self.client_id ||= SecureRandom.urlsafe_base64(32)
# Generate and hash the client secret
@@ -161,4 +267,18 @@ class Application < ApplicationRecord
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

View 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

View File

@@ -4,11 +4,31 @@ class Group < ApplicationRecord
has_many :application_groups, dependent: :destroy
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 }
normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names
# Parse custom_claims JSON field
def parsed_custom_claims
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

View File

@@ -1,34 +1,83 @@
class OidcAccessToken < ApplicationRecord
belongs_to :application
belongs_to :user
has_many :oidc_refresh_tokens, dependent: :destroy
before_validation :generate_token, 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 :revoked, -> { where.not(revoked_at: nil) }
scope :active, -> { valid }
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
def expired?
expires_at <= Time.current
end
def revoked?
revoked_at.present?
end
def active?
!expired?
!expired? && !revoked?
end
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
private
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
def set_expiry
self.expires_at ||= 1.hour.from_now
self.expires_at ||= application.access_token_expiry
end
end

View File

@@ -7,6 +7,8 @@ class OidcAuthorizationCode < ApplicationRecord
validates :code, presence: true, uniqueness: 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 :expired, -> { where("expires_at <= ?", Time.current) }
@@ -23,6 +25,10 @@ class OidcAuthorizationCode < ApplicationRecord
update!(used: true)
end
def uses_pkce?
code_challenge.present?
end
private
def generate_code
@@ -32,4 +38,11 @@ class OidcAuthorizationCode < ApplicationRecord
def set_expiry
self.expires_at ||= 10.minutes.from_now
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

View 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

View File

@@ -6,6 +6,7 @@ class OidcUserConsent < ApplicationRecord
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
@@ -44,9 +45,18 @@ class OidcUserConsent < ApplicationRecord
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

View File

@@ -3,6 +3,7 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy
has_many :user_groups, dependent: :destroy
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
@@ -20,10 +21,22 @@ class User < ApplicationRecord
end
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 },
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
validate :no_reserved_claim_names
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
@@ -44,7 +57,9 @@ class User < ApplicationRecord
end
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
def totp_provisioning_uri(issuer: "Clinch")
@@ -66,19 +81,53 @@ class User < ApplicationRecord
def verify_backup_code(code)
return false unless backup_codes.present?
codes = JSON.parse(backup_codes)
if codes.include?(code)
codes.delete(code)
update(backup_codes: codes.to_json)
# Rate limiting: prevent brute force attacks on backup codes
if rate_limit_backup_code_verification?
Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
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
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
end
end
def parsed_backup_codes
return [] unless backup_codes.present?
JSON.parse(backup_codes)
# Rate limiting for backup code verification to prevent brute force attacks
def rate_limit_backup_code_verification?
# 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
@@ -146,12 +195,50 @@ class User < ApplicationRecord
# Parse custom_claims JSON field
def parsed_custom_claims
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
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
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

View 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

View File

@@ -1,18 +1,25 @@
class OidcJwtService
extend ClaimsMerger
class << self
# 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
# 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 = {
iss: issuer_url,
sub: user.id.to_s,
sub: subject,
aud: application.client_id,
exp: now + 3600, # 1 hour
exp: now + ttl,
iat: now,
email: user.email_address,
email_verified: true,
preferred_username: user.email_address,
preferred_username: user.username.presence || user.email_address,
name: user.name.presence || user.email_address
}
@@ -24,17 +31,41 @@ class OidcJwtService
payload[:groups] = user.groups.pluck(:name)
end
# Add admin claim if user is admin
payload[:admin] = true if user.admin?
# Merge custom claims from groups
# Merge custom claims from groups (arrays are combined, not overwritten)
user.groups.each do |group|
payload.merge!(group.parsed_custom_claims)
payload = deep_merge_claims(payload, group.parsed_custom_claims)
end
# Merge custom claims from user (overrides group claims)
payload.merge!(user.parsed_custom_claims)
# 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" })
end
@@ -63,7 +94,14 @@ class OidcJwtService
def issuer_url
# In production, this should come from ENV or config
# For now, we'll use a placeholder that can be overridden
"https://#{ENV.fetch("CLINCH_HOST", "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
private
@@ -71,17 +109,37 @@ class OidcJwtService
# Get or generate RSA private key
def private_key
@private_key ||= begin
key_source = nil
# Try ENV variable first (best for Docker/Kamal)
if ENV["OIDC_PRIVATE_KEY"].present?
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"])
key_source = ENV["OIDC_PRIVATE_KEY"]
# Then try Rails credentials
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
# Generate a new key for development
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials
# In production, we should never generate a key on the fly
# 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: 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)
end
end

View File

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
<% if application.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% application.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -34,6 +17,87 @@
<%= 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 class="flex items-center justify-between">
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
Browse icons at dashboardicons.com
</a>
</div>
<% if application.icon.attached? && application.persisted? %>
<% begin %>
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
<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 %>
<% rescue ArgumentError => e %>
<%# Handle case where icon attachment exists but can't generate signed_id %>
<% if e.message.include?("Cannot get a signed_id for a new record") %>
<div class="mt-2 mb-3 text-sm text-gray-600">
<p class="font-medium">Icon uploaded</p>
<p class="text-xs">File will be processed shortly</p>
</div>
<% else %>
<%# Re-raise if it's a different error %>
<% raise e %>
<% end %>
<% end %>
<% end %>
<div class="mt-2" data-controller="file-drop image-paste">
<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-image-paste-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
tabindex="0">
<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",
image_paste_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>
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</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" %>
@@ -42,14 +106,18 @@
<div>
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, 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? %>
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
<% end %>
</div>
<!-- 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>
<div>
@@ -57,10 +125,67 @@
<%= 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>
</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" style="<%= 'display: none;' unless application.forward_auth? %>">
<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>
@@ -69,12 +194,25 @@
<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>
<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, 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"}' %>
<%= 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">
<p class="font-medium">Optional: Customize header names sent to your application.</p>
<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">
@@ -120,30 +258,3 @@
</div>
<% end %>
<script>
// Show/hide type-specific fields based on app type selection
const appTypeSelect = document.querySelector('#application_app_type');
const oidcFields = document.querySelector('#oidc-fields');
const forwardAuthFields = document.querySelector('#forward-auth-fields');
function updateFieldVisibility() {
if (!appTypeSelect) return;
const appType = appTypeSelect.value;
if (oidcFields) {
oidcFields.style.display = appType === 'oidc' ? 'block' : 'none';
}
if (forwardAuthFields) {
forwardAuthFields.style.display = appType === 'forward_auth' ? 'block' : 'none';
}
}
if (appTypeSelect) {
appTypeSelect.addEventListener('change', updateFieldVisibility);
}
// Initialize visibility on page load
updateFieldVisibility();
</script>

View File

@@ -14,7 +14,7 @@
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">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">Type</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| %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
<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" %>
</div>
</td>
<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>
@@ -37,6 +48,8 @@
<% case application.app_type %>
<% when "oidc" %>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
<% when "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" %>
<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 %>

View File

@@ -16,10 +16,21 @@
</div>
<% end %>
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
<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>
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
</div>
</div>
<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" %>
@@ -78,27 +89,29 @@
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="flex items-center justify-between mb-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" %>
</div>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
<dd class="mt-1 text-sm text-gray-900">
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
<dd class="mt-1 text-sm text-gray-900">
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
🔒 Client secret is stored securely and cannot be displayed
</div>
<p class="mt-2 text-xs text-gray-500">
To get a new client secret, use the "Regenerate Credentials" button above.
</p>
</dd>
</div>
<% unless flash[:client_id] && flash[:client_secret] %>
<div>
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
<dd class="mt-1 text-sm text-gray-900">
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
<dd class="mt-1 text-sm text-gray-900">
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
🔒 Client secret is stored securely and cannot be displayed
</div>
<p class="mt-2 text-xs text-gray-500">
To get a new client secret, use the "Regenerate Credentials" button above.
</p>
</dd>
</div>
<% end %>
<div>
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
<dd class="mt-1 text-sm text-gray-900">
@@ -111,6 +124,27 @@
<% end %>
</dd>
</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>

View File

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
<% if group.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% group.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -49,10 +32,25 @@
<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"]}' %>
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
<%= 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">

View File

@@ -39,9 +39,11 @@
<%= pluralize(group.applications.count, "app") %>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
<%= 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" %>
<div class="flex justify-end space-x-3">
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= 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>
</tr>
<% end %>

View 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 %>

View File

@@ -1,32 +1,21 @@
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
<% if user.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
<%= render "shared/form_errors", form: form %>
<div>
<%= 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" %>
</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: Name shown in applications. Defaults to email address if not set.</p>
<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>
@@ -53,9 +42,43 @@
</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"}' %>
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
<%= 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">

View File

@@ -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>
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
<%= render "form", user: @user %>
<div class="max-w-2xl">
<%= render "form", user: @user %>
</div>
<% if @user.persisted? %>
<%= render "application_claims", user: @user, applications: @applications %>
<% end %>
</div>

View File

@@ -85,15 +85,20 @@
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if user.totp_enabled? %>
<svg class="h-5 w-5 text-green-500" 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>
<% else %>
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<% end %>
<div class="flex items-center gap-2">
<% if user.totp_enabled? %>
<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>
</svg>
<% else %>
<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>
</svg>
<% 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 class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<%= user.groups.count %>

View File

@@ -102,38 +102,56 @@
<% @applications.each do |app| %>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
<div class="p-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-semibold text-gray-900 truncate">
<%= app.name %>
</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<% if app.oidc? %>
bg-blue-100 text-blue-800
<% else %>
bg-green-100 text-green-800
<% end %>">
<%= app.app_type.humanize %>
</span>
<div 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>
<p class="text-sm text-gray-600 mb-4">
<% if app.oidc? %>
OIDC Application
<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 %>
ForwardAuth Protected Application
<div class="text-sm text-gray-500 italic">
No landing URL configured
</div>
<% end %>
</p>
<% if app.landing_url.present? %>
<%= link_to "Open Application", app.landing_url,
target: "_blank",
rel: "noopener noreferrer",
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
<% else %>
<div class="text-sm text-gray-500 italic">
No landing URL configured
</div>
<% end %>
<% 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 %>

View File

@@ -25,24 +25,29 @@
<body>
<% if authenticated? %>
<%= render "shared/sidebar" %>
<div class="lg:pl-64">
<!-- 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">
<button type="button" class="-m-2.5 p-2.5 text-gray-700" id="mobile-menu-button">
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">
<%= render "shared/flash" %>
<%= yield %>
<div data-controller="mobile-sidebar">
<%= render "shared/sidebar" %>
<div class="lg:pl-64">
<!-- 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">
<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>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
</main>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">
<%= render "shared/flash" %>
<%= yield %>
</div>
</main>
</div>
</div>
<% else %>
<!-- Public layout (signup/signin) -->
@@ -52,23 +57,5 @@
</main>
<% 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>
</html>

View File

@@ -1,6 +1,15 @@
<div class="mx-auto max-w-md">
<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>
<p class="mt-2 text-sm text-gray-600">
<strong><%= @application.name %></strong> is requesting access to your account.
@@ -57,7 +66,7 @@
</div>
</div>
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %>
<%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
<%= form.submit "Authorize",
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>

View File

@@ -5,7 +5,7 @@
<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">
<%= 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>

View File

@@ -1,4 +1,4 @@
<div class="space-y-8">
<div class="space-y-8" data-controller="modal">
<div>
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
@@ -98,23 +98,52 @@
<p class="text-sm font-medium text-green-800">
Two-factor authentication is enabled
</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 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>
<% 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">
<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>
<% 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 %>
<%= 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
@@ -126,7 +155,6 @@
<!-- Disable 2FA Modal -->
<div id="disable-2fa-modal"
data-controller="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">
@@ -164,18 +192,27 @@
</div>
</div>
<!-- View Backup Codes Modal -->
<!-- Regenerate Backup Codes Modal -->
<div id="view-backup-codes-modal"
data-controller="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>
<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">
<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>
<%= 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>
<%= password_field_tag :password, nil,
placeholder: "Enter your password",
@@ -184,7 +221,7 @@
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<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" %>
<button type="button"
data-action="click->modal#hide"

View File

@@ -3,7 +3,7 @@
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
</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? %>
<div class="my-5">
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>

View File

@@ -1,29 +1,73 @@
<% if flash[:alert] %>
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
<div class="flex">
<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 %>
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
<% flash.each do |type, message| %>
<% next if message.blank? %>
<% 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-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<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"/>
<div class="shrink-0">
<svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
<div class="ml-3 flex-1">
<p class="text-sm font-medium <%= text_class %>"><%= message %></p>
</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>
<% end %>

View File

@@ -1,23 +1,36 @@
<% if form.object.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<%# Usage: render "shared/form_errors", object: @user %>
<%# 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-shrink-0">
<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>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
<div class="ml-3 flex-1">
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc space-y-1 pl-5">
<% form.object.errors.full_messages.each do |message| %>
<div class="mt-2">
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
<% form_object.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</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>
<% end %>

View File

@@ -90,7 +90,7 @@
<!-- Sign Out -->
<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">
<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>
@@ -105,12 +105,18 @@
</div>
<!-- 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 flex">
<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">
<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>
<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" />
@@ -138,7 +144,7 @@
<!-- Same nav items as desktop -->
<ul role="list" class="-mx-2 space-y-1">
<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">
<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>
@@ -147,7 +153,7 @@
</li>
<% if user.admin? %>
<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">
<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>
@@ -155,7 +161,7 @@
<% end %>
</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">
<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>
@@ -163,7 +169,7 @@
<% end %>
</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">
<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>
@@ -172,7 +178,7 @@
</li>
<% end %>
<li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
<%= 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">
<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>
@@ -180,7 +186,7 @@
<% end %>
</li>
<li>
<%= link_to active_sessions_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 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>
@@ -188,7 +194,7 @@
<% end %>
</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">
<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>

View File

@@ -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">
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
<p class="mt-2 text-sm text-gray-600">
@@ -29,14 +29,14 @@
</div>
<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">
<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>
Download Codes
</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">
<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>
@@ -45,34 +45,15 @@
</div>
<div class="mt-8">
<%= 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" %>
<% 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,
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>
<script>
const backupCodes = <%= raw @backup_codes.to_json %>;
function downloadBackupCodes() {
const content = "Clinch Backup Codes\n" +
"===================\n\n" +
backupCodes.join("\n") +
"\n\nSave these codes in a secure location.";
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'clinch-backup-codes.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
function printBackupCodes() {
window.print();
}
</script>

View File

@@ -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>

View File

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

View File

@@ -83,4 +83,14 @@ Rails.application.configure do
# Apply autocorrection by RuboCop to files generated by `bin/rails 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

View File

@@ -80,14 +80,28 @@ Rails.application.configure do
# Only use :id for inspections in production.
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.
# Configure allowed hosts based on deployment scenario
allowed_hosts = [
ENV.fetch('CLINCH_HOST', 'auth.example.com'), # External domain (auth service itself)
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 = ENV.fetch('CLINCH_HOST', 'auth.example.com')
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
if host_domain.present?
begin
# Use PublicSuffix to properly extract the domain
@@ -133,4 +147,18 @@ Rails.application.configure do
# Skip DNS rebinding protection for the default health check endpoint.
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

View File

@@ -50,4 +50,8 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions.
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

View 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

View File

@@ -6,72 +6,64 @@
Rails.application.configure do
config.content_security_policy do |policy|
# Default policy: only allow resources from same origin and HTTPS
policy.default_src :self, :https
# Default to self for everything, plus blob: for file downloads
policy.default_src :self, "blob:"
# Scripts: strict security with nonce support for dynamic content
policy.script_src :self, :https, :strict_dynamic
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:"
# Styles: allow inline styles for CSS frameworks, but require HTTPS
policy.style_src :self, :https, :unsafe_inline
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
# and Stimulus controller style manipulations
policy.style_src :self, :unsafe_inline
# Images: allow data URIs for inline images and HTTPS sources
policy.img_src :self, :https, :data
# Images: Allow self, data URLs, and https for external images
policy.img_src :self, :data, :https
# Fonts: allow self-hosted and HTTPS fonts, plus data URIs
policy.font_src :self, :https, :data
# Fonts: Allow self and data URLs
policy.font_src :self, :data
# Media: allow self and HTTPS media sources
policy.media_src :self, :https
# Connect: Allow self for API calls, WebAuthn, and ActionCable if needed
# WebAuthn endpoints are on the same domain, so self is sufficient
policy.connect_src :self, "wss:"
# Objects: block potentially dangerous plugins
# Media: Allow self
policy.media_src :self
# Object and embed sources: Disallow for security (no Flash/etc)
policy.object_src :none
# Base URI: restrict base tag to same origin
policy.base_uri :self
# Form actions: only allow forms to submit to same origin
policy.form_action :self
# Frame ancestors: prevent clickjacking by disallowing framing
policy.frame_src :none
policy.frame_ancestors :none
# Frame sources: block iframes unless explicitly needed
policy.frame_src :none
# Base URI: Restricted to self
policy.base_uri :self
# Connect sources: control where XHR/Fetch can connect
policy.connect_src :self, :https
# Form actions: Allow self for all form submissions
# Note: OAuth redirects will be handled dynamically in the consent page
policy.form_action :self
# Manifest: only allow same-origin manifest files
# Manifest sources: Allow self for PWA manifest
policy.manifest_src :self
# Worker sources: control web worker origins
policy.worker_src :self, :https
# Worker sources: Allow self for potential Web Workers
policy.worker_src :self
# Report URI: send violation reports to our monitoring endpoint
if Rails.env.production?
policy.report_uri "/api/csp-violation-report"
end
# 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
# Generate session nonces for permitted inline scripts and styles
config.content_security_policy_nonce_generator = ->(request) {
# Use a secure random nonce instead of session ID for better security
SecureRandom.base64(16)
}
# Apply nonces to script and style directives
config.content_security_policy_nonce_directives = %w(script-src style-src)
# 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?
# Automatically add `nonce` attributes to script/style tags
config.content_security_policy_nonce_auto = true
# Enforce CSP in production, but use report-only in development for debugging
if Rails.env.production?
# Enforce the policy in production
config.content_security_policy_report_only = false
else
# Report violations only in development (helps with debugging)
config.content_security_policy_report_only = true
end
# Report CSP violations (optional - uncomment to enable)
# config.content_security_policy_report_uri = "/csp-violations"
end

View 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

View 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

View 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

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
module Clinch
VERSION = "0.6.3"
end

View File

@@ -1,14 +1,31 @@
# WebAuthn configuration for Clinch Identity Provider
WebAuthn.configure do |config|
# Relying Party name (displayed in authenticator prompts)
# For development, use http://localhost to match passkey in Passwords app
# 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)
# Extract domain from origin for RP ID
origin_uri = URI.parse(origin_host)
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
# 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?

17
config/recurring.yml Normal file
View File

@@ -0,0 +1,17 @@
# Solid Queue Recurring Jobs Configuration
# This file defines scheduled/cron-like jobs that run periodically
production:
oidc_token_cleanup:
class: OidcTokenCleanupJob
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

View File

@@ -29,6 +29,7 @@ Rails.application.routes.draw do
get "/oauth/authorize", to: "oidc#authorize"
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
post "/oauth/token", to: "oidc#token"
post "/oauth/revoke", to: "oidc#revoke"
get "/oauth/userinfo", to: "oidc#userinfo"
get "/logout", to: "oidc#logout"
@@ -48,6 +49,7 @@ Rails.application.routes.draw do
end
resource :active_sessions, only: [:show] do
member do
delete :logout_from_app
delete :revoke_consent
delete :revoke_all_consents
end
@@ -64,6 +66,9 @@ Rails.application.routes.draw do
delete '/totp', to: 'totp#destroy'
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_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
@@ -78,6 +83,8 @@ Rails.application.routes.draw do
resources :users do
member do
post :resend_invitation
post :update_application_claims
delete :delete_application_claims
end
end
resources :applications do

View File

@@ -4,7 +4,7 @@ test:
local:
service: Disk
root: <%= Rails.root.join("storage") %>
root: <%= Rails.root.join("storage/uploads") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
class AddPkceSupportToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
def change
add_column :oidc_authorization_codes, :code_challenge, :string
add_column :oidc_authorization_codes, :code_challenge_method, :string
# Add index for code_challenge to improve query performance
add_index :oidc_authorization_codes, :code_challenge
end
end

View File

@@ -0,0 +1,17 @@
class FixEmptyDomainPatterns < ActiveRecord::Migration[8.1]
def up
# Convert empty string domain_patterns to NULL
# This fixes a unique constraint issue where multiple OIDC apps
# had empty string domain_patterns, causing uniqueness violations
execute <<-SQL
UPDATE applications
SET domain_pattern = NULL
WHERE domain_pattern = ''
SQL
end
def down
# No need to reverse this - empty strings and NULL are functionally equivalent
# for OIDC applications where domain_pattern is not used
end
end

View File

@@ -0,0 +1,22 @@
class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
def change
create_table :oidc_refresh_tokens do |t|
t.string :token_digest, null: false # BCrypt hashed token
t.references :application, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.references :oidc_access_token, null: false, foreign_key: true
t.string :scope
t.datetime :expires_at, null: false
t.datetime :revoked_at
t.integer :token_family_id # For token rotation detection
t.timestamps
end
add_index :oidc_refresh_tokens, :token_digest, unique: true
add_index :oidc_refresh_tokens, :expires_at
add_index :oidc_refresh_tokens, :revoked_at
add_index :oidc_refresh_tokens, :token_family_id
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
end
end

View File

@@ -0,0 +1,9 @@
class AddTokenDigestToOidcAccessTokens < ActiveRecord::Migration[8.1]
def change
add_column :oidc_access_tokens, :token_digest, :string
add_column :oidc_access_tokens, :revoked_at, :datetime
add_index :oidc_access_tokens, :token_digest, unique: true
add_index :oidc_access_tokens, :revoked_at
end
end

View File

@@ -0,0 +1,7 @@
class AddTokenExpiryToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :access_token_ttl, :integer, default: 3600 # 1 hour in seconds
add_column :applications, :refresh_token_ttl, :integer, default: 2592000 # 30 days in seconds
add_column :applications, :id_token_ttl, :integer, default: 3600 # 1 hour in seconds
end
end

View File

@@ -0,0 +1,5 @@
class MakeOidcAccessTokenTokenNullable < ActiveRecord::Migration[8.1]
def change
change_column_null :oidc_access_tokens, :token, true
end
end

View File

@@ -0,0 +1,15 @@
class AddSidToOidcUserConsent < ActiveRecord::Migration[8.1]
def change
add_column :oidc_user_consents, :sid, :string
add_index :oidc_user_consents, :sid
# Generate UUIDs for existing consent records
reversible do |dir|
dir.up do
OidcUserConsent.where(sid: nil).find_each do |consent|
consent.update_column(:sid, SecureRandom.uuid)
end
end
end
end
end

View File

@@ -0,0 +1,13 @@
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
def change
create_table :application_user_claims do |t|
t.references :application, null: false, foreign_key: { on_delete: :cascade }
t.references :user, null: false, foreign_key: { on_delete: :cascade }
t.json :custom_claims, default: {}, null: false
t.timestamps
end
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique'
end
end

View File

@@ -0,0 +1,6 @@
class AddUsernameToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :username, :string
add_index :users, :username, unique: true
end
end

View File

@@ -0,0 +1,57 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end

View File

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

88
db/schema.rb generated
View File

@@ -10,7 +10,35 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.bigint "byte_size", null: false
t.string "checksum"
t.string "content_type"
t.datetime "created_at", null: false
t.string "filename", null: false
t.string "key", null: false
t.text "metadata"
t.string "service_name", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
@@ -21,19 +49,34 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
t.index ["group_id"], name: "index_application_groups_on_group_id"
end
create_table "application_user_claims", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_app_user_claims_unique", unique: true
t.index ["application_id"], name: "index_application_user_claims_on_application_id"
t.index ["user_id"], name: "index_application_user_claims_on_user_id"
end
create_table "applications", force: :cascade do |t|
t.integer "access_token_ttl", default: 3600
t.boolean "active", default: true, null: false
t.string "app_type", null: false
t.string "backchannel_logout_uri"
t.string "client_id"
t.string "client_secret_digest"
t.datetime "created_at", null: false
t.text "description"
t.string "domain_pattern"
t.json "headers_config", default: {}, null: false
t.integer "id_token_ttl", default: 3600
t.string "landing_url"
t.text "metadata"
t.string "name", null: false
t.text "redirect_uris"
t.integer "refresh_token_ttl", default: 2592000
t.string "slug", null: false
t.datetime "updated_at", null: false
t.index ["active"], name: "index_applications_on_active"
@@ -55,20 +98,26 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.datetime "revoked_at"
t.string "scope"
t.string "token", null: false
t.string "token"
t.string "token_digest"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at"
t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true
t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
end
create_table "oidc_authorization_codes", force: :cascade do |t|
t.integer "application_id", null: false
t.string "code", null: false
t.string "code_challenge"
t.string "code_challenge_method"
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.string "nonce"
@@ -80,19 +129,43 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id"
t.index ["code"], name: "index_oidc_authorization_codes_on_code", unique: true
t.index ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge"
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
end
create_table "oidc_refresh_tokens", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.integer "oidc_access_token_id", null: false
t.datetime "revoked_at"
t.string "scope"
t.string "token_digest", null: false
t.integer "token_family_id"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_refresh_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
t.index ["token_digest"], name: "index_oidc_refresh_tokens_on_token_digest", unique: true
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
end
create_table "oidc_user_consents", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.datetime "granted_at", null: false
t.text "scopes_granted", null: false
t.string "sid"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id"], name: "index_oidc_user_consents_on_application_id"
t.index ["granted_at"], name: "index_oidc_user_consents_on_granted_at"
t.index ["sid"], name: "index_oidc_user_consents_on_sid"
t.index ["user_id", "application_id"], name: "index_oidc_user_consents_on_user_id_and_application_id", unique: true
t.index ["user_id"], name: "index_oidc_user_consents_on_user_id"
end
@@ -124,7 +197,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
create_table "users", force: :cascade do |t|
t.boolean "admin", default: false, null: false
t.text "backup_codes"
t.json "backup_codes"
t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false
t.string "email_address", null: false
@@ -136,10 +209,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
t.boolean "totp_required", default: false, null: false
t.string "totp_secret"
t.datetime "updated_at", null: false
t.string "username"
t.string "webauthn_id"
t.boolean "webauthn_required", default: false, null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["status"], name: "index_users_on_status"
t.index ["username"], name: "index_users_on_username", unique: true
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
end
@@ -165,12 +240,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "application_groups", "applications"
add_foreign_key "application_groups", "groups"
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
add_foreign_key "application_user_claims", "users", on_delete: :cascade
add_foreign_key "oidc_access_tokens", "applications"
add_foreign_key "oidc_access_tokens", "users"
add_foreign_key "oidc_authorization_codes", "applications"
add_foreign_key "oidc_authorization_codes", "users"
add_foreign_key "oidc_refresh_tokens", "applications"
add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens"
add_foreign_key "oidc_refresh_tokens", "users"
add_foreign_key "oidc_user_consents", "applications"
add_foreign_key "oidc_user_consents", "users"
add_foreign_key "sessions", "users"

136
docs/oidc-key-setup.md Normal file
View File

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

View File

@@ -0,0 +1,441 @@
require "test_helper"
class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
def setup
@user = User.create!(email_address: "security_test@example.com", password: "password123")
@application = Application.create!(
name: "Security Test App",
slug: "security-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
# Store the plain text client secret for testing
@client_secret = @application.client_secret_digest
@application.generate_new_client_secret!
@plain_client_secret = @application.client_secret
@application.save!
end
def teardown
OidcAuthorizationCode.where(application: @application).delete_all
# Use delete_all to avoid triggering callbacks that might have issues with the schema
OidcAccessToken.where(application: @application).delete_all
@user.destroy
@application.destroy
end
# ====================
# CRITICAL SECURITY TESTS
# ====================
test "prevents authorization code reuse - sequential attempts" do
# Create a valid authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# First request should succeed
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
first_response = JSON.parse(@response.body)
assert first_response.key?("access_token")
assert first_response.key?("id_token")
# Second request with same code should fail
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/already been used/, error["error_description"])
end
test "revokes existing tokens when authorization code is reused" do
# Create a valid authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# First request - get access token
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
first_response = JSON.parse(@response.body)
first_access_token = first_response["access_token"]
# Verify the token works
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{first_access_token}"
}
assert_response :success
# Second request with same code - should fail AND revoke first token
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
# Verify the first token is now revoked (expired)
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{first_access_token}"
}
assert_response :unauthorized, "First access token should be revoked after code reuse"
end
test "rejects already used authorization code" do
# Create and mark code as used
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
used: true,
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/already been used/, error["error_description"])
end
test "rejects expired authorization code" do
# Create expired code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 5.minutes.ago
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/expired/, error["error_description"])
end
test "rejects authorization code with mismatched redirect_uri" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/Redirect URI mismatch/, error["error_description"])
end
test "rejects non-existent authorization code" do
token_params = {
grant_type: "authorization_code",
code: "nonexistent_code_12345",
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
end
test "rejects authorization code for different application" do
# Create another application
other_app = Application.create!(
name: "Other App",
slug: "other-app",
app_type: "oidc",
redirect_uris: ["http://localhost:5000/callback"].to_json,
active: true
)
other_secret = other_app.client_secret
# Create auth code for first application
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Try to use it with different application credentials
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{other_app.client_id}:#{other_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
other_app.destroy
end
# ====================
# CLIENT AUTHENTICATION TESTS
# ====================
test "rejects invalid client_id in Basic auth" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("invalid_client_id:#{@plain_client_secret}")
}
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
test "rejects invalid client_secret in Basic auth" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret")
}
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
test "accepts client credentials in POST body" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
client_id: @application.client_id,
client_secret: @plain_client_secret
}
post "/oauth/token", params: token_params
assert_response :success
response_body = JSON.parse(@response.body)
assert response_body.key?("access_token")
assert response_body.key?("id_token")
end
test "rejects request with no client authentication" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
# ====================
# GRANT TYPE VALIDATION
# ====================
test "rejects unsupported grant_type" do
post "/oauth/token", params: {
grant_type: "password",
username: "user",
password: "pass"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "unsupported_grant_type", error["error"]
end
test "rejects missing grant_type" do
post "/oauth/token", params: {
code: "some_code",
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "unsupported_grant_type", error["error"]
end
# ====================
# TIMING ATTACK PROTECTION
# ====================
test "client authentication uses constant-time comparison" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# Test with completely wrong secret
times_wrong = []
5.times do
start_time = Time.now.to_f
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret_xxx")
}
times_wrong << (Time.now.to_f - start_time)
assert_response :unauthorized
end
# Test with almost correct secret (differs by one character)
correct_secret = @plain_client_secret
almost_correct = correct_secret[0..-2] + "X"
times_almost = []
5.times do
start_time = Time.now.to_f
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{almost_correct}")
}
times_almost << (Time.now.to_f - start_time)
assert_response :unauthorized
end
# The timing difference should be minimal (within 50ms) if using constant-time comparison
avg_wrong = times_wrong.sum / times_wrong.size
avg_almost = times_almost.sum / times_almost.size
timing_difference = (avg_wrong - avg_almost).abs
# This is a best-effort check - in practice, constant-time comparison is handled by bcrypt
assert timing_difference < 0.05,
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
end
end

View File

@@ -0,0 +1,299 @@
require "test_helper"
class OidcPkceControllerTest < ActionDispatch::IntegrationTest
def setup
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
@application = Application.create!(
name: "PKCE Test App",
slug: "pkce-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
# Sign in the user using the test helper
sign_in_as(@user)
end
def teardown
Current.session&.destroy
OidcAuthorizationCode.where(application: @application).destroy_all
OidcAccessToken.where(application: @application).destroy_all
@user.destroy
@application.destroy
end
test "discovery endpoint includes PKCE support" do
get "/.well-known/openid-configuration"
assert_response :success
config = JSON.parse(@response.body)
assert config.key?("code_challenge_methods_supported")
assert_includes config["code_challenge_methods_supported"], "S256"
assert_includes config["code_challenge_methods_supported"], "plain"
end
test "authorization endpoint accepts PKCE parameters (S256)" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
state: "test_state",
nonce: "test_nonce",
code_challenge: code_challenge,
code_challenge_method: "S256"
}
get "/oauth/authorize", params: auth_params
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
end
test "authorization endpoint accepts PKCE parameters (plain)" do
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
state: "test_state",
nonce: "test_nonce",
code_challenge: code_challenge,
code_challenge_method: "plain"
}
get "/oauth/authorize", params: auth_params
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
end
test "authorization endpoint rejects invalid code_challenge_method" do
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "invalid_method"
}
get "/oauth/authorize", params: auth_params
assert_response :bad_request
assert_match(/Invalid code_challenge_method/, @response.body)
end
test "authorization endpoint rejects invalid code_challenge format" do
# Contains + character which is not base64url
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "invalid+challenge",
code_challenge_method: "S256"
}
get "/oauth/authorize", params: auth_params
assert_response :bad_request
assert_match(/Invalid code_challenge format/, @response.body)
end
test "token endpoint requires code_verifier when PKCE was used (S256)" do
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match(/code_verifier is required/, error["error_description"])
end
test "token endpoint requires code_verifier when PKCE was used (plain)" do
# Create authorization code with PKCE plain
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "plain",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match(/code_verifier is required/, error["error_description"])
end
test "token endpoint rejects invalid code_verifier (S256)" do
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
# Use a properly formatted but wrong verifier (43+ chars, base64url)
code_verifier: "wrongverifier_with_enough_characters_base64url"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/Invalid code verifier/, error["error_description"])
end
test "token endpoint accepts valid code_verifier (S256)" do
# Generate valid PKCE pair
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = Digest::SHA256.base64digest(code_verifier)
.tr("+/", "-_")
.tr("=", "")
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
test "token endpoint accepts valid code_verifier (plain)" do
code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
# Create authorization code with PKCE plain
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_verifier, # Same as verifier for plain method
code_challenge_method: "plain",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
test "token endpoint works without PKCE (backward compatibility)" do
# Create authorization code without PKCE
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
end

View File

@@ -0,0 +1,235 @@
require "test_helper"
class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:alice)
@application = applications(:kavita_app)
# Store a known client secret for testing
@client_secret = SecureRandom.urlsafe_base64(48)
@application.client_secret = @client_secret
@application.save!
end
test "token endpoint returns refresh_token with authorization_code grant" do
# Create an authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: @application.parsed_redirect_uris.first,
scope: "openid profile email",
expires_at: 10.minutes.from_now
)
# Exchange authorization code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
json = JSON.parse(response.body)
assert json["access_token"].present?
assert json["id_token"].present?
assert json["refresh_token"].present?
assert_equal "Bearer", json["token_type"]
assert_equal 3600, json["expires_in"]
end
test "refresh_token grant exchanges refresh token for new tokens" do
# Create access and refresh tokens
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
# Store the plaintext refresh token (available only during creation)
plaintext_refresh_token = refresh_token.token
# Use refresh token to get new tokens
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
json = JSON.parse(response.body)
assert json["access_token"].present?
assert json["id_token"].present?
assert json["refresh_token"].present?
assert_equal "Bearer", json["token_type"]
# Old refresh token should be revoked
assert refresh_token.reload.revoked?
end
test "refresh_token grant fails with expired refresh token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email",
expires_at: 1.hour.ago # Expired
)
plaintext_refresh_token = refresh_token.token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_grant", json["error"]
end
test "refresh_token grant fails with revoked refresh token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
plaintext_refresh_token = refresh_token.token
refresh_token.revoke!
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_grant", json["error"]
end
test "token revocation endpoint revokes access tokens" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
plaintext_access_token = access_token.plaintext_token
post "/oauth/revoke", params: {
token: plaintext_access_token,
token_type_hint: "access_token",
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
assert access_token.reload.revoked?
end
test "token revocation endpoint revokes refresh tokens" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
plaintext_refresh_token = refresh_token.token
post "/oauth/revoke", params: {
token: plaintext_refresh_token,
token_type_hint: "refresh_token",
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
assert refresh_token.reload.revoked?
end
test "token rotation: new refresh token has same family id" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
old_refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
family_id = old_refresh_token.token_family_id
plaintext_refresh_token = old_refresh_token.token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
# Find the new refresh token
new_refresh_token = OidcRefreshToken.active.where(user: @user, application: @application).last
assert_equal family_id, new_refresh_token.token_family_id
end
test "userinfo endpoint works with hashed access token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
plaintext_token = access_token.plaintext_token
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
assert_equal @user.id.to_s, json["sub"]
assert_equal @user.email_address, json["email"]
end
end

View File

@@ -11,7 +11,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "create" do
post passwords_path, params: { email_address: @user.email_address }
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
assert_redirected_to new_session_path
assert_redirected_to signin_path
follow_redirect!
assert_notice "reset instructions sent"
@@ -20,14 +20,14 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "create for an unknown user redirects but sends no mail" do
post passwords_path, params: { email_address: "missing-user@example.com" }
assert_enqueued_emails 0
assert_redirected_to new_session_path
assert_redirected_to signin_path
follow_redirect!
assert_notice "reset instructions sent"
end
test "edit" do
get edit_password_path(@user.password_reset_token)
get edit_password_path(@user.generate_token_for(:password_reset))
assert_response :success
end
@@ -41,8 +41,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "update" do
assert_changes -> { @user.reload.password_digest } do
put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" }
assert_redirected_to new_session_path
put password_path(@user.generate_token_for(:password_reset)), params: { password: "newpassword", password_confirmation: "newpassword" }
assert_redirected_to signin_path
end
follow_redirect!

View File

@@ -18,7 +18,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
test "create with invalid credentials" do
post session_path, params: { email_address: @user.email_address, password: "wrong" }
assert_redirected_to new_session_path
assert_redirected_to signin_path
assert_nil cookies[:session_id]
end
@@ -27,7 +27,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
delete session_path
assert_redirected_to new_session_path
assert_redirected_to signin_path
assert_empty cookies[:session_id]
end
end

View File

@@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
kavita_alice_claims:
application: kavita_app
user: alice
custom_claims: { "kavita_groups": ["admin"], "library_access": "all" }
abs_alice_claims:
application: audiobookshelf_app
user: alice
custom_claims: { "abs_groups": ["user"], "abs_permissions": { "canDownload": true, "canUpload": false } }

View File

@@ -24,3 +24,14 @@ another_app:
https://app.example.com/auth/callback
metadata: "{}"
active: true
audiobookshelf_app:
name: Audiobookshelf
slug: audiobookshelf
app_type: oidc
client_id: <%= SecureRandom.urlsafe_base64(32) %>
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
redirect_uris: |
https://abs.example.com/auth/openid/callback
metadata: "{}"
active: true

View File

@@ -1,5 +1,13 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: Group One
description: First test group
two:
name: Group Two
description: Second test group
admin_group:
name: Administrators
description: System administrators with full access

View File

@@ -1,5 +1,17 @@
<% password_digest = BCrypt::Password.create("password") %>
one:
email_address: one@example.com
password_digest: <%= password_digest %>
admin: false
status: 0 # active
two:
email_address: two@example.com
password_digest: <%= password_digest %>
admin: true
status: 0 # active
alice:
email_address: alice@example.com
password_digest: <%= password_digest %>

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